This commit is contained in:
2025-09-10 02:17:25 +10:00
parent c88d1965c3
commit 2c058a654f
11 changed files with 236 additions and 63 deletions

View File

@@ -19,6 +19,7 @@ pub enum LeadErrCode {
Syntax,
Server,
Unsupported,
Invalid,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]

View File

@@ -7,6 +7,7 @@ use crate::common::Literal;
use crate::grid::Grid;
use crate::parser::*;
use std::collections::HashSet;
use std::f64;
use std::fmt;
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)]
@@ -117,6 +118,15 @@ fn evaluate_expr(
Expr::Group(g) => evaluate_expr(g, precs, grid)?,
Expr::Function { name, args } => match name.as_str() {
"AVG" => eval_avg(args, precs, grid)?,
"EXP" => eval_single_arg_numeric(args, precs, grid, |x| x.exp(), "EXP".into())?,
"SIN" => eval_single_arg_numeric(args, precs, grid, |x| x.sin(), "SIN".into())?,
"COS" => eval_single_arg_numeric(args, precs, grid, |x| x.cos(), "COS".into())?,
"TAN" => eval_single_arg_numeric(args, precs, grid, |x| x.tan(), "TAN".into())?,
"ASIN" => eval_single_arg_numeric(args, precs, grid, |x| x.asin(), "ASIN".into())?,
"ACOS" => eval_single_arg_numeric(args, precs, grid, |x| x.acos(), "ACOS".into())?,
"ATAN" => eval_single_arg_numeric(args, precs, grid, |x| x.atan(), "ATAN".into())?,
"PI" => eval_const(args, Eval::Literal(Literal::Number(f64::consts::PI)))?,
"TAU" => eval_const(args, Eval::Literal(Literal::Number(f64::consts::TAU)))?,
it => {
return Err(LeadErr {
title: "Evaluation error.".into(),
@@ -256,6 +266,49 @@ fn eval_avg(
}
}
fn eval_single_arg_numeric(
args: &Vec<Expr>,
precs: &mut HashSet<CellRef>,
grid: Option<&Grid>,
func: fn(f64) -> f64,
func_name: String,
) -> Result<Eval, LeadErr> {
if args.len() != 1 {
return Err(LeadErr {
title: "Evaluation error.".into(),
desc: format!("{func_name} function requires a single argument."),
code: LeadErrCode::Invalid,
});
}
let err = LeadErr {
title: "Evaluation error.".into(),
desc: format!("{func_name} function requires a numeric argument."),
code: LeadErrCode::TypeErr,
};
match evaluate_expr(&args[0], precs, grid)? {
Eval::Literal(Literal::Number(num)) => Ok(Eval::Literal(Literal::Number(func(num)))),
Eval::CellRef { eval, .. } => match *eval {
Eval::Literal(Literal::Number(n)) => Ok(Eval::Literal(Literal::Number(func(n)))),
_ => Err(err),
},
_ => Err(err),
}
}
fn eval_const(args: &Vec<Expr>, value: Eval) -> Result<Eval, LeadErr> {
if args.len() != 0 {
return Err(LeadErr {
title: "Evaluation error.".into(),
desc: format!("PI function requires no arguments."),
code: LeadErrCode::Invalid,
});
}
Ok(value)
}
fn eval_add(lval: &Eval, rval: &Eval) -> Result<Eval, LeadErr> {
match (lval, rval) {
(Eval::Literal(a), Eval::Literal(b)) => {

View File

@@ -79,6 +79,11 @@ async fn accept_connection(stream: TcpStream) {
}
}
if msgs.len() == 1 {
let _ = write
.send(serde_json::to_string(&msgs.get(0)).unwrap().into())
.await;
} else if msgs.len() > 1 {
let msg = LeadMsg {
cell: None,
raw: None,
@@ -91,6 +96,7 @@ async fn accept_connection(stream: TcpStream) {
.send(serde_json::to_string(&msg).unwrap().into())
.await;
}
}
Err(e) => {
let res = LeadMsg {
msg_type: MsgType::Error,

View File

@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use crate::{cell::CellRef, evaluator::Eval};
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "lowercase")]
pub enum MsgType {
Set,
@@ -11,7 +11,7 @@ pub enum MsgType {
Bulk,
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, Debug)]
pub struct LeadMsg {
pub msg_type: MsgType,
pub cell: Option<CellRef>,

View File

@@ -1,7 +1,8 @@
<script lang="ts">
import { Input } from '$lib/components/ui/input/index.js';
import clsx from 'clsx';
import type { CellT } from './utils';
import { getErrDesc, getErrTitle, getEvalLiteral, isErr, type CellT } from './utils';
import * as HoverCard from '$lib/components/ui/hover-card/index.js';
let {
cla = '',
@@ -56,15 +57,30 @@
focus:z-20 focus:shadow-[0_0_0_1px_var(--color-primary)] focus:outline-none"
onblur={(e) => {
cell = {
isErr: false,
val: undefined,
val: cell?.val,
raw_val: (e.target as HTMLInputElement).value
};
stopediting();
}}
/>
</div>
{:else if cell && isErr(cell.val)}
<HoverCard.Root openDelay={500} closeDelay={500}>
<HoverCard.Trigger>
{@render InnerCell()}
</HoverCard.Trigger>
<HoverCard.Content side="right">
<h2 class="text-md font-semibold tracking-tight transition-colors">
{getErrTitle(cell.val)}
</h2>
{getErrDesc(cell.val)}
</HoverCard.Content>
</HoverCard.Root>
{:else}
{@render InnerCell()}
{/if}
{#snippet InnerCell()}
<div
ondblclick={startediting}
{onmousedown}
@@ -72,17 +88,17 @@
style:height
class={clsx('placeholder bg-background p-1', { active }, cla)}
>
{#if cell && (cell.raw_val !== '' || cell.val !== '')}
<span class={clsx('pointer-events-none select-none', { err: cell.isErr })}>
{#if cell && (cell.raw_val !== '' || getEvalLiteral(cell.val) !== '')}
<span class={clsx('pointer-events-none select-none', { err: isErr(cell.val) })}>
{#if cell.val}
{cell.val}
{getEvalLiteral(cell.val)}
{:else}
{cell.raw_val}
{/if}
</span>
{/if}
</div>
{/if}
{/snippet}
<style>
.placeholder {
@@ -101,6 +117,7 @@
.active:has(.err),
.placeholder:has(.err) {
position: relative; /* needed for absolute positioning */
color: red;
}
.active:has(.err)::after,

View File

@@ -1,10 +1,12 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { Pencil } from '@lucide/svelte';
import Cell from '$lib/components/grid/cell.svelte';
import { onDestroy, onMount } from 'svelte';
import CellHeader from './cell-header.svelte';
import { colToStr, getEvalLiteral, isErr, refToStr, type CellT } from './utils';
import { colToStr, refToStr, type CellT } from './utils';
import clsx from 'clsx';
import { Input } from '../ui/input';
let {
socket,
@@ -44,7 +46,12 @@
console.error('Expected cell value for SET msgponse from server.');
return;
}
setCellVal(msg.cell.row, msg.cell.col, getEvalLiteral(msg.eval), isErr(msg.eval));
grid_vals[key(msg.cell.row, msg.cell.col)] = {
raw_val: msg.raw ?? '',
val: msg.eval
};
break;
}
case 'bulk': {
@@ -108,35 +115,16 @@
const key = (i: number, j: number) => `${i}:${j}`;
const getCell = (i: number, j: number) => grid_vals[key(i, j)];
const getCellRaw = (i: number, j: number) => getCell(i, j)?.raw_val ?? '';
const setCellRaw = (i: number, j: number, val: string) => {
if (grid_vals[key(i, j)] === undefined) {
grid_vals[key(i, j)] = {
raw_val: val,
isErr: false,
val: undefined
};
} else {
grid_vals[key(i, j)].raw_val = val;
const setCell = (row: number, col: number, v: CellT) => {
if (v?.raw_val == null || v.raw_val === '') {
delete grid_vals[key(row, col)];
return;
}
};
const getCellVal = (i: number, j: number) => getCell(i, j);
const setCellVal = (i: number, j: number, val: LiteralValue, isErr: boolean) => {
if (grid_vals[key(i, j)] === undefined) {
console.warn('Cell raw value was undefined but recieved eval.');
} else {
let cell = grid_vals[key(i, j)];
cell.val = val;
cell.isErr = isErr;
}
};
const setCell = (row: number, col: number, v: CellT | undefined) => {
// ignore “no value” so we dont create keys on mount
if (v?.raw_val == null || v.raw_val === '') delete grid_vals[key(row, col)];
else {
setCellRaw(row, col, v.raw_val);
grid_vals[key(row, col)] = {
raw_val: v.raw_val,
val: v.val
};
let msg: LeadMsg = {
msg_type: 'set',
@@ -145,7 +133,6 @@
};
socket.send(JSON.stringify(msg));
}
};
function handleCellInteraction(i: number, j: number, e: MouseEvent) {
@@ -180,6 +167,33 @@
active_cell = [i, j];
}
function getActiveCell(): CellT {
if (active_cell != null && grid_vals[key(active_cell[0], active_cell[1])])
return grid_vals[key(active_cell[0], active_cell[1])];
else
return {
raw_val: '',
val: undefined
};
}
function setActiveCellRaw(raw: string): void {
if (active_cell == null) return;
if (grid_vals[key(active_cell[0], active_cell[1])]) {
let cell = grid_vals[key(active_cell[0], active_cell[1])];
setCell(active_cell[0], active_cell[1], {
raw_val: raw,
val: cell.val
});
} else {
setCell(active_cell[0], active_cell[1], {
raw_val: raw,
val: undefined
});
}
}
onMount(() => {
const handler = (e: MouseEvent) => {
// optional: check if click target is outside grid container
@@ -192,6 +206,15 @@
});
</script>
<div class="mb-5 ml-5 flex items-center gap-5">
<Pencil />
<Input
bind:value={() => getActiveCell().raw_val, (raw) => setActiveCellRaw(raw)}
class="relative w-[200px] rounded-none p-1 !transition-none delay-0 duration-0
focus:z-20 focus:shadow-[0_0_0_1px_var(--color-primary)] focus:outline-none"
></Input>
</div>
<div
class={clsx('grid-wrapper relative h-full min-h-0 max-w-full min-w-0 overflow-auto', className)}
>

View File

@@ -1,7 +1,6 @@
export interface CellT {
raw_val: string;
val: LiteralValue | undefined;
isErr: boolean;
val?: Eval;
}
/**
@@ -44,16 +43,38 @@ export function refToStr(row: number, col: number): string {
return colToStr(col) + (row + 1).toString();
}
export function getEvalLiteral(value: Eval): LiteralValue {
export function getEvalLiteral(value: Eval | undefined): LiteralValue {
if (value === undefined) return '';
if (value === 'unset') return '';
if ('literal' in value) return value.literal.value;
if ('literal' in value) {
if (value.literal.value == null) return 'NaN';
return value.literal.value;
}
if ('cellref' in value) return getEvalLiteral(value.cellref.eval);
if ('err' in value) return `err: ${value.err.code}`;
if ('err' in value) return `#${value.err.code.toUpperCase()}`;
// if ('range' in value) return 'err';
return 'todo!';
}
export function isErr(value: Eval): boolean {
export function isErr(value: Eval | undefined): boolean {
if (value === undefined) return false;
if (value === 'unset') return false;
if ('cellref' in value) return isErr(value.cellref.eval);
return 'err' in value;
}
export function getErrTitle(value: Eval | undefined): string {
if (value === undefined) return '';
if (value === 'unset') return '';
if ('cellref' in value) return getErrTitle(value.cellref.eval);
if (!('err' in value)) return '';
return value.err.title;
}
export function getErrDesc(value: Eval | undefined): string {
if (value === undefined) return '';
if (value === 'unset') return '';
if ('cellref' in value) return getErrDesc(value.cellref.eval);
if (!('err' in value)) return '';
return value.err.desc;
}

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { LinkPreview as HoverCardPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
align = 'center',
sideOffset = 4,
portalProps,
...restProps
}: HoverCardPrimitive.ContentProps & {
portalProps?: HoverCardPrimitive.PortalProps;
} = $props();
</script>
<HoverCardPrimitive.Portal {...portalProps}>
<HoverCardPrimitive.Content
bind:ref
data-slot="hover-card-content"
{align}
{sideOffset}
class={cn(
'z-[200] mt-3 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
{...restProps}
/>
</HoverCardPrimitive.Portal>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { LinkPreview as HoverCardPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: HoverCardPrimitive.TriggerProps = $props();
</script>
<HoverCardPrimitive.Trigger bind:ref data-slot="hover-card-trigger" {...restProps} />

View File

@@ -0,0 +1,14 @@
import { LinkPreview as HoverCardPrimitive } from "bits-ui";
import Content from "./hover-card-content.svelte";
import Trigger from "./hover-card-trigger.svelte";
const Root = HoverCardPrimitive.Root;
export {
Root,
Content,
Trigger,
Root as HoverCard,
Content as HoverCardContent,
Trigger as HoverCardTrigger,
};

View File

@@ -21,8 +21,10 @@
<div class="absolute left-0 min-h-0 w-full">
<div class="flex h-[100vh] flex-col">
<div class="h-[60px] w-full p-3">
<div class="flex items-center gap-5">
<Sidebar.Trigger />
</div>
</div>
<div class="grid-wrapper min-h-0 w-full flex-1">
<Grid class="h-full min-w-0" {socket} />
@@ -32,6 +34,6 @@
<style>
.grid-wrapper {
border-top: 2px solid var(--color-input);
/* border-top: 2px solid var(--color-input); */
}
</style>