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, Syntax,
Server, Server,
Unsupported, Unsupported,
Invalid,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]

View File

@@ -7,6 +7,7 @@ use crate::common::Literal;
use crate::grid::Grid; use crate::grid::Grid;
use crate::parser::*; use crate::parser::*;
use std::collections::HashSet; use std::collections::HashSet;
use std::f64;
use std::fmt; use std::fmt;
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)]
@@ -117,6 +118,15 @@ fn evaluate_expr(
Expr::Group(g) => evaluate_expr(g, precs, grid)?, Expr::Group(g) => evaluate_expr(g, precs, grid)?,
Expr::Function { name, args } => match name.as_str() { Expr::Function { name, args } => match name.as_str() {
"AVG" => eval_avg(args, precs, grid)?, "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 => { it => {
return Err(LeadErr { return Err(LeadErr {
title: "Evaluation error.".into(), 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> { fn eval_add(lval: &Eval, rval: &Eval) -> Result<Eval, LeadErr> {
match (lval, rval) { match (lval, rval) {
(Eval::Literal(a), Eval::Literal(b)) => { (Eval::Literal(a), Eval::Literal(b)) => {

View File

@@ -79,17 +79,23 @@ async fn accept_connection(stream: TcpStream) {
} }
} }
let msg = LeadMsg { if msgs.len() == 1 {
cell: None, let _ = write
raw: None, .send(serde_json::to_string(&msgs.get(0)).unwrap().into())
eval: None, .await;
bulk_msgs: Some(msgs), } else if msgs.len() > 1 {
msg_type: MsgType::Bulk, let msg = LeadMsg {
}; cell: None,
raw: None,
eval: None,
bulk_msgs: Some(msgs),
msg_type: MsgType::Bulk,
};
let _ = write let _ = write
.send(serde_json::to_string(&msg).unwrap().into()) .send(serde_json::to_string(&msg).unwrap().into())
.await; .await;
}
} }
Err(e) => { Err(e) => {
let res = LeadMsg { let res = LeadMsg {

View File

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

View File

@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { Input } from '$lib/components/ui/input/index.js'; import { Input } from '$lib/components/ui/input/index.js';
import clsx from 'clsx'; 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 { let {
cla = '', cla = '',
@@ -56,15 +57,30 @@
focus:z-20 focus:shadow-[0_0_0_1px_var(--color-primary)] focus:outline-none" focus:z-20 focus:shadow-[0_0_0_1px_var(--color-primary)] focus:outline-none"
onblur={(e) => { onblur={(e) => {
cell = { cell = {
isErr: false, val: cell?.val,
val: undefined,
raw_val: (e.target as HTMLInputElement).value raw_val: (e.target as HTMLInputElement).value
}; };
stopediting(); stopediting();
}} }}
/> />
</div> </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} {:else}
{@render InnerCell()}
{/if}
{#snippet InnerCell()}
<div <div
ondblclick={startediting} ondblclick={startediting}
{onmousedown} {onmousedown}
@@ -72,17 +88,17 @@
style:height style:height
class={clsx('placeholder bg-background p-1', { active }, cla)} class={clsx('placeholder bg-background p-1', { active }, cla)}
> >
{#if cell && (cell.raw_val !== '' || cell.val !== '')} {#if cell && (cell.raw_val !== '' || getEvalLiteral(cell.val) !== '')}
<span class={clsx('pointer-events-none select-none', { err: cell.isErr })}> <span class={clsx('pointer-events-none select-none', { err: isErr(cell.val) })}>
{#if cell.val} {#if cell.val}
{cell.val} {getEvalLiteral(cell.val)}
{:else} {:else}
{cell.raw_val} {cell.raw_val}
{/if} {/if}
</span> </span>
{/if} {/if}
</div> </div>
{/if} {/snippet}
<style> <style>
.placeholder { .placeholder {
@@ -101,6 +117,7 @@
.active:has(.err), .active:has(.err),
.placeholder:has(.err) { .placeholder:has(.err) {
position: relative; /* needed for absolute positioning */ position: relative; /* needed for absolute positioning */
color: red;
} }
.active:has(.err)::after, .active:has(.err)::after,

View File

@@ -1,10 +1,12 @@
<script lang="ts"> <script lang="ts">
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { Pencil } from '@lucide/svelte';
import Cell from '$lib/components/grid/cell.svelte'; import Cell from '$lib/components/grid/cell.svelte';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import CellHeader from './cell-header.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 clsx from 'clsx';
import { Input } from '../ui/input';
let { let {
socket, socket,
@@ -44,7 +46,12 @@
console.error('Expected cell value for SET msgponse from server.'); console.error('Expected cell value for SET msgponse from server.');
return; 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; break;
} }
case 'bulk': { case 'bulk': {
@@ -108,44 +115,24 @@
const key = (i: number, j: number) => `${i}:${j}`; const key = (i: number, j: number) => `${i}:${j}`;
const getCell = (i: number, j: number) => grid_vals[key(i, j)]; const getCell = (i: number, j: number) => grid_vals[key(i, j)];
const setCell = (row: number, col: number, v: CellT) => {
const getCellRaw = (i: number, j: number) => getCell(i, j)?.raw_val ?? ''; if (v?.raw_val == null || v.raw_val === '') {
const setCellRaw = (i: number, j: number, val: string) => { delete grid_vals[key(row, col)];
if (grid_vals[key(i, j)] === undefined) { return;
grid_vals[key(i, j)] = {
raw_val: val,
isErr: false,
val: undefined
};
} else {
grid_vals[key(i, j)].raw_val = val;
} }
};
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) => { grid_vals[key(row, col)] = {
// ignore “no value” so we dont create keys on mount raw_val: v.raw_val,
if (v?.raw_val == null || v.raw_val === '') delete grid_vals[key(row, col)]; val: v.val
else { };
setCellRaw(row, col, v.raw_val);
let msg: LeadMsg = { let msg: LeadMsg = {
msg_type: 'set', msg_type: 'set',
cell: { row, col }, cell: { row, col },
raw: v.raw_val raw: v.raw_val
}; };
socket.send(JSON.stringify(msg)); socket.send(JSON.stringify(msg));
}
}; };
function handleCellInteraction(i: number, j: number, e: MouseEvent) { function handleCellInteraction(i: number, j: number, e: MouseEvent) {
@@ -180,6 +167,33 @@
active_cell = [i, j]; 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(() => { onMount(() => {
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
// optional: check if click target is outside grid container // optional: check if click target is outside grid container
@@ -192,6 +206,15 @@
}); });
</script> </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 <div
class={clsx('grid-wrapper relative h-full min-h-0 max-w-full min-w-0 overflow-auto', className)} 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 { export interface CellT {
raw_val: string; raw_val: string;
val: LiteralValue | undefined; val?: Eval;
isErr: boolean;
} }
/** /**
@@ -44,16 +43,38 @@ export function refToStr(row: number, col: number): string {
return colToStr(col) + (row + 1).toString(); 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 (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 ('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'; // if ('range' in value) return 'err';
return 'todo!'; 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 (value === 'unset') return false;
if ('cellref' in value) return isErr(value.cellref.eval);
return 'err' in value; 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,7 +21,9 @@
<div class="absolute left-0 min-h-0 w-full"> <div class="absolute left-0 min-h-0 w-full">
<div class="flex h-[100vh] flex-col"> <div class="flex h-[100vh] flex-col">
<div class="h-[60px] w-full p-3"> <div class="h-[60px] w-full p-3">
<Sidebar.Trigger /> <div class="flex items-center gap-5">
<Sidebar.Trigger />
</div>
</div> </div>
<div class="grid-wrapper min-h-0 w-full flex-1"> <div class="grid-wrapper min-h-0 w-full flex-1">
@@ -32,6 +34,6 @@
<style> <style>
.grid-wrapper { .grid-wrapper {
border-top: 2px solid var(--color-input); /* border-top: 2px solid var(--color-input); */
} }
</style> </style>