This commit is contained in:
2025-09-04 15:25:22 +10:00
parent f95f8fcf83
commit 0c68c8cd5b
2 changed files with 162 additions and 154 deletions

View File

@@ -1,7 +1,7 @@
use crate::cell::{Cell, CellRef}; use crate::cell::{Cell, CellRef};
use crate::parser::*; use crate::parser::*;
use crate::tokenizer::Literal; use crate::tokenizer::Literal;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::fmt; use std::fmt;
#[derive(Debug, PartialEq, Clone)] #[derive(Debug, PartialEq, Clone)]
@@ -34,15 +34,17 @@ impl Evaluator {
} }
let eval: Eval; let eval: Eval;
let deps: HashSet<CellRef>;
if let Some(c) = raw_val.chars().nth(0) if let Some(c) = raw_val.chars().nth(0)
&& c == '=' && c == '='
{ {
eval = self.evaluate(raw_val[1..].to_owned())?; (eval, deps) = self.evaluate(raw_val[1..].to_owned())?;
// for dep in deps {}
} else { } else {
match self.evaluate(raw_val.to_owned()) { match self.evaluate(raw_val.to_owned()) {
Ok(e) => { Ok(e) => {
eval = e; (eval, deps) = e;
} }
Err(_) => eval = Eval::Literal(Literal::String(raw_val.to_owned())), Err(_) => eval = Eval::Literal(Literal::String(raw_val.to_owned())),
} }
@@ -61,14 +63,28 @@ impl Evaluator {
Ok((cell.raw(), cell.eval())) Ok((cell.raw(), cell.eval()))
} }
pub fn add_cell_dep(&mut self, cell_ref: CellRef, dep_ref: CellRef) -> Result<(), String> {
pub fn evaluate(&mut self, str: String) -> Result<Eval, String> { if !self.cells.contains_key(&cell_ref) {
let (mut expr, mut deps) = parse(&str)?; return Err(format!("Cell at {:?} not found.", cell_ref));
self.evaluate_expr(&mut expr)
} }
fn evaluate_expr(&mut self, expr: &mut Expr) -> Result<Eval, String> { if let Some(cell) = self.cells.get_mut(&cell_ref) {
cell.add_i_dep(dep_ref);
}
Ok(())
}
pub fn evaluate(&mut self, str: String) -> Result<(Eval, HashSet<CellRef>), String> {
let (expr, deps) = parse(&str)?;
match self.evaluate_expr(&expr) {
Ok(it) => Ok((it, deps)),
Err(it) => Err(it),
}
}
fn evaluate_expr(&mut self, expr: &Expr) -> Result<Eval, String> {
let res = match expr { let res = match expr {
Expr::Literal(lit) => Eval::Literal(lit.clone()), Expr::Literal(lit) => Eval::Literal(lit.clone()),
Expr::CellRef(re) => self.get_cell(re.to_owned())?.1, Expr::CellRef(re) => self.get_cell(re.to_owned())?.1,

View File

@@ -1,43 +1,30 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
const socket = new WebSocket("ws://localhost:7050");
socket.onmessage = (event) => {
const message = event.data;
console.log("Received message from server:", message);
};
socket.onopen = () => {
console.log("WebSocket connection established.");
};
// Connection opened
// --- Configuration --- // --- Configuration ---
const numRows = 1000; // Increased to show performance const numRows = 1000;
const numCols = 100; // Increased to show performance const numCols = 100;
const rowHeight = 30; // px const rowHeight = 30; // px
const colWidth = 150; // px const colWidth = 150; // px
const rowNumberWidth = 50; // px const rowNumberWidth = 50; // px
const colHeaderHeight = 30; // px const colHeaderHeight = 30; // px
// --- State --- // --- State (Svelte 5 runes) ---
let gridData: string[][]; let gridData = $state<string[][] | null>(new Array());
let columnLabels: string[] = []; let columnLabels = $state<string[]>([]);
let activeCell: [number, number] | null = null; let activeCell = $state<[number, number] | null>(null);
let viewportElement: HTMLElement;
let viewportWidth = 0;
let viewportHeight = 0;
let scrollTop = 0;
let scrollLeft = 0;
// --- Helper Functions --- let viewportEl = $state<HTMLElement | null>(null);
/** let viewportWidth = $state(0);
* Generates Excel-style column labels (A, B, ..., Z, AA, AB, ...). let viewportHeight = $state(0);
*/ let scrollTop = $state(0);
let scrollLeft = $state(0);
let socket: WebSocket | null = null;
// --- Helpers ---
function generateColumnLabels(count: number): string[] { function generateColumnLabels(count: number): string[] {
const labels = []; const labels: string[] = [];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
let label = ""; let label = "";
let temp = i; let temp = i;
@@ -52,64 +39,71 @@
onMount(() => { onMount(() => {
columnLabels = generateColumnLabels(numCols); columnLabels = generateColumnLabels(numCols);
gridData = Array(numRows) gridData = Array.from({ length: numRows }, () =>
.fill(null) Array<string>(numCols).fill(""),
.map(() => Array(numCols).fill("")); );
socket = new WebSocket("ws://localhost:7050");
socket.onmessage = (e) =>
console.log("Received message from server:", e.data);
socket.onopen = () => console.log("WebSocket connection established.");
return () => socket?.close();
}); });
// --- Event Handlers --- // --- Events ---
function handleGridBlur(e: FocusEvent) { function handleGridBlur(e: FocusEvent) {
if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) { const target = e.currentTarget as HTMLElement;
activeCell = null; const next = e.relatedTarget as Node | null;
} if (!target.contains(next)) activeCell = null;
} }
function handleCellBlur(row: number, col: number) { function handleCellBlur(row: number, col: number) {
// This function runs when a cell loses focus.
// You can add your logic here, e.g., validation, calculations, etc.
console.log( console.log(
`Cell (${row + 1}, ${columnLabels[col]}) lost focus. Value:`, `Cell (${row + 1}, ${columnLabels[col]}) lost focus. Value:`,
gridData[row][col], gridData![row][col],
); );
socket?.send(
socket.send(
JSON.stringify({ JSON.stringify({
row: row + 1, row: row + 1,
col: columnLabels[col], col: columnLabels[col],
value: gridData[row][col], value: gridData![row][col],
}), }),
); );
} }
// --- Reactive Calculations for Virtualization --- // --- Scroll math (account for header/row gutters) ---
// $: is a Svelte feature that re-runs code when its dependencies change. // How far into the *grid area* (excluding headers) we've scrolled:
const scrollDX = $derived(Math.max(0, scrollLeft - rowNumberWidth));
const scrollDY = $derived(Math.max(0, scrollTop - colHeaderHeight));
// Calculate which rows/cols are visible based on scroll position // Viewport pixels actually available to show cells (excluding sticky gutters):
$: startRow = Math.max(0, Math.floor(scrollTop / rowHeight)); const innerW = $derived(Math.max(0, viewportWidth - rowNumberWidth));
$: endRow = Math.min( const innerH = $derived(Math.max(0, viewportHeight - colHeaderHeight));
numRows,
startRow + Math.ceil(viewportHeight / rowHeight) + 1, // Virtualization windows:
); const startCol = $derived(Math.max(0, Math.floor(scrollDX / colWidth)));
$: startCol = Math.max(0, Math.floor(scrollLeft / colWidth)); const endCol = $derived(
$: endCol = Math.min( Math.min(numCols, startCol + Math.ceil(innerW / colWidth) + 1),
numCols,
startCol + Math.ceil(viewportWidth / colWidth) + 1,
); );
// Create arrays of only the visible items to render const startRow = $derived(Math.max(0, Math.floor(scrollDY / rowHeight)));
$: visibleRows = Array(endRow - startRow) const endRow = $derived(
.fill(0) Math.min(numRows, startRow + Math.ceil(innerH / rowHeight) + 1),
.map((_, i) => startRow + i); );
$: visibleCols = Array(endCol - startCol)
.fill(0) const visibleCols = $derived(
.map((_, i) => startCol + i); Array.from({ length: endCol - startCol }, (_, i) => startCol + i),
);
const visibleRows = $derived(
Array.from({ length: endRow - startRow }, (_, i) => startRow + i),
);
</script> </script>
<div <div
class="w-full h-[85vh] p-4 bg-background text-foreground rounded-lg border flex flex-col" class="w-full h-[85vh] p-4 bg-background text-foreground rounded-lg border flex flex-col"
> >
<div <div
class="relative grid-container" class="grid-container relative"
on:focusout={handleGridBlur} on:focusout={handleGridBlur}
style=" style="
--row-height: {rowHeight}px; --row-height: {rowHeight}px;
@@ -118,43 +112,45 @@
--col-header-height: {colHeaderHeight}px; --col-header-height: {colHeaderHeight}px;
" "
> >
<!-- The scrollable viewport provides the scrollbars --> <!-- Single, real scroll container -->
<div <div
class="viewport" class="viewport"
bind:this={viewportElement} bind:this={viewportEl}
bind:clientWidth={viewportWidth} bind:clientWidth={viewportWidth}
bind:clientHeight={viewportHeight} bind:clientHeight={viewportHeight}
on:scroll={(e) => { on:scroll={(e) => {
scrollTop = e.currentTarget.scrollTop; const el = e.currentTarget as HTMLElement;
scrollLeft = e.currentTarget.scrollLeft; scrollTop = el.scrollTop;
scrollLeft = el.scrollLeft;
}} }}
> >
<!-- Sizer div creates the full scrollable area --> <!-- Sizer creates true scrollable area (includes gutters + grid) -->
<div <div
class="total-sizer" class="total-sizer"
style:width="{numCols * colWidth}px" style:width={`${rowNumberWidth + numCols * colWidth}px`}
style:height="{numRows * rowHeight}px" style:height={`${colHeaderHeight + numRows * rowHeight}px`}
/> />
</div>
<!-- The Renderer sits on top of the viewport and handles drawing --> <!-- Top-left sticky corner -->
{#if gridData}
<div class="renderer">
<!-- Top-left corner -->
<div <div
class="top-left-corner" class="top-left-corner"
style="top: 0; left: 0;"
class:active-header-corner={activeCell !== null} class:active-header-corner={activeCell !== null}
/> />
<!-- Visible Column Headers --> <!-- Column headers (stick to top, scroll horizontally with grid) -->
<div <div
class="col-headers-container" class="col-headers-container"
style:transform="translateX(-{scrollLeft}px)" style="
top: 0;
left: {rowNumberWidth}px;
transform: translateX(-{scrollDX}px);
"
> >
{#each visibleCols as j (j)} {#each visibleCols as j (j)}
<div <div
class="header-cell" class="header-cell"
style:left="{j * colWidth}px" style:left={`${j * colWidth}px`}
class:active-header={activeCell !== null && activeCell[1] === j} class:active-header={activeCell !== null && activeCell[1] === j}
> >
{columnLabels[j]} {columnLabels[j]}
@@ -162,15 +158,19 @@
{/each} {/each}
</div> </div>
<!-- Visible Row Headers --> <!-- Row headers (stick to left, scroll vertically with grid) -->
<div <div
class="row-headers-container" class="row-headers-container"
style:transform="translateY(-{scrollTop}px)" style="
top: {colHeaderHeight}px;
left: 0;
transform: translateY(-{scrollDY}px);
"
> >
{#each visibleRows as i (i)} {#each visibleRows as i (i)}
<div <div
class="row-number-cell" class="row-number-cell"
style:top="{i * rowHeight}px" style:top={`${i * rowHeight}px`}
class:active-header={activeCell !== null && activeCell[0] === i} class:active-header={activeCell !== null && activeCell[0] === i}
> >
{i + 1} {i + 1}
@@ -178,27 +178,34 @@
{/each} {/each}
</div> </div>
<!-- Visible Grid Cells --> <!-- Visible grid cells (overlay; move opposite the inner scroll) -->
{#if gridData}
<div <div
class="cells-container" class="cells-container"
style:transform="translate(-{scrollLeft}px, -{scrollTop}px)" style="
top: {colHeaderHeight}px;
left: {rowNumberWidth}px;
transform: translate(-{scrollDX}px, -{scrollDY}px);
"
> >
{#each visibleRows as i (i)} {#each visibleRows as i (i)}
{#each visibleCols as j (j)} {#each visibleCols as j (j)}
<div <div
class="grid-cell" class="grid-cell"
style:top="{i * rowHeight}px" style:top={`${i * rowHeight}px`}
style:left="{j * colWidth}px" style:left={`${j * colWidth}px`}
> >
<!-- Using a standard input to ensure styles are applied correctly -->
<input <input
type="text" type="text"
bind:value={gridData[i][j]} value={gridData[i][j]}
class="cell-input" class="cell-input"
on:input={(e) =>
(gridData[i][j] = (
e.currentTarget as HTMLInputElement
).value)}
on:focus={() => (activeCell = [i, j])} on:focus={() => (activeCell = [i, j])}
on:blur={() => handleCellBlur(i, j)} on:blur={() => handleCellBlur(i, j)}
/> />
<!-- Active cell indicator with fill handle -->
{#if activeCell && activeCell[0] === i && activeCell[1] === j} {#if activeCell && activeCell[0] === i && activeCell[1] === j}
<div class="active-cell-indicator"> <div class="active-cell-indicator">
<div class="fill-handle" /> <div class="fill-handle" />
@@ -208,50 +215,39 @@
{/each} {/each}
{/each} {/each}
</div> </div>
</div>
{/if} {/if}
</div> </div>
</div> </div>
</div>
<style> <style>
.grid-container { .grid-container {
flex-grow: 1; flex-grow: 1;
position: relative;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif; Helvetica, Arial, sans-serif;
border: 1px solid hsl(var(--border)); border: 1px solid hsl(var(--border));
} }
.viewport { .viewport {
position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: auto; overflow: auto;
position: absolute;
top: 0;
left: 0;
}
.total-sizer {
position: relative;
pointer-events: none;
} }
.renderer { .total-sizer {
position: absolute; /* occupies the scroll area; other layers are absolutely positioned on top */
top: 0;
left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
pointer-events: none;
overflow: hidden;
} }
/* Layered overlays inside the viewport */
.top-left-corner, .top-left-corner,
.col-headers-container, .col-headers-container,
.row-headers-container, .row-headers-container,
.cells-container { .cells-container {
position: absolute; position: absolute;
top: 0; z-index: 1;
left: 0;
} }
.top-left-corner { .top-left-corner {
@@ -262,25 +258,22 @@
border-bottom: 1px solid hsl(var(--border)); border-bottom: 1px solid hsl(var(--border));
z-index: 4; z-index: 4;
} }
.active-header-corner { .active-header-corner {
background-color: hsl(var(--accent)); background-color: hsl(var(--accent));
} }
.col-headers-container { .col-headers-container {
top: 0;
left: var(--row-number-width);
height: var(--col-header-height); height: var(--col-header-height);
z-index: 3; z-index: 3;
} }
.row-headers-container { .row-headers-container {
top: var(--col-header-height);
left: 0;
width: var(--row-number-width); width: var(--row-number-width);
z-index: 2; z-index: 2;
} }
.cells-container { .cells-container {
top: var(--col-header-height);
left: var(--row-number-width);
z-index: 1; z-index: 1;
} }
@@ -320,12 +313,10 @@
height: var(--row-height); height: var(--row-height);
border-right: 1px solid hsl(var(--border) / 0.7); border-right: 1px solid hsl(var(--border) / 0.7);
border-bottom: 1px solid hsl(var(--border) / 0.7); border-bottom: 1px solid hsl(var(--border) / 0.7);
pointer-events: auto;
background-color: hsl(var(--background)); background-color: hsl(var(--background));
} }
.cell-input { .cell-input {
/* Overriding all default input styles */
appearance: none; appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
-moz-appearance: none; -moz-appearance: none;
@@ -342,7 +333,7 @@
outline: none; outline: none;
box-shadow: none; box-shadow: none;
} }
/* Ensure no focus styles are added by the browser or libraries */
.cell-input:focus, .cell-input:focus,
.cell-input:focus-visible { .cell-input:focus-visible {
border: 1px solid red; border: 1px solid red;
@@ -358,6 +349,7 @@
pointer-events: none; pointer-events: none;
z-index: 5; z-index: 5;
} }
.fill-handle { .fill-handle {
position: absolute; position: absolute;
bottom: -4px; bottom: -4px;