Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions crates/foxvm/src/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,12 @@ pub const PAGE_RECORDS: u32 = 64;
pub struct DataSession {
areas: Vec<Option<Cursor>>,
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.
Expand Down Expand Up @@ -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<u32> {
pub fn install(&mut self, mut cursor: Cursor) -> Option<u32> {
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())
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions crates/foxvm/src/vm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1615,6 +1615,57 @@ 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 records = Vec::new();
let mut truncated = false;
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| {
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();
records.push(index + 1);
rows.push(cells);
}
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 {
&self.settings
}
Expand Down
9 changes: 9 additions & 0 deletions crates/foxvm/src/wasm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,15 @@ 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))
}

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();
Expand Down
50 changes: 50 additions & 0 deletions crates/foxvm/tests/grid_preview.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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");
}

#[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());
}
42 changes: 42 additions & 0 deletions docs/grid-cursor-preview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# 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.

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
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.
64 changes: 57 additions & 7 deletions src/renderer/src/runtime/controls/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ 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';

export interface RuntimeProps {
Expand Down Expand Up @@ -543,10 +545,53 @@ const RGrid: FC<RuntimeProps> = ({ 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<GridPreview | null>(() => {
// 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 [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;
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 (
<div ref={bind} tabIndex={-1} style={{ ...fill, overflow: 'auto', border: '1px solid var(--colorNeutralStroke1)', background: colorCss(r['BackColor']) }} {...ev.focus} {...ev.common}>
<Table size="extra-small" aria-label={obj.name} style={{ ...fontStyle(r), minWidth: 0 }}>
<div ref={bind} tabIndex={r['Enabled'] === false ? -1 : 0} style={{ ...fill, overflow: 'auto', border: '1px solid var(--colorNeutralStroke1)', background: colorCss(r['BackColor']) }} {...ev.focus} {...ev.common} onKeyDown={(e) => { void keyDown(e).catch(error => setSelectionError(String(error))); }}>
<Table role="grid" size="extra-small" aria-label={obj.name} style={{ ...fontStyle(r), minWidth: 0 }}>
<TableHeader>
<TableRow>
{columns.map((col) => {
Expand All @@ -560,15 +605,20 @@ const RGrid: FC<RuntimeProps> = ({ obj, props: r }) => {
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: rows }).map((_, i) => (
<TableRow key={i}>
{columns.map((col) => (
<TableCell key={col.handle} />
{data?.rows.map((row, i) => (
<TableRow key={data.records[i] ?? i} aria-selected={data.currentRecord === data.records[i]}
style={data.currentRecord === data.records[i] ? {background: colorCss(r['HighlightBackColor'] ?? 0x800000), color: colorCss(r['HighlightForeColor'] ?? 0xffffff)} : undefined}>
{columns.map((col, j) => (
<TableCell key={col.handle} role="gridcell" onClick={() => { const record = data.records[i]; if (record !== undefined) void select(record, j + 1); }}>{positions[j]! < 0 ? '(unsupported field)' : row[positions[j]!] ?? ''}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
{selectionError && <div role="alert">{selectionError}</div>}
{preview && 'error' in preview && <div role="status">{preview.error}</div>}
{data && <div role="status">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).' : ''}</div>}

</div>
);
};
Expand Down
79 changes: 79 additions & 0 deletions src/renderer/src/runtime/gridNavigation.ts
Original file line number Diff line number Diff line change
@@ -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<RuntimeObject>();

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<boolean> {
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}));
}
}
Loading
Loading