This commit is contained in:
2025-09-10 15:54:52 +10:00
parent c41d5487a9
commit e17d66fed1
10 changed files with 349 additions and 244 deletions

View File

@@ -23,13 +23,8 @@ impl Grid {
&mut self, &mut self,
cell_ref: CellRef, cell_ref: CellRef,
raw_val: String, raw_val: String,
do_propagation: bool,
force_propagation: bool,
) -> Result<Vec<CellRef>, String> { ) -> Result<Vec<CellRef>, String> {
if self.cells.contains_key(&cell_ref) if self.cells.contains_key(&cell_ref) && self.cells[&cell_ref].raw() == raw_val {
&& self.cells[&cell_ref].raw() == raw_val
&& !force_propagation
{
return Ok(Vec::new()); return Ok(Vec::new());
} }
@@ -48,14 +43,7 @@ impl Grid {
if self.cells.contains_key(&cell_ref) { if self.cells.contains_key(&cell_ref) {
updated_cells = self updated_cells = self
.update_exisiting_cell( .update_exisiting_cell(raw_val, eval, precs, cell_ref)?
raw_val,
eval,
precs,
cell_ref,
do_propagation,
force_propagation,
)?
.into_iter() .into_iter()
.chain(updated_cells) .chain(updated_cells)
.collect(); .collect();
@@ -66,6 +54,15 @@ impl Grid {
Ok(updated_cells) Ok(updated_cells)
} }
pub fn quick_eval(&mut self, raw_val: String) -> Eval {
if raw_val.chars().nth(0) != Some('=') {
Eval::Literal(Literal::String(raw_val.to_owned()))
} else {
let (res_eval, ..) = evaluate(raw_val[1..].to_owned(), Some(&self));
res_eval
}
}
pub fn get_cell(&self, cell_ref: CellRef) -> Result<Cell, String> { pub fn get_cell(&self, cell_ref: CellRef) -> Result<Cell, String> {
if !self.cells.contains_key(&cell_ref) { if !self.cells.contains_key(&cell_ref) {
return Err(format!("Cell at {:?} not found.", cell_ref)); return Err(format!("Cell at {:?} not found.", cell_ref));
@@ -168,8 +165,6 @@ impl Grid {
new_eval: Eval, new_eval: Eval,
new_precs: HashSet<CellRef>, new_precs: HashSet<CellRef>,
cell_ref: CellRef, cell_ref: CellRef,
do_propagation: bool,
force_propagation: bool,
) -> Result<Vec<CellRef>, String> { ) -> Result<Vec<CellRef>, String> {
let (old_precs, old_eval) = match self.cells.get_mut(&cell_ref) { let (old_precs, old_eval) = match self.cells.get_mut(&cell_ref) {
Some(cell) => { Some(cell) => {
@@ -211,7 +206,7 @@ impl Grid {
cell.set_precs(new_precs); cell.set_precs(new_precs);
cell.set_eval(new_eval); cell.set_eval(new_eval);
if (eval_changed && do_propagation) || force_propagation { if eval_changed {
self.propagate(cell_ref) self.propagate(cell_ref)
} else { } else {
Ok(Vec::new()) Ok(Vec::new())

View File

@@ -62,16 +62,9 @@ async fn accept_connection(stream: TcpStream) {
MsgType::Set => { MsgType::Set => {
let Some(cell_ref) = req.cell else { continue }; let Some(cell_ref) = req.cell else { continue };
let Some(raw) = req.raw else { continue }; let Some(raw) = req.raw else { continue };
let Some(config) = req.eval_config else { // let config = req.eval_config.unwrap_or_default();
continue;
};
match grid.update_cell( match grid.update_cell(cell_ref.clone(), raw.to_owned()) {
cell_ref.clone(),
raw.to_owned(),
config.do_propagation,
config.force_propagation,
) {
Ok(updates) => { Ok(updates) => {
let mut msgs = Vec::new(); let mut msgs = Vec::new();
@@ -122,6 +115,25 @@ async fn accept_connection(stream: TcpStream) {
} }
} }
} }
MsgType::Eval => {
let Some(cell_ref) = req.cell else { continue };
let Some(raw) = req.raw else { continue };
let eval = grid.quick_eval(raw.to_owned());
let msg = LeadMsg {
msg_type: MsgType::Eval,
cell: Some(cell_ref),
raw: Some(raw),
eval: Some(eval),
bulk_msgs: None,
eval_config: None,
};
let _ = write
.send(serde_json::to_string(&msg).unwrap().into())
.await;
}
_ => { _ => {
continue; // handle other cases continue; // handle other cases
} }

View File

@@ -6,6 +6,7 @@ use crate::{cell::CellRef, evaluator::Eval};
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum MsgType { pub enum MsgType {
Set, Set,
Eval,
Get, Get,
Error, Error,
Bulk, Bulk,
@@ -17,6 +18,15 @@ pub struct EvalConfig {
pub force_propagation: bool, pub force_propagation: bool,
} }
impl Default for EvalConfig {
fn default() -> Self {
EvalConfig {
do_propagation: true,
force_propagation: false,
}
}
}
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub struct LeadMsg { pub struct LeadMsg {
pub msg_type: MsgType, pub msg_type: MsgType,

View File

@@ -102,10 +102,10 @@
border-left: none; border-left: none;
} }
.placeholder.blank, /* .placeholder.blank, */
.placeholder.col { /* .placeholder.col { */
border-top: none; /* border-top: none; */
} /* } */
.active { .active {
background-color: color-mix(in oklab, var(--color-primary) 80%, var(--color-background) 80%); background-color: color-mix(in oklab, var(--color-primary) 80%, var(--color-background) 80%);

View File

@@ -13,7 +13,8 @@
startediting = () => {}, startediting = () => {},
stopediting = () => {}, stopediting = () => {},
active = false, active = false,
editing = false editing = false,
externalediting = false
}: { }: {
cla?: string; cla?: string;
width?: string; width?: string;
@@ -24,6 +25,7 @@
stopediting?: () => void; stopediting?: () => void;
active?: boolean; active?: boolean;
editing?: boolean; editing?: boolean;
externalediting?: boolean;
} = $props(); } = $props();
// focus the first focusable descendant (the inner <input>) // focus the first focusable descendant (the inner <input>)
@@ -101,7 +103,7 @@
> >
{#if cell && (cell.raw_val !== '' || getEvalLiteral(cell.val) !== '')} {#if cell && (cell.raw_val !== '' || getEvalLiteral(cell.val) !== '')}
<span class={clsx('pointer-events-none select-none', { err: isErr(cell.val) })}> <span class={clsx('pointer-events-none select-none', { err: isErr(cell.val) })}>
{#if cell.val} {#if cell.val && !externalediting}
{getEvalLiteral(cell.val)} {getEvalLiteral(cell.val)}
{:else} {:else}
{cell.raw_val} {cell.raw_val}

View File

View File

@@ -1,21 +1,13 @@
<script lang="ts"> <script lang="ts">
import { toast } from 'svelte-sonner'; import { Omega } from '@lucide/svelte';
import {
Infinity,
Omega,
Parentheses,
Pyramid,
Radical,
Sigma,
SquareFunction,
Variable
} 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 { onMount } from 'svelte';
import CellHeader from './cell-header.svelte'; import CellHeader from './cell-header.svelte';
import { colToStr, refToStr, type CellT } from './utils'; import { colToStr, refToStr } from './utils';
import clsx from 'clsx'; import clsx from 'clsx';
import { Input } from '../ui/input'; import { Input } from '../ui/input';
import { Grid, Position } from './grid';
import type { LeadMsg } from './messages';
let { let {
socket, socket,
@@ -36,186 +28,47 @@
return; return;
} }
handle_msg(res); grid.handle_msg(res);
}; };
function handle_msg(msg: LeadMsg) {
switch (msg.msg_type) {
case 'error': {
toast.error('Error', {
description: msg.raw
});
break;
}
case 'set': {
if (msg.cell === undefined) {
console.error('Expected cell ref for SET msgponse from server.');
return;
} else if (msg.eval === undefined) {
console.error('Expected cell value for SET msgponse from server.');
return;
}
grid_vals[key(msg.cell.row, msg.cell.col)] = {
raw_val: msg.raw ?? '',
val: msg.eval
};
break;
}
case 'bulk': {
if (msg.bulk_msgs === undefined) {
console.error('Expected bulk_msgs field to be defined for BULK message.');
return;
}
for (const m of msg.bulk_msgs) handle_msg(m);
}
}
}
let rows = 50; let rows = 50;
let cols = 40; let cols = 40;
let default_row_height = '30px'; let grid = $state(new Grid(socket));
let default_col_width = '80px';
// Only store touched cells
let grid_vals: Record<string, CellT> = $state({});
let row_heights: Record<number, string> = $state({});
let col_widths: Record<number, string> = $state({});
function getRowHeight(row: number) {
return row_heights[row] ?? default_row_height;
}
function getColWidth(col: number) {
return col_widths[col] ?? default_col_width;
}
function setRowHeight(row: number, height: string) {
if (height === default_row_height) {
delete row_heights[row];
} else {
row_heights[row] = height;
}
}
function setColWidth(col: number, width: string) {
if (width === default_col_width) {
delete col_widths[col];
} else {
col_widths[col] = width;
}
}
let active_cell: [number, number] | null = $state(null);
let editing_cell: [number, number] | null = $state(null);
function startEditing(i: number, j: number) {
active_cell = [i, j];
editing_cell = [i, j];
}
function stopEditing(i: number, j: number) {
editing_cell = null;
setCell(i, j, getCell(i, j), { do_propagation: true, force_propagation: true });
}
const key = (i: number, j: number) => `${i}:${j}`;
const getCell = (i: number, j: number) => grid_vals[key(i, j)];
const setCell = (row: number, col: number, v: CellT, eval_config: EvalConfig) => {
if (v?.raw_val == null || v.raw_val === '') {
delete grid_vals[key(row, col)];
return;
}
grid_vals[key(row, col)] = {
raw_val: v.raw_val,
val: v.val
};
let msg: LeadMsg = {
msg_type: 'set',
cell: { row, col },
raw: v.raw_val,
eval_config
};
socket.send(JSON.stringify(msg));
};
function handleCellInteraction(i: number, j: number, e: MouseEvent) { function handleCellInteraction(i: number, j: number, e: MouseEvent) {
if (editing_cell) { let pos = new Position(i, j);
// Get the actual input element that's being edited console.log('clicked', pos);
const el = document.querySelector<HTMLInputElement>('input:focus'); //
const currentInputValue = el?.value ?? ''; // if (grid.isEditing(pos)) {
// // Get the actual input element that's being edited
// ONLY treat this as a reference insert if it's a formula // const el = document.querySelector<HTMLInputElement>('input:focus');
if (currentInputValue.trim().startsWith('=')) { // const currentInputValue = el?.value ?? '';
// Prevent the input from losing focus //
e.preventDefault(); // // ONLY treat this as a reference insert if it's a formula
// if (currentInputValue.trim().startsWith('=')) {
// --- This is the same reference-inserting logic as before --- // // Prevent the input from losing focus
const ref = refToStr(i, j); // e.preventDefault();
if (el) { //
const { selectionStart, selectionEnd } = el; // // --- This is the same reference-inserting logic as before ---
const before = el.value.slice(0, selectionStart ?? 0); // const ref = refToStr(i, j);
const after = el.value.slice(selectionEnd ?? 0); // if (el) {
el.value = before + ref + after; // const { selectionStart, selectionEnd } = el;
const newPos = (selectionStart ?? 0) + ref.length; // const before = el.value.slice(0, selectionStart ?? 0);
el.setSelectionRange(newPos, newPos); // const after = el.value.slice(selectionEnd ?? 0);
el.dispatchEvent(new Event('input', { bubbles: true })); // el.value = before + ref + after;
el.focus(); // const newPos = (selectionStart ?? 0) + ref.length;
} // el.setSelectionRange(newPos, newPos);
// el.dispatchEvent(new Event('input', { bubbles: true }));
return; // el.focus();
} // }
} //
// return;
// }
// }
// We are not editing, so this is a normal cell selection OR this is not a formula // We are not editing, so this is a normal cell selection OR this is not a formula
active_cell = [i, j]; grid.setActive(pos);
}
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
},
{ do_propagation: false, force_propagation: false }
);
} else {
setCell(
active_cell[0],
active_cell[1],
{
raw_val: raw,
val: undefined
},
{
do_propagation: false,
force_propagation: false
}
);
}
} }
onMount(() => { onMount(() => {
@@ -231,15 +84,33 @@
</script> </script>
<div class="relative mb-5 ml-5 flex items-center gap-[5px]"> <div class="relative mb-5 ml-5 flex items-center gap-[5px]">
<div class="relative"> <div
class="relative"
onkeydown={(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === 'NumpadEnter') {
e.preventDefault(); // avoid form submit/line break
const el = (e.currentTarget as HTMLElement).querySelector(
'input'
) as HTMLInputElement | null;
el?.blur(); // triggers on:blur below
} else if (e.key == 'Escape') {
e.preventDefault();
grid.stopEditingActive();
}
}}
>
<Omega <Omega
size="20px" size="20px"
class="absolute top-1/2 left-2 -translate-y-1/2 text-muted-foreground" class="absolute top-1/2 left-2 z-10 -translate-y-1/2 text-muted-foreground"
strokeWidth={1}
/> />
<Input <Input
bind:value={() => getActiveCell().raw_val, (raw) => setActiveCellRaw(raw)} onmousedown={() => grid.setExternalEdit(grid.getActivePos())}
class="relative w-[200px] pl-8" onblur={() => grid.setExternalEdit(null)}
bind:value={
() => grid.getActiveCell().raw_val, (raw) => grid.quickEval(grid.getActivePos(), raw)
}
class="relative w-[200px] pl-9"
></Input> ></Input>
</div> </div>
</div> </div>
@@ -251,8 +122,8 @@
<div class="sticky top-0 left-0" style="z-index: {rows + 70}"> <div class="sticky top-0 left-0" style="z-index: {rows + 70}">
<CellHeader <CellHeader
resizeable={false} resizeable={false}
height={default_row_height} height={grid.getDefaultRowHeight()}
width={default_col_width} width={grid.getDefaultColWidth()}
val="" val=""
active={false} active={false}
direction="blank" direction="blank"
@@ -261,12 +132,12 @@
{#each Array(cols) as _, j} {#each Array(cols) as _, j}
<CellHeader <CellHeader
height={default_row_height} height={grid.getDefaultRowHeight()}
width={getColWidth(j)} width={grid.getColWidth(j)}
setColWidth={(width) => setColWidth(j, width)} setColWidth={(width) => grid.setColWidth(j, width)}
direction="col" direction="col"
val={colToStr(j)} val={colToStr(j)}
active={active_cell !== null && active_cell[1] === j} active={grid.getActivePos() !== null && grid.getActivePos()?.col === j}
/> />
{/each} {/each}
</div> </div>
@@ -275,28 +146,31 @@
<div class="sticky left-0 flex w-fit" style="z-index: {rows - i + 40}"> <div class="sticky left-0 flex w-fit" style="z-index: {rows - i + 40}">
<CellHeader <CellHeader
direction="row" direction="row"
width={default_col_width} height={grid.getRowHeight(i)}
height={getRowHeight(i)} width={grid.getDefaultColWidth()}
setRowHeight={(height) => setRowHeight(i, height)} setRowHeight={(height) => grid.setRowHeight(i, height)}
val={(i + 1).toString()} val={(i + 1).toString()}
active={active_cell !== null && active_cell[0] === i} active={grid.getActivePos() !== null && grid.getActivePos()?.row === i}
/> />
</div> </div>
{#each Array(cols) as _, j} {#each Array(cols) as _, j}
<Cell <Cell
height={getRowHeight(i)} height={grid.getRowHeight(i)}
width={getColWidth(j)} width={grid.getColWidth(j)}
editing={editing_cell?.[0] === i && editing_cell?.[1] === j} editing={grid.isEditing(new Position(i, j))}
startediting={() => startEditing(i, j)} externalediting={grid.isExternalEditing(new Position(i, j))}
stopediting={() => stopEditing(i, j)} startediting={() => grid.startEditing(new Position(i, j))}
stopediting={() => grid.stopEditing(new Position(i, j))}
onmousedown={(e) => { onmousedown={(e) => {
handleCellInteraction(i, j, e); handleCellInteraction(i, j, e);
}} }}
bind:cell={ bind:cell={
() => getCell(i, j), () => grid.getCell(new Position(i, j)), (v) => grid.setCell(new Position(i, j), v)
(v) => setCell(i, j, v, { do_propagation: false, force_propagation: false })
} }
active={active_cell !== null && active_cell[0] === i && active_cell[1] === j} active={(() => {
console.log(grid.isActive(new Position(i, j)));
return grid.isActive(new Position(i, j));
})()}
/> />
{/each} {/each}
</div> </div>

View File

@@ -0,0 +1,208 @@
import { toast } from 'svelte-sonner';
import type { CellRef, CellT, LeadMsg } from './messages';
class Position {
public row: number;
public col: number;
constructor(row: number, col: number) {
this.row = row;
this.col = col;
}
public key() {
return `${this.row}:${this.col}`;
}
public static key(row: number, col: number) {
return `${row}:${col}`;
}
public ref(): CellRef {
return { row: this.row, col: this.col };
}
public equals(other: CellRef | null | undefined): boolean {
return !!other && this.row === other.row && this.col === other.col;
}
public static equals(a: CellRef | null | undefined, b: CellRef | null | undefined): boolean {
return !!a && !!b && a.row === b.row && a.col === b.col;
}
}
class Grid {
data: Record<string, CellT> = {};
socket: WebSocket;
row_heights: Record<number, string> = {};
col_widths: Record<number, string> = {};
default_row_height: string;
default_col_width: string;
active_cell: Position | null = null;
editing_cell: Position | null = null;
external_editing_cell: Position | null = null;
constructor(socket: WebSocket, default_col_width = '80px', default_row_height = '30px') {
this.socket = socket;
this.default_col_width = default_col_width;
this.default_row_height = default_row_height;
}
public getCell(pos: Position): CellT {
return this.data[pos.key()];
}
public setCell(pos: Position, v: CellT) {
if (v?.raw_val == null || v.raw_val === '') {
delete this.data[pos.key()];
return;
}
this.data[pos.key()] = {
raw_val: v.raw_val,
val: v.val
};
let msg: LeadMsg = {
msg_type: 'set',
cell: pos.ref(),
raw: v.raw_val
};
this.socket.send(JSON.stringify(msg));
}
public getRowHeight(row: number) {
return this.row_heights[row] ?? this.default_row_height;
}
public getColWidth(col: number) {
return this.col_widths[col] ?? this.default_col_width;
}
public getDefaultColWidth() {
return this.default_col_width;
}
public getDefaultRowHeight() {
return this.default_row_height;
}
public setRowHeight(row: number, height: string) {
if (height === this.default_row_height) {
delete this.row_heights[row];
} else {
this.row_heights[row] = height;
}
}
public setColWidth(col: number, width: string) {
if (width === this.default_col_width) {
delete this.col_widths[col];
} else {
this.col_widths[col] = width;
}
}
public startEditing(pos: Position) {
this.active_cell = pos;
this.editing_cell = pos;
}
public stopEditing(pos: Position) {
this.editing_cell = null;
this.setCell(pos, this.getCell(pos));
}
public stopEditingActive() {
if (this.active_cell == null) return;
this.stopEditing(this.active_cell);
}
public isEditing(pos: Position): boolean {
if (this.editing_cell == null) return false;
return this.editing_cell.equals(pos);
}
public isExternalEditing(pos: Position): boolean {
if (this.external_editing_cell == null) return false;
return this.external_editing_cell.equals(pos);
}
public setActive(pos: Position | null) {
this.active_cell = pos;
}
public setExternalEdit(pos: Position | null) {
this.external_editing_cell = pos;
}
public getActiveCell(): CellT {
if (this.active_cell === null) return {
raw_val: '',
val: undefined
};
return this.getCell(this.active_cell);
}
public getActivePos(): Position | null {
return this.active_cell;
}
public isActive(pos: Position): boolean {
if (this.active_cell == null) return false;
return this.active_cell.equals(pos);
}
public quickEval(pos: Position | null, raw: string) {
if (pos === null) return;
let msg: LeadMsg = {
msg_type: 'eval',
cell: pos.ref(),
raw: raw
};
this.socket.send(JSON.stringify(msg));
}
public handle_msg(msg: LeadMsg) {
switch (msg.msg_type) {
case 'error': {
toast.error('Error', {
description: msg.raw
});
break;
}
case 'set': {
if (msg.cell === undefined) {
console.error('Expected cell ref for SET msgponse from server.');
return;
} else if (msg.eval === undefined) {
console.error('Expected cell value for SET msgponse from server.');
return;
}
this.data[Position.key(msg.cell.row, msg.cell.col)] = {
raw_val: msg.raw ?? '',
val: msg.eval
};
break;
}
case 'bulk': {
if (msg.bulk_msgs === undefined) {
console.error('Expected bulk_msgs field to be defined for BULK message.');
return;
}
for (const m of msg.bulk_msgs) this.handle_msg(m);
}
case 'eval': {
// TODO
}
}
}
}
export { Grid, Position };

View File

@@ -1,5 +1,5 @@
interface LeadMsg { interface LeadMsg {
msg_type: 'set' | 'get' | 'error' | 'bulk'; msg_type: 'set' | 'get' | 'error' | 'bulk' | 'eval';
cell?: CellRef; cell?: CellRef;
raw?: string; raw?: string;
eval?: Eval; eval?: Eval;
@@ -44,3 +44,10 @@ type Eval =
| { range: Range } | { range: Range }
| { err: LeadErr } | { err: LeadErr }
| 'unset'; | 'unset';
interface CellT {
raw_val: string;
val?: Eval;
}
export type { Eval, LeadMsg, LeadErr, Literal, CellRef, LiteralValue, CellT };

View File

@@ -1,7 +1,4 @@
export interface CellT { import type { CellRef, Eval, LiteralValue } from './messages';
raw_val: string;
val?: Eval;
}
/** /**
* Zero indexed | A1 == {row: 0, col: 0}; * Zero indexed | A1 == {row: 0, col: 0};