From f96f7c34487a3668228a431cbbb4ad7e8360c26c Mon Sep 17 00:00:00 2001 From: Ed Date: Sat, 3 Oct 2026 18:39:43 +0300 Subject: [PATCH 1/2] feat: display bounded in-memory cursor previews in runtime grids --- crates/foxvm/src/vm.rs | 31 ++++++++++++ crates/foxvm/src/wasm.rs | 5 ++ crates/foxvm/tests/grid_preview.rs | 35 ++++++++++++++ docs/grid-cursor-preview.md | 28 +++++++++++ src/renderer/src/runtime/controls/index.tsx | 33 +++++++++++-- src/renderer/src/runtime/session.ts | 2 +- src/renderer/src/runtime/vmBridge.ts | 5 ++ tests/runtime/gridCursor.test.tsx | 52 +++++++++++++++++++++ tests/runtime/vmBridge.test.ts | 15 ++++++ 9 files changed, 201 insertions(+), 5 deletions(-) create mode 100644 crates/foxvm/tests/grid_preview.rs create mode 100644 docs/grid-cursor-preview.md create mode 100644 tests/runtime/gridCursor.test.tsx diff --git a/crates/foxvm/src/vm.rs b/crates/foxvm/src/vm.rs index 833073c..6a5472a 100644 --- a/crates/foxvm/src/vm.rs +++ b/crates/foxvm/src/vm.rs @@ -1615,6 +1615,37 @@ impl Vm { &self.data } + /// A bounded, read-only preview for runtime grids. Never moves a work-area pointer. + pub fn grid_preview(&self, alias: &str) -> serde_json::Value { + use serde_json::json; + let Some(cursor) = self.data.find(&crate::data::AreaRef::Alias(alias.into())) else { + return json!({"error": "RecordSource alias is not open"}); + }; + if cursor.handle().is_some() || cursor.buffering() != 1 || cursor.ordered() + || !cursor.filter().is_empty() || cursor.key_limit().is_some() || !cursor.relations().is_empty() { + return json!({"error": "Grid preview supports unfiltered, unbuffered in-memory cursors without indexes or relations"}); + } + if cursor.header.fields.len() > 128 { + return json!({"error": "Grid preview supports at most 128 fields"}); + } + let columns: Vec<_> = cursor.header.fields.iter().map(|f| f.name.clone()).collect(); + let mut rows = Vec::new(); + let mut truncated = false; + for record in cursor.all_rows() { + if record.deleted && self.settings.deleted { continue; } + if rows.len() == 200 { truncated = true; break; } + let cells: Vec<_> = record.values.iter().map(|v| { + let text = crate::value::display(&value_of_dbf(Some(v)), &self.settings); + let mut chars = text.chars(); + let mut bounded: String = chars.by_ref().take(512).collect(); + if chars.next().is_some() { bounded.push('…'); truncated = true; } + bounded + }).collect(); + rows.push(cells); + } + json!({"columns": columns, "rows": rows, "truncated": truncated}) + } + pub fn settings(&self) -> &Settings { &self.settings } diff --git a/crates/foxvm/src/wasm.rs b/crates/foxvm/src/wasm.rs index bad7598..f9075b7 100644 --- a/crates/foxvm/src/wasm.rs +++ b/crates/foxvm/src/wasm.rs @@ -860,6 +860,11 @@ impl FoxVm { to_js(&JsonValue::from_value(&self.vm.get_global(name).unwrap_or(crate::value::Value::Null))) } + /// A read-only grid preview; no host IO or record movement. + pub fn grid_preview(&self, alias: &str) -> JsValue { + to_js(&self.vm.grid_preview(alias)) + } + /// `exact`, `decimals`, `century`, `date`, `talk`, `safety`, `escape`. pub fn set_setting(&mut self, name: &str, value: JsValue) { let s = self.vm.settings_mut(); diff --git a/crates/foxvm/tests/grid_preview.rs b/crates/foxvm/tests/grid_preview.rs new file mode 100644 index 0000000..3e5f1fe --- /dev/null +++ b/crates/foxvm/tests/grid_preview.rs @@ -0,0 +1,35 @@ +use foxvm::mock_host::{MockHost, run_program}; +use foxvm::data::AreaRef; + +fn run(source: &str) -> foxvm::vm::Vm { + run_program(source, &mut MockHost::new()).unwrap().0 +} + +#[test] +fn preview_preserves_alias_pointer_and_unicode() { + let vm = run("CREATE CURSOR choices (title C(30))\nINSERT INTO choices VALUES ('Ąžuolas')\nINSERT INTO choices VALUES ('Birch')\nGO TOP\nCREATE CURSOR other (id I)"); + let before = vm.data().current_area(); + let result = vm.grid_preview("CHOICES"); + assert!(result["error"].is_null(), "{result}"); + assert_eq!(result["columns"][0], "TITLE"); + assert!(result["rows"][0][0].as_str().unwrap().starts_with("Ąžuolas")); + assert_eq!(result["rows"].as_array().unwrap().len(), 2); + assert_eq!(vm.data().current_area(), before); + assert_eq!(vm.data().find(&AreaRef::Alias("choices".into())).unwrap().recno(), 1); +} + +#[test] +fn closed_and_filtered_aliases_report_limits() { + let vm = run("CREATE CURSOR choices (id I)\nINSERT INTO choices VALUES (1)\nSET FILTER TO id=1"); + assert!(vm.grid_preview("choices")["error"].is_string()); + assert!(vm.grid_preview("missing")["error"].is_string()); +} + +#[test] +fn preview_is_bounded_and_respects_deleted_setting() { + let vm = run("CREATE CURSOR choices (id I)\nFOR n=1 TO 205\nINSERT INTO choices VALUES (n)\nENDFOR\nGO TOP\nDELETE\nSET DELETED ON"); + let result = vm.grid_preview("choices"); + assert_eq!(result["rows"].as_array().unwrap().len(), 200); + assert_eq!(result["truncated"], true); + assert_eq!(result["rows"][0][0].as_str().unwrap().trim(), "2"); +} diff --git a/docs/grid-cursor-preview.md b/docs/grid-cursor-preview.md new file mode 100644 index 0000000..7e0886d --- /dev/null +++ b/docs/grid-cursor-preview.md @@ -0,0 +1,28 @@ +# Runtime grid cursor preview + +The runtime grid previously drew headers and blank cells without reading +RecordSource. RecordSourceType 1 now reads a bounded snapshot of an open, +unbuffered in-memory cursor and displays its fields. No selected work area, +record pointer or database file is changed by a preview. + +An empty Column.ControlSource uses the field at that column's ordinal; a simple +field name or matching alias.field maps explicitly, case-insensitively. Other +expressions show an unsupported-field indicator. Existing columns and captions +are preserved; automatic ColumnCount=-1 expansion is not implemented here. + +Snapshots refresh after runtime state changes and desktop revisions. Closing an +alias removes its old values and reports that the alias is not open. Rendering +uses text nodes, not HTML, and does not execute ControlSource expressions. + +This is a read-only preview, visibly labelled as such. Row navigation, editing, +Before/AfterRowColChange dispatch, Dynamic* expressions, per-column formatting, +record markers and full Grid parity remain unimplemented. Selecting a record via +the grid is not supported; consuming form actions still see the VM's current +record, which the preview never moves. + +Host-backed tables, buffering, SET FILTER, controlling indexes, key ranges and +relations report a limitation instead of showing an unfiltered or misleading +snapshot. SET DELETED ON omits deleted records for supported cursors. Preview +limits are 200 visible rows, 128 fields and 512 characters per cell; shortening is +reported. There is no pagination in this first slice. Private data sessions and +native Windows VFP comparisons are not validated. diff --git a/src/renderer/src/runtime/controls/index.tsx b/src/renderer/src/runtime/controls/index.tsx index 2ec6724..8db9f88 100644 --- a/src/renderer/src/runtime/controls/index.tsx +++ b/src/renderer/src/runtime/controls/index.tsx @@ -20,6 +20,7 @@ import { colorCss, designSurfaces, fontStyle } from '../../designer/surfaces'; import { pictureProblem, pictureUrl } from '../pictureUrl'; import { useSessionStore } from '../session'; import { ImageList, TreeView } from '@shared/runtime/oleObjects'; +import type { GridPreview } from '../vmBridge'; import { OleTreeView } from './OleTreeView'; export interface RuntimeProps { @@ -543,7 +544,28 @@ const RGrid: FC = ({ obj, props: r }) => { const ev = useEvents(obj); const bind = useBind(obj); const columns = obj.children; - const rows = Math.max(1, Math.floor((Number(r['Height']) - Number(r['HeaderHeight'] ?? 17) - 2) / Number(r['RowHeight'] ?? 17))); + const vm = useSessionStore((s) => s.vm); + const status = useSessionStore((s) => s.status); + const revision = useSessionStore((s) => s.revision); + const alias = String(r['RecordSource'] ?? ''); + const sourceType = Number(r['RecordSourceType'] ?? 1); + const preview = useMemo(() => { + // Session revisions invalidate a preview even when an event starts and finishes + // between React renders and leaves the scheduler in the same waiting state. + void revision; + if (status === 'running' || !alias || !vm) return null; + if (sourceType !== 1) return {error: 'Grid preview requires RecordSourceType 1 (alias)'}; + try { return vm.gridPreview(alias); } + catch { return {error: 'Grid preview could not read the cursor'}; } + }, [vm, status, revision, alias, sourceType]); + const data = preview && !('error' in preview) ? preview : null; + const positions = columns.map((col, i) => { + const source = String(col.get('ControlSource') ?? '').trim(); + if (!source) return i; + const parts = source.split('.'); + if (parts.length > 2 || (parts.length === 2 && parts[0]?.toLowerCase() !== alias.toLowerCase())) return -1; + return data?.columns.findIndex((name) => name.toLowerCase() === parts.at(-1)?.toLowerCase()) ?? -1; + }); return (
@@ -560,15 +582,18 @@ const RGrid: FC = ({ obj, props: r }) => { - {Array.from({ length: rows }).map((_, i) => ( + {data?.rows.map((row, i) => ( - {columns.map((col) => ( - + {columns.map((col, j) => ( + {positions[j]! < 0 ? '(unsupported field)' : row[positions[j]!] ?? ''} ))} ))}
+ {preview && 'error' in preview &&
{preview.error}
} + {data &&
Read-only preview; row navigation and editing are not available.{data.truncated ? ' Preview shortened (200 rows / 512 characters per cell).' : ''}
} +
); }; diff --git a/src/renderer/src/runtime/session.ts b/src/renderer/src/runtime/session.ts index 66603dc..8d6fbc7 100644 --- a/src/renderer/src/runtime/session.ts +++ b/src/renderer/src/runtime/session.ts @@ -445,7 +445,7 @@ export const useSessionStore = create((set, get) => { set({ status: 'error', errorReport: { error, stack, resolve: (action) => { set({ errorReport: null }); resolve(action); } } }); print({ kind: 'error', text: `Error ${error.code} in ${error.program} line ${error.line}: ${error.message}` }); }), - onStateChange: (status) => set({ status }), + onStateChange: (status) => set((s) => ({ status, revision: s.revision + 1 })), onQuit: () => get().cancel(), // a program stopped at a breakpoint is a parked fiber, so the IDE carries on around it onBreak: (stop) => { diff --git a/src/renderer/src/runtime/vmBridge.ts b/src/renderer/src/runtime/vmBridge.ts index 6b04e0b..5d9389a 100644 --- a/src/renderer/src/runtime/vmBridge.ts +++ b/src/renderer/src/runtime/vmBridge.ts @@ -13,6 +13,8 @@ import type { VmValue } from '@shared/runtime/values'; import { headerStem } from '@shared/runtime/programSource'; import { loadFoxVm, loadFoxVmSync, type FoxVmModule } from '../../../wasm/foxvm/loader'; +export type GridPreview = {error: string} | {columns: string[]; rows: string[][]; truncated: boolean}; + export interface MethodSourceInput { objectPath: string; event: string; @@ -111,6 +113,9 @@ class WasmVm implements VmLike { getGlobal(name: string): VmValue { return this.guard('getGlobal', () => this.vm.get_global(name) as VmValue); } + gridPreview(alias: string): GridPreview { + return this.guard('gridPreview', () => this.vm.grid_preview(alias) as GridPreview); + } /** The text of an XML document from its bytes, decoded as its declaration says. */ xmlText(bytes: string): string { return this.guard('xmlText', () => this.vm.xml_text(bytes)); diff --git a/tests/runtime/gridCursor.test.tsx b/tests/runtime/gridCursor.test.tsx new file mode 100644 index 0000000..6d6e7bc --- /dev/null +++ b/tests/runtime/gridCursor.test.tsx @@ -0,0 +1,52 @@ +import { act, screen } from '@testing-library/react'; +import { afterEach, expect, it } from 'vitest'; +import { Desktop } from '@shared/runtime/objectModel'; +import { RuntimeControl } from '@renderer/runtime/RuntimeControl'; +import { useSessionStore } from '@renderer/runtime/session'; +import type { WasmVm } from '@renderer/runtime/vmBridge'; +import { renderWithProviders } from '../helpers/render'; + +const grid = () => new Desktop().instantiate({name: 'Form1', props: {}, methods: {}, children: [{ + id: 'grid', type: 'Grid', name: 'Choices', props: {RecordSource: 'choices', RecordSourceType: 1, Height: 180}, methods: {}, + children: [{id: 'col', type: 'Column', name: 'Column1', props: {}, methods: {}, children: [ + {id: 'head', type: 'Header', name: 'Header1', props: {Caption: 'Name'}, methods: {}}, + ]}], +}]}, -1).children[0]!; + +afterEach(() => useSessionStore.setState({vm: null})); + +it('shows cursor values instead of empty placeholder cells', async () => { + const vm = {gridPreview: () => ({columns: ['NAME'], rows: [['Oak'], ['Birch']], truncated: false})} as unknown as WasmVm; + useSessionStore.setState({vm, status: 'waiting'}); + renderWithProviders(); + expect(await screen.findByText('Oak')).toBeInTheDocument(); + expect(screen.getByText('Birch')).toBeInTheDocument(); +}); + +it('refreshes the preview after a runtime revision and clears closed sources', async () => { + let value = 'Oak'; + const vm = {gridPreview: () => value ? {columns: ['NAME'], rows: [[value]], truncated: false} : {error: 'RecordSource alias is not open'}} as unknown as WasmVm; + useSessionStore.setState({vm, status: 'waiting'}); + renderWithProviders(); + await screen.findByText('Oak'); + value = 'Maple'; + await act(() => useSessionStore.setState(s => ({revision: s.revision + 1}))); + expect(await screen.findByText('Maple')).toBeInTheDocument(); + expect(screen.queryByText('Oak')).toBeNull(); + value = ''; + await act(() => useSessionStore.setState(s => ({revision: s.revision + 1}))); + expect(await screen.findByText('RecordSource alias is not open')).toBeInTheDocument(); + expect(screen.queryByText('Maple')).toBeNull(); +}); + +it('maps explicit fields and reports expressions instead of showing the wrong field', async () => { + const vm = {gridPreview: () => ({columns: ['NAME', 'CODE'], rows: [['Oak', 'A1']], truncated: false})} as unknown as WasmVm; + useSessionStore.setState({vm, status: 'waiting'}); + const obj = grid(); + obj.children[0]!.set('ControlSource', 'choices.code'); + renderWithProviders(); + expect(await screen.findByText('A1')).toBeInTheDocument(); + expect(screen.queryByText('Oak')).toBeNull(); + await act(() => { obj.children[0]!.set('ControlSource', 'UPPER(name)'); useSessionStore.setState(s => ({revision: s.revision + 1})); }); + expect(await screen.findByText('(unsupported field)')).toBeInTheDocument(); +}); diff --git a/tests/runtime/vmBridge.test.ts b/tests/runtime/vmBridge.test.ts index 5e92003..3411231 100644 --- a/tests/runtime/vmBridge.test.ts +++ b/tests/runtime/vmBridge.test.ts @@ -102,6 +102,21 @@ describe('FoxVM bridge', () => { expect(host.lines).toEqual(['sum: 42', 'DONE']); }); + it('reads a cursor preview across WASM without moving the current work area', () => { + const host = new StubHost(); + const {vm, scheduler} = makeSession(host); + const out = compileProgram("CREATE CURSOR choices (title C(20))\nINSERT INTO choices VALUES ('Oak')\nCREATE CURSOR other (id I)", 'grid'); + expect(compileError(out)).toBeNull(); + scheduler.runProgram(vm.loadModule(out.bytes!)); + const preview = vm.gridPreview('choices'); + expect('error' in preview).toBe(false); + if ('error' in preview) throw new Error(preview.error); + expect(preview.rows[0]?.[0]?.trim()).toBe('Oak'); + const check = compileProgram('? ALIAS()', 'check'); + scheduler.runProgram(vm.loadModule(check.bytes!)); + expect(host.lines.at(-1)?.toLowerCase()).toBe('other'); + }); + it('reports compile errors instead of a module', () => { const out = compileProgram('IF x\n', 'broken'); expect(out.bytes).toBeNull(); From 69fac812500e54c8d0c1f8b501189ddb8743770f Mon Sep 17 00:00:00 2001 From: Ed Date: Sat, 3 Oct 2026 19:03:58 +0300 Subject: [PATCH 2/2] feat: select records in bounded runtime cursor grids --- crates/foxvm/src/data.rs | 12 +++- crates/foxvm/src/vm.rs | 24 ++++++- crates/foxvm/src/wasm.rs | 4 ++ crates/foxvm/tests/grid_preview.rs | 15 ++++ docs/grid-cursor-preview.md | 24 +++++-- src/renderer/src/runtime/controls/index.tsx | 35 +++++++-- src/renderer/src/runtime/gridNavigation.ts | 79 +++++++++++++++++++++ src/renderer/src/runtime/vmBridge.ts | 6 +- tests/runtime/gridCursor.test.tsx | 76 ++++++++++++++++++-- tests/runtime/vmBridge.test.ts | 17 +++++ 10 files changed, 272 insertions(+), 20 deletions(-) create mode 100644 src/renderer/src/runtime/gridNavigation.ts diff --git a/crates/foxvm/src/data.rs b/crates/foxvm/src/data.rs index 6857829..366d5dc 100644 --- a/crates/foxvm/src/data.rs +++ b/crates/foxvm/src/data.rs @@ -25,11 +25,12 @@ pub const PAGE_RECORDS: u32 = 64; pub struct DataSession { areas: Vec>, current: usize, + next_generation: u64, } impl DataSession { pub fn new() -> DataSession { - DataSession { areas: Vec::new(), current: 0 } + DataSession { areas: Vec::new(), current: 0, next_generation: 0 } } /// The selected work area, 1-based, as `SELECT()` reports it. @@ -94,9 +95,11 @@ impl DataSession { /// Puts a freshly opened cursor in the selected area, replacing whatever was there. /// Returns the handle of the table that was closed, if any, so the caller can release it. - pub fn install(&mut self, cursor: Cursor) -> Option { + pub fn install(&mut self, mut cursor: Cursor) -> Option { let index = self.current; self.ensure(index); + self.next_generation += 1; + cursor.generation = self.next_generation; self.areas[index].replace(cursor).and_then(|old| old.handle()) } @@ -154,6 +157,8 @@ pub enum Source { /// One open table. #[derive(Debug)] pub struct Cursor { + /// Identity of this installation, to reject UI events for a replaced alias. + generation: u64, source: Source, /// The name this table answers to: the file stem, or whatever `USE ... ALIAS` said. pub alias: String, @@ -239,6 +244,8 @@ pub struct Cursor { } impl Cursor { + pub fn generation(&self) -> u64 { self.generation } + pub fn new(handle: u32, alias: String, path: String, header: DbfHeader) -> Cursor { Cursor::over(Source::Host { handle }, alias, path, header) } @@ -281,6 +288,7 @@ impl Cursor { let count = header.record_count; // VFP opens a table at record 1, which for an empty one is already end of file Cursor { + generation: 0, source, alias, path, diff --git a/crates/foxvm/src/vm.rs b/crates/foxvm/src/vm.rs index 6a5472a..55aadd1 100644 --- a/crates/foxvm/src/vm.rs +++ b/crates/foxvm/src/vm.rs @@ -1630,8 +1630,9 @@ impl Vm { } let columns: Vec<_> = cursor.header.fields.iter().map(|f| f.name.clone()).collect(); let mut rows = Vec::new(); + let mut records = Vec::new(); let mut truncated = false; - for record in cursor.all_rows() { + for (index, record) in cursor.all_rows().iter().enumerate() { if record.deleted && self.settings.deleted { continue; } if rows.len() == 200 { truncated = true; break; } let cells: Vec<_> = record.values.iter().map(|v| { @@ -1641,9 +1642,28 @@ impl Vm { if chars.next().is_some() { bounded.push('…'); truncated = true; } bounded }).collect(); + records.push(index + 1); rows.push(cells); } - json!({"columns": columns, "rows": rows, "truncated": truncated}) + json!({"columns": columns, "rows": rows, "records": records, + "currentRecord": cursor.recno(), "generation": cursor.generation().to_string(), "truncated": truncated}) + } + + /// Select a physical record that is currently offered by the bounded grid view. + /// The generation rejects clicks queued before an alias was closed/recreated. + pub fn grid_select(&mut self, alias: &str, recno: u32, generation: &str) -> Result<(), String> { + let preview = self.grid_preview(alias); + if let Some(error) = preview["error"].as_str() { return Err(error.into()); } + if preview["generation"].as_str() != Some(generation) { + return Err("Grid source changed; select the row again".into()); + } + if !preview["records"].as_array().is_some_and(|records| records.iter().any(|r| r.as_u64() == Some(recno.into()))) { + return Err("Grid record is no longer visible".into()); + } + let area = crate::data::AreaRef::Alias(alias.into()); + self.data.select(&area).map_err(|_| "Grid source is no longer open".to_string())?; + self.data.cursor_mut().unwrap().seek(recno.into()); + Ok(()) } pub fn settings(&self) -> &Settings { diff --git a/crates/foxvm/src/wasm.rs b/crates/foxvm/src/wasm.rs index f9075b7..9686510 100644 --- a/crates/foxvm/src/wasm.rs +++ b/crates/foxvm/src/wasm.rs @@ -865,6 +865,10 @@ impl FoxVm { to_js(&self.vm.grid_preview(alias)) } + pub fn grid_select(&mut self, alias: &str, recno: u32, generation: &str) -> Result<(), JsValue> { + self.vm.grid_select(alias, recno, generation).map_err(|error| JsValue::from_str(&error)) + } + /// `exact`, `decimals`, `century`, `date`, `talk`, `safety`, `escape`. pub fn set_setting(&mut self, name: &str, value: JsValue) { let s = self.vm.settings_mut(); diff --git a/crates/foxvm/tests/grid_preview.rs b/crates/foxvm/tests/grid_preview.rs index 3e5f1fe..e093b8a 100644 --- a/crates/foxvm/tests/grid_preview.rs +++ b/crates/foxvm/tests/grid_preview.rs @@ -33,3 +33,18 @@ fn preview_is_bounded_and_respects_deleted_setting() { assert_eq!(result["truncated"], true); assert_eq!(result["rows"][0][0].as_str().unwrap().trim(), "2"); } + +#[test] +fn selection_uses_physical_records_and_rejects_invalid_requests() { + let mut vm = run("CREATE CURSOR choices (id I)\nINSERT INTO choices VALUES (10)\nINSERT INTO choices VALUES (20)\nGO TOP\nDELETE\nSET DELETED ON\nCREATE CURSOR other (id I)"); + let preview = vm.grid_preview("choices"); + let generation = preview["generation"].as_str().unwrap(); + assert_eq!(preview["records"][0], 2); + assert!(vm.grid_select("choices", 1, generation).is_err()); + assert!(vm.grid_select("choices", 2, "stale").is_err()); + assert!(vm.grid_select("choices", 999, generation).is_err()); + vm.grid_select("choices", 2, generation).unwrap(); + assert_eq!(vm.grid_preview("choices")["currentRecord"], 2); + assert_eq!(vm.data().cursor().unwrap().recno(), 2); + assert!(vm.grid_select("missing", 2, generation).is_err()); +} diff --git a/docs/grid-cursor-preview.md b/docs/grid-cursor-preview.md index 7e0886d..626a844 100644 --- a/docs/grid-cursor-preview.md +++ b/docs/grid-cursor-preview.md @@ -14,11 +14,25 @@ Snapshots refresh after runtime state changes and desktop revisions. Closing an alias removes its old values and reports that the alias is not open. Rendering uses text nodes, not HTML, and does not execute ControlSource expressions. -This is a read-only preview, visibly labelled as such. Row navigation, editing, -Before/AfterRowColChange dispatch, Dynamic* expressions, per-column formatting, -record markers and full Grid parity remain unimplemented. Selecting a record via -the grid is not supported; consuming form actions still see the VM's current -record, which the preview never moves. +Clicking a displayed cell or pressing Up/Down/Home/End selects its physical +record in the VM and selects the source work area. Thus form actions read the +chosen record. Highlighting follows the VM pointer, including programmatic +movement. Selection is limited to the displayed snapshot; cells remain read-only. + +Navigation dispatches BeforeRowColChange with the old column, then the current +control's Valid, selects the target record, dispatches the target control's When, +and finally AfterRowColChange with the new column. NODEFAULT in Before cancels; +Valid returning false cancels. When returning false restores the previous visible +record: this is a conservative fallback, not VFP's full alternate-focus search. +RowColChange uses 1=row, 2=column, 3=both. Keyboard KeyPress can suppress default +navigation. Mouse selection also dispatches Grid.Click; keyboard navigation does +not synthesize a click. Full child-control focus/click/double-click behavior, +cell editing and Dynamic* expressions remain separate work. + +Every cursor installation gets a generation token. Stale requests after closing +and recreating an alias are rejected, as are disabled/rebound grids and concurrent +navigation on the same grid. Events that move the cursor themselves cancel the +pending default movement. No host file writes are performed by navigation. Host-backed tables, buffering, SET FILTER, controlling indexes, key ranges and relations report a limitation instead of showing an unfiltered or misleading diff --git a/src/renderer/src/runtime/controls/index.tsx b/src/renderer/src/runtime/controls/index.tsx index 8db9f88..fc6deec 100644 --- a/src/renderer/src/runtime/controls/index.tsx +++ b/src/renderer/src/runtime/controls/index.tsx @@ -20,6 +20,7 @@ import { colorCss, designSurfaces, fontStyle } from '../../designer/surfaces'; import { pictureProblem, pictureUrl } from '../pictureUrl'; import { useSessionStore } from '../session'; import { ImageList, TreeView } from '@shared/runtime/oleObjects'; +import { selectGridCell } from '../gridNavigation'; import type { GridPreview } from '../vmBridge'; import { OleTreeView } from './OleTreeView'; @@ -559,6 +560,28 @@ const RGrid: FC = ({ obj, props: r }) => { catch { return {error: 'Grid preview could not read the cursor'}; } }, [vm, status, revision, alias, sourceType]); const data = preview && !('error' in preview) ? preview : null; + const [selectionError, setSelectionError] = useState(''); + const select = async (record: number, column: number, click = true) => { + if (!data) return; + try { + if (await selectGridCell(obj, record, column, data.generation)) { + setSelectionError(''); + if (click) await obj.desktop.dispatch(obj, 'Click', []); + } + } catch (error) { setSelectionError(String(error)); } + }; + const keyDown = async (e: ReactKeyboardEvent) => { + if (!data || !['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(e.key)) { ev.common.onKeyDown(e); return; } + e.preventDefault(); + const key = e.key; + const outcome = await obj.desktop.dispatch(obj, 'KeyPress', [{ArrowUp: 5, ArrowDown: 24, Home: 1, End: 6}[key]!, shiftAltCtrl(e)]); + if (outcome?.nodefault) return; + const at = data.records.indexOf(data.currentRecord); + const index = key === 'Home' ? 0 : key === 'End' ? data.records.length - 1 + : Math.max(0, Math.min(data.records.length - 1, at + (key === 'ArrowDown' ? 1 : -1))); + const record = data.records[index]; + if (record !== undefined) await select(record, Math.max(1, Number(obj.get('ActiveColumn')) || 1), false); + }; const positions = columns.map((col, i) => { const source = String(col.get('ControlSource') ?? '').trim(); if (!source) return i; @@ -567,8 +590,8 @@ const RGrid: FC = ({ obj, props: r }) => { return data?.columns.findIndex((name) => name.toLowerCase() === parts.at(-1)?.toLowerCase()) ?? -1; }); return ( -
- +
{ void keyDown(e).catch(error => setSelectionError(String(error))); }}> +
{columns.map((col) => { @@ -583,16 +606,18 @@ const RGrid: FC = ({ obj, props: r }) => { {data?.rows.map((row, i) => ( - + {columns.map((col, j) => ( - {positions[j]! < 0 ? '(unsupported field)' : row[positions[j]!] ?? ''} + { const record = data.records[i]; if (record !== undefined) void select(record, j + 1); }}>{positions[j]! < 0 ? '(unsupported field)' : row[positions[j]!] ?? ''} ))} ))}
+ {selectionError &&
{selectionError}
} {preview && 'error' in preview &&
{preview.error}
} - {data &&
Read-only preview; row navigation and editing are not available.{data.truncated ? ' Preview shortened (200 rows / 512 characters per cell).' : ''}
} + {data &&
Select a row with a click or the arrow keys. Cell editing is not available.{data.truncated ? ' Preview shortened (200 rows / 512 characters per cell).' : ''}
}
); diff --git a/src/renderer/src/runtime/gridNavigation.ts b/src/renderer/src/runtime/gridNavigation.ts new file mode 100644 index 0000000..82bb353 --- /dev/null +++ b/src/renderer/src/runtime/gridNavigation.ts @@ -0,0 +1,79 @@ +/** Navigation for the bounded, in-memory Grid view. No database writes. */ +import type { RuntimeObject } from '@shared/runtime/objectModel'; +import { useSessionStore } from './session'; + +const pending = new WeakSet(); + +function enabled(obj: RuntimeObject): boolean { + for (let current: RuntimeObject | null = obj; current; current = current.parent) { + if (!current.alive || current.get('Enabled') === false || current.get('Visible') === false) return false; + } + return true; +} + +function control(grid: RuntimeObject, column: number): RuntimeObject | undefined { + const col = grid.children[column - 1]; + return col?.child(String(col.get('CurrentControl') || 'Text1')); +} + +/** Record numbers are physical DBF positions, not indexes in the displayed array. */ +export async function selectGridCell(grid: RuntimeObject, record: number, column: number, generation: string): Promise { + const state = useSessionStore.getState(); + const vm = state.vm; + if (!vm || state.scheduler?.isExecuting || pending.has(grid) || !enabled(grid)) return false; + const alias = String(grid.get('RecordSource') ?? ''); + if (Number(grid.get('RecordSourceType')) !== 1 || column < 1 || column > grid.children.length) return false; + const before = vm.gridPreview(alias); + if ('error' in before) throw new Error(before.error); + if (before.generation !== generation || !before.records.includes(record)) return false; + const oldColumn = Math.max(1, Number(grid.get('ActiveColumn')) || 1); + const change = (before.currentRecord !== record ? 1 : 0) | (oldColumn !== column ? 2 : 0); + if (!change) return true; + const stillBound = () => enabled(grid) && useSessionStore.getState().vm === vm + && String(grid.get('RecordSource')) === alias && Number(grid.get('RecordSourceType')) === 1; + const unchanged = () => { + if (!stillBound()) return false; + const current = vm.gridPreview(alias); + return !('error' in current) && current.generation === generation && current.currentRecord === before.currentRecord; + }; + pending.add(grid); + try { + grid.set('RowColChange', change); + const entering = control(grid, column); + const leaving = control(grid, oldColumn); + const outcome = await grid.desktop.dispatch(grid, 'BeforeRowColChange', [oldColumn]); + if (outcome?.nodefault || !unchanged()) return false; + if (leaving) { + const valid = await grid.desktop.dispatch(leaving, 'Valid', []); + if (valid?.value === false || !unchanged()) return false; + } + vm.gridSelect(alias, record, generation); + if (entering) { + const when = await grid.desktop.dispatch(entering, 'When', []); + if (!stillBound()) return false; + const current = vm.gridPreview(alias); + if ('error' in current || current.generation !== generation || current.currentRecord !== record) return false; + if (when?.value === false) { + // This view has no alternate editable cell to focus. Restore the prior + // visible record on rejection rather than displaying a false selection. + if (current.records.includes(before.currentRecord)) vm.gridSelect(alias, before.currentRecord, generation); + return false; + } + } + if (!stillBound()) return false; + const selected = vm.gridPreview(alias); + if ('error' in selected || selected.generation !== generation || selected.currentRecord !== record) return false; + grid.set('ActiveRow', selected.records.indexOf(record) + 1); + grid.set('ActiveColumn', column); + grid.setFocus(); + if (!stillBound()) return false; + const focused = vm.gridPreview(alias); + if ('error' in focused || focused.generation !== generation || focused.currentRecord !== record) return false; + await grid.desktop.dispatch(grid, 'AfterRowColChange', [column]); + return true; + } finally { + pending.delete(grid); + // Direct VM movement has no scheduler transition; notify all bound grids. + useSessionStore.setState(s => ({revision: s.revision + 1})); + } +} diff --git a/src/renderer/src/runtime/vmBridge.ts b/src/renderer/src/runtime/vmBridge.ts index 5d9389a..7e38cd3 100644 --- a/src/renderer/src/runtime/vmBridge.ts +++ b/src/renderer/src/runtime/vmBridge.ts @@ -13,7 +13,7 @@ import type { VmValue } from '@shared/runtime/values'; import { headerStem } from '@shared/runtime/programSource'; import { loadFoxVm, loadFoxVmSync, type FoxVmModule } from '../../../wasm/foxvm/loader'; -export type GridPreview = {error: string} | {columns: string[]; rows: string[][]; truncated: boolean}; +export type GridPreview = {error: string} | {columns: string[]; rows: string[][]; records: number[]; currentRecord: number; generation: string; truncated: boolean}; export interface MethodSourceInput { objectPath: string; @@ -116,6 +116,10 @@ class WasmVm implements VmLike { gridPreview(alias: string): GridPreview { return this.guard('gridPreview', () => this.vm.grid_preview(alias) as GridPreview); } + gridSelect(alias: string, record: number, generation: string): void { + if (!Number.isInteger(record) || record < 1 || record > 0xffffffff) throw new Error('Invalid grid record'); + this.guard('gridSelect', () => this.vm.grid_select(alias, record, generation)); + } /** The text of an XML document from its bytes, decoded as its declaration says. */ xmlText(bytes: string): string { return this.guard('xmlText', () => this.vm.xml_text(bytes)); diff --git a/tests/runtime/gridCursor.test.tsx b/tests/runtime/gridCursor.test.tsx index 6d6e7bc..d0b7327 100644 --- a/tests/runtime/gridCursor.test.tsx +++ b/tests/runtime/gridCursor.test.tsx @@ -1,22 +1,25 @@ import { act, screen } from '@testing-library/react'; -import { afterEach, expect, it } from 'vitest'; +import { afterEach, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; import { Desktop } from '@shared/runtime/objectModel'; +import { selectGridCell } from '@renderer/runtime/gridNavigation'; import { RuntimeControl } from '@renderer/runtime/RuntimeControl'; import { useSessionStore } from '@renderer/runtime/session'; import type { WasmVm } from '@renderer/runtime/vmBridge'; import { renderWithProviders } from '../helpers/render'; -const grid = () => new Desktop().instantiate({name: 'Form1', props: {}, methods: {}, children: [{ +const grid = () => new Desktop().instantiate({name: 'Form1', props: {Visible: true}, methods: {}, children: [{ id: 'grid', type: 'Grid', name: 'Choices', props: {RecordSource: 'choices', RecordSourceType: 1, Height: 180}, methods: {}, children: [{id: 'col', type: 'Column', name: 'Column1', props: {}, methods: {}, children: [ {id: 'head', type: 'Header', name: 'Header1', props: {Caption: 'Name'}, methods: {}}, + {id: 'text', type: 'TextBox', name: 'Text1', props: {}, methods: {}}, ]}], }]}, -1).children[0]!; afterEach(() => useSessionStore.setState({vm: null})); it('shows cursor values instead of empty placeholder cells', async () => { - const vm = {gridPreview: () => ({columns: ['NAME'], rows: [['Oak'], ['Birch']], truncated: false})} as unknown as WasmVm; + const vm = {gridPreview: () => ({columns: ['NAME'], rows: [['Oak'], ['Birch']], records: [1, 2], currentRecord: 1, generation: '1', truncated: false})} as unknown as WasmVm; useSessionStore.setState({vm, status: 'waiting'}); renderWithProviders(); expect(await screen.findByText('Oak')).toBeInTheDocument(); @@ -25,7 +28,7 @@ it('shows cursor values instead of empty placeholder cells', async () => { it('refreshes the preview after a runtime revision and clears closed sources', async () => { let value = 'Oak'; - const vm = {gridPreview: () => value ? {columns: ['NAME'], rows: [[value]], truncated: false} : {error: 'RecordSource alias is not open'}} as unknown as WasmVm; + const vm = {gridPreview: () => value ? {columns: ['NAME'], rows: [[value]], records: [1], currentRecord: 1, generation: '1', truncated: false} : {error: 'RecordSource alias is not open'}} as unknown as WasmVm; useSessionStore.setState({vm, status: 'waiting'}); renderWithProviders(); await screen.findByText('Oak'); @@ -40,7 +43,7 @@ it('refreshes the preview after a runtime revision and clears closed sources', a }); it('maps explicit fields and reports expressions instead of showing the wrong field', async () => { - const vm = {gridPreview: () => ({columns: ['NAME', 'CODE'], rows: [['Oak', 'A1']], truncated: false})} as unknown as WasmVm; + const vm = {gridPreview: () => ({columns: ['NAME', 'CODE'], rows: [['Oak', 'A1']], records: [1], currentRecord: 1, generation: '1', truncated: false})} as unknown as WasmVm; useSessionStore.setState({vm, status: 'waiting'}); const obj = grid(); obj.children[0]!.set('ControlSource', 'choices.code'); @@ -50,3 +53,66 @@ it('maps explicit fields and reports expressions instead of showing the wrong fi await act(() => { obj.children[0]!.set('ControlSource', 'UPPER(name)'); useSessionStore.setState(s => ({revision: s.revision + 1})); }); expect(await screen.findByText('(unsupported field)')).toBeInTheDocument(); }); + +it('moves the cursor when a displayed record is clicked', async () => { + let currentRecord = 1; + const select = vi.fn((_alias: string, record: number) => { currentRecord = record; }); + const vm = {gridPreview: () => ({columns: ['NAME'], rows: [['Oak'], ['Birch']], records: [1, 2], currentRecord, generation: '1', truncated: false}), gridSelect: select} as unknown as WasmVm; + useSessionStore.setState({vm, status: 'waiting', scheduler: null}); + const obj = grid(); + renderWithProviders(); + await userEvent.click(await screen.findByText('Birch')); + expect(select).toHaveBeenCalledWith('choices', 2, '1'); + expect(currentRecord).toBe(2); + expect(obj.get('ActiveRow')).toBe(2); +}); + +for (const scenario of ['nodefault', 'recreated', 'disabled'] as const) { + it(`rejects navigation when BeforeRowColChange leaves the source ${scenario}`, async () => { + let generation = '1'; + const select = vi.fn(); + const vm = {gridPreview: () => ({columns: ['NAME'], rows: [['Oak'], ['Birch']], records: [1, 2], currentRecord: 1, generation, truncated: false}), gridSelect: select} as unknown as WasmVm; + useSessionStore.setState({vm, status: 'waiting', scheduler: null}); + const obj = grid(); + vi.spyOn(obj.desktop, 'dispatch').mockImplementation(async (_obj, event) => { + if (event === 'BeforeRowColChange') { + if (scenario === 'recreated') generation = '2'; + if (scenario === 'disabled') obj.set('Enabled', false); + return {value: null, nodefault: scenario === 'nodefault'}; + } + return {value: null, nodefault: false}; + }); + expect(await selectGridCell(obj, 2, 1, '1')).toBe(false); + expect(select).not.toHaveBeenCalled(); + }); +} + +it('dispatches old and new column events around movement and supports keyboard selection', async () => { + let currentRecord = 1; + const order: string[] = []; + const vm = {gridPreview: () => ({columns: ['NAME'], rows: [['Oak'], ['Birch']], records: [1, 2], currentRecord, generation: '1', truncated: false}), gridSelect: (_alias: string, record: number) => {order.push('move'); currentRecord = record;}} as unknown as WasmVm; + useSessionStore.setState({vm, status: 'waiting', scheduler: null}); + const obj = grid(); + vi.spyOn(obj.desktop, 'dispatch').mockImplementation(async (_obj, event) => {order.push(event); return {value: null, nodefault: false};}); + renderWithProviders(); + await userEvent.click(await screen.findByText('Birch')); + expect(order.indexOf('BeforeRowColChange')).toBeLessThan(order.indexOf('move')); + expect(order.indexOf('AfterRowColChange')).toBeGreaterThan(order.indexOf('move')); + order.length = 0; + await userEvent.keyboard('{ArrowUp}'); + expect(currentRecord).toBe(1); + expect(order).not.toContain('Click'); +}); + +for (const rejected of ['Valid', 'When']) { + it(`keeps the prior record when ${rejected} rejects navigation`, async () => { + let currentRecord = 1; + const vm = {gridPreview: () => ({columns: ['NAME'], rows: [['Oak'], ['Birch']], records: [1, 2], currentRecord, generation: '1', truncated: false}), gridSelect: (_alias: string, record: number) => {currentRecord = record;}} as unknown as WasmVm; + useSessionStore.setState({vm, status: 'waiting', scheduler: null}); + const obj = grid(); + const dispatch = vi.spyOn(obj.desktop, 'dispatch').mockImplementation(async (_obj, event) => ({value: event === rejected ? false : null, nodefault: false})); + expect(await selectGridCell(obj, 2, 1, '1')).toBe(false); + expect(currentRecord).toBe(1); + expect(dispatch.mock.calls.some(call => call[1] === 'AfterRowColChange')).toBe(false); + }); +} diff --git a/tests/runtime/vmBridge.test.ts b/tests/runtime/vmBridge.test.ts index 3411231..6124664 100644 --- a/tests/runtime/vmBridge.test.ts +++ b/tests/runtime/vmBridge.test.ts @@ -117,6 +117,23 @@ describe('FoxVM bridge', () => { expect(host.lines.at(-1)?.toLowerCase()).toBe('other'); }); + it('selects physical rows and rejects a recreated alias across WASM', () => { + const host = new StubHost(); + const {vm, scheduler} = makeSession(host); + const run = (text: string) => scheduler.runProgram(vm.loadModule(compileProgram(text, 'select-test').bytes!)); + run("CREATE CURSOR choices (id I)\nINSERT INTO choices VALUES (10)\nINSERT INTO choices VALUES (20)\nGO TOP"); + const preview = vm.gridPreview('choices'); + if ('error' in preview) throw new Error(preview.error); + vm.gridSelect('choices', 2, preview.generation); + run('? id'); + expect(host.lines.at(-1)?.trim()).toBe('20'); + run('USE IN choices\nCREATE CURSOR choices (id I)\nINSERT INTO choices VALUES (30)'); + expect(() => vm.gridSelect('choices', 1, preview.generation)).toThrow(/source changed/i); + expect(() => vm.gridSelect('choices', -1, preview.generation)).toThrow(); + run('? id'); + expect(host.lines.at(-1)?.trim()).toBe('30'); + }); + it('reports compile errors instead of a module', () => { const out = compileProgram('IF x\n', 'broken'); expect(out.bytes).toBeNull();