refactor(command): unify layout access and navigation commands
Refactor `CmdContext` to delegate layout-related information (row/column counts, categories, cell keys) to a `GridLayout` object. - Add `layout` field to `CmdContext` . - Implement helper methods on `CmdContext` to access layout data. - Consolidate multiple jump commands ( `JumpToFirstRow` , `JumpToLastRow` , `JumpToFirstCol` , `JumpToLastCol` ) into a single `JumpToEdge` command. - Introduce `ScrollRows` and `PageScroll` commands for improved navigation. - Update `CursorState` instantiation to use the new context structure. - Update command registry to use the new unified commands and macros. Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
This commit is contained in:
@ -12,6 +12,7 @@ use crate::view::{Axis, AxisEntry, GridLayout};
|
||||
/// Read-only context available to commands for decision-making.
|
||||
pub struct CmdContext<'a> {
|
||||
pub model: &'a Model,
|
||||
pub layout: &'a GridLayout,
|
||||
pub mode: &'a AppMode,
|
||||
pub selected: (usize, usize),
|
||||
pub row_offset: usize,
|
||||
@ -31,24 +32,12 @@ pub struct CmdContext<'a> {
|
||||
pub tile_cat_idx: usize,
|
||||
/// Named text buffers
|
||||
pub buffers: &'a HashMap<String, String>,
|
||||
/// Pre-resolved cell key at the cursor position (None if out of bounds)
|
||||
pub cell_key: Option<crate::model::cell::CellKey>,
|
||||
/// Grid dimensions (so commands don't need GridLayout)
|
||||
pub row_count: usize,
|
||||
pub col_count: usize,
|
||||
/// Categories on Axis::None — aggregated away in the current view
|
||||
pub none_cats: Vec<String>,
|
||||
/// View navigation stacks (for drill back/forward)
|
||||
pub view_back_stack: Vec<String>,
|
||||
pub view_forward_stack: Vec<String>,
|
||||
/// Records-mode info (drill view). None for normal pivot views.
|
||||
/// When Some, edits stage to drill_state.pending_edits.
|
||||
pub records_col: Option<String>,
|
||||
/// The display value at the cursor in records mode (including any
|
||||
/// pending edit override). None for normal pivot views.
|
||||
pub records_value: Option<String>,
|
||||
pub view_back_stack: &'a [String],
|
||||
pub view_forward_stack: &'a [String],
|
||||
/// Display value at the cursor — works uniformly for pivot and records mode.
|
||||
pub display_value: String,
|
||||
/// How many data rows/cols fit on screen (for viewport scrolling).
|
||||
/// Defaults to generous fallbacks when unknown.
|
||||
pub visible_rows: usize,
|
||||
pub visible_cols: usize,
|
||||
/// Expanded categories in the tree panel
|
||||
@ -57,6 +46,21 @@ pub struct CmdContext<'a> {
|
||||
pub key_code: KeyCode,
|
||||
}
|
||||
|
||||
impl<'a> CmdContext<'a> {
|
||||
pub fn cell_key(&self) -> Option<CellKey> {
|
||||
self.layout.cell_key(self.selected.0, self.selected.1)
|
||||
}
|
||||
pub fn row_count(&self) -> usize {
|
||||
self.layout.row_count()
|
||||
}
|
||||
pub fn col_count(&self) -> usize {
|
||||
self.layout.col_count()
|
||||
}
|
||||
pub fn none_cats(&self) -> &[String] {
|
||||
&self.layout.none_cats
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> CmdContext<'a> {
|
||||
/// Resolve the category panel tree entry at the current cursor.
|
||||
pub fn cat_tree_entry(&self) -> Option<crate::ui::cat_tree::CatTreeEntry> {
|
||||
@ -251,8 +255,8 @@ impl CursorState {
|
||||
Self {
|
||||
row: ctx.selected.0,
|
||||
col: ctx.selected.1,
|
||||
row_count: ctx.row_count,
|
||||
col_count: ctx.col_count,
|
||||
row_count: ctx.row_count(),
|
||||
col_count: ctx.col_count(),
|
||||
row_offset: ctx.row_offset,
|
||||
col_offset: ctx.col_offset,
|
||||
visible_rows: ctx.visible_rows,
|
||||
@ -317,75 +321,32 @@ impl Cmd for MoveSelection {
|
||||
}
|
||||
}
|
||||
|
||||
/// Unified jump-to-edge: jump to first/last row or column.
|
||||
/// `is_row` selects the axis; `end` selects first (false) or last (true).
|
||||
#[derive(Debug)]
|
||||
pub struct JumpToFirstRow {
|
||||
pub col: usize,
|
||||
pub struct JumpToEdge {
|
||||
pub cursor: CursorState,
|
||||
pub is_row: bool,
|
||||
pub end: bool,
|
||||
pub cmd_name: &'static str,
|
||||
}
|
||||
impl Cmd for JumpToFirstRow {
|
||||
impl Cmd for JumpToEdge {
|
||||
fn name(&self) -> &'static str {
|
||||
"jump-first-row"
|
||||
self.cmd_name
|
||||
}
|
||||
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
vec![
|
||||
Box::new(effect::SetSelected(0, self.col)),
|
||||
Box::new(effect::SetRowOffset(0)),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct JumpToLastRow {
|
||||
pub col: usize,
|
||||
pub row_count: usize,
|
||||
pub row_offset: usize,
|
||||
}
|
||||
impl Cmd for JumpToLastRow {
|
||||
fn name(&self) -> &'static str {
|
||||
"jump-last-row"
|
||||
}
|
||||
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let last = self.row_count.saturating_sub(1);
|
||||
let mut effects: Vec<Box<dyn Effect>> = vec![Box::new(effect::SetSelected(last, self.col))];
|
||||
if last >= self.row_offset + 20 {
|
||||
effects.push(Box::new(effect::SetRowOffset(last.saturating_sub(19))));
|
||||
}
|
||||
effects
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct JumpToFirstCol {
|
||||
pub row: usize,
|
||||
}
|
||||
impl Cmd for JumpToFirstCol {
|
||||
fn name(&self) -> &'static str {
|
||||
"jump-first-col"
|
||||
}
|
||||
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
vec![
|
||||
Box::new(effect::SetSelected(self.row, 0)),
|
||||
Box::new(effect::SetColOffset(0)),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct JumpToLastCol {
|
||||
pub row: usize,
|
||||
pub col_count: usize,
|
||||
pub col_offset: usize,
|
||||
}
|
||||
impl Cmd for JumpToLastCol {
|
||||
fn name(&self) -> &'static str {
|
||||
"jump-last-col"
|
||||
}
|
||||
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let last = self.col_count.saturating_sub(1);
|
||||
let mut effects: Vec<Box<dyn Effect>> = vec![Box::new(effect::SetSelected(self.row, last))];
|
||||
if last >= self.col_offset + 8 {
|
||||
effects.push(Box::new(effect::SetColOffset(last.saturating_sub(7))));
|
||||
}
|
||||
effects
|
||||
let (nr, nc) = if self.is_row {
|
||||
let r = if self.end { self.cursor.row_count.saturating_sub(1) } else { 0 };
|
||||
(r, self.cursor.col)
|
||||
} else {
|
||||
let c = if self.end { self.cursor.col_count.saturating_sub(1) } else { 0 };
|
||||
(self.cursor.row, c)
|
||||
};
|
||||
viewport_effects(
|
||||
nr, nc,
|
||||
self.cursor.row_offset, self.cursor.col_offset,
|
||||
self.cursor.visible_rows, self.cursor.visible_cols,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -401,19 +362,38 @@ impl Cmd for ScrollRows {
|
||||
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let row_max = self.cursor.row_count.saturating_sub(1) as i32;
|
||||
let nr = (self.cursor.row as i32 + self.delta).clamp(0, row_max) as usize;
|
||||
let mut effects: Vec<Box<dyn Effect>> =
|
||||
vec![Box::new(effect::SetSelected(nr, self.cursor.col))];
|
||||
let mut row_offset = self.cursor.row_offset;
|
||||
if nr < row_offset {
|
||||
row_offset = nr;
|
||||
viewport_effects(
|
||||
nr,
|
||||
self.cursor.col,
|
||||
self.cursor.row_offset,
|
||||
self.cursor.col_offset,
|
||||
self.cursor.visible_rows,
|
||||
self.cursor.visible_cols,
|
||||
)
|
||||
}
|
||||
if nr >= row_offset + 20 {
|
||||
row_offset = nr.saturating_sub(19);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PageScroll {
|
||||
pub direction: i32, // 1 for down, -1 for up
|
||||
pub cursor: CursorState,
|
||||
}
|
||||
impl Cmd for PageScroll {
|
||||
fn name(&self) -> &'static str {
|
||||
"page-scroll"
|
||||
}
|
||||
if row_offset != self.cursor.row_offset {
|
||||
effects.push(Box::new(effect::SetRowOffset(row_offset)));
|
||||
}
|
||||
effects
|
||||
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let delta = (self.cursor.visible_rows as i32 * 3 / 4).max(1) * self.direction;
|
||||
let row_max = self.cursor.row_count.saturating_sub(1) as i32;
|
||||
let nr = (self.cursor.row as i32 + delta).clamp(0, row_max) as usize;
|
||||
viewport_effects(
|
||||
nr,
|
||||
self.cursor.col,
|
||||
self.cursor.row_offset,
|
||||
self.cursor.col_offset,
|
||||
self.cursor.visible_rows,
|
||||
self.cursor.visible_cols,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -471,7 +451,7 @@ impl Cmd for SaveAndQuit {
|
||||
|
||||
// ── Cell operations ──────────────────────────────────────────────────────────
|
||||
// All cell commands take an explicit CellKey. The interactive spec fills it
|
||||
// from ctx.cell_key; the parser fills it from Cat/Item coordinate args.
|
||||
// from ctx.cell_key(); the parser fills it from Cat/Item coordinate args.
|
||||
|
||||
/// Clear a cell.
|
||||
#[derive(Debug)]
|
||||
@ -500,8 +480,7 @@ impl Cmd for YankCell {
|
||||
"yank"
|
||||
}
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let layout = GridLayout::new(ctx.model, ctx.model.active_view());
|
||||
let value = ctx.model.evaluate_aggregated(&self.key, &layout.none_cats);
|
||||
let value = ctx.model.evaluate_aggregated(&self.key, ctx.none_cats());
|
||||
vec![
|
||||
Box::new(effect::SetYanked(value)),
|
||||
effect::set_status("Yanked"),
|
||||
@ -590,12 +569,7 @@ impl Cmd for TogglePanelAndFocus {
|
||||
open: self.open,
|
||||
}));
|
||||
if self.focused {
|
||||
let mode = match self.panel {
|
||||
Panel::Formula => AppMode::FormulaPanel,
|
||||
Panel::Category => AppMode::CategoryPanel,
|
||||
Panel::View => AppMode::ViewPanel,
|
||||
};
|
||||
effects.push(effect::change_mode(mode));
|
||||
effects.push(effect::change_mode(self.panel.mode()));
|
||||
} else {
|
||||
effects.push(effect::change_mode(AppMode::Normal));
|
||||
}
|
||||
@ -682,33 +656,29 @@ impl Cmd for EditOrDrill {
|
||||
// Only consider regular (non-virtual, non-label) categories on None
|
||||
// as true aggregation. Virtuals like _Index/_Dim are always None in
|
||||
// pivot mode and don't imply aggregation.
|
||||
let regular_none = ctx.none_cats.iter().any(|c| {
|
||||
let regular_none = ctx.none_cats().iter().any(|c| {
|
||||
ctx.model
|
||||
.category(c)
|
||||
.map(|cat| cat.kind.is_regular())
|
||||
.unwrap_or(false)
|
||||
});
|
||||
let is_aggregated = ctx.records_col.is_none() && regular_none;
|
||||
// In records mode (synthetic key), always edit directly — no drilling.
|
||||
let is_synthetic = ctx
|
||||
.cell_key()
|
||||
.as_ref()
|
||||
.and_then(|k| crate::view::synthetic_record_info(k))
|
||||
.is_some();
|
||||
let is_aggregated = !is_synthetic && regular_none;
|
||||
if is_aggregated {
|
||||
let Some(key) = ctx.cell_key.clone() else {
|
||||
return vec![effect::set_status(
|
||||
"cannot drill — no cell at cursor",
|
||||
)];
|
||||
let Some(key) = ctx.cell_key().clone() else {
|
||||
return vec![effect::set_status("cannot drill — no cell at cursor")];
|
||||
};
|
||||
return DrillIntoCell { key }.execute(ctx);
|
||||
}
|
||||
// Edit path: prefer records display value (includes pending edits),
|
||||
// else the underlying cell's stored value.
|
||||
let initial_value = if let Some(v) = &ctx.records_value {
|
||||
v.clone()
|
||||
} else {
|
||||
ctx.cell_key
|
||||
.as_ref()
|
||||
.and_then(|k| ctx.model.get_cell(k).cloned())
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
EnterEditMode { initial_value }.execute(ctx)
|
||||
EnterEditMode {
|
||||
initial_value: ctx.display_value.clone(),
|
||||
}
|
||||
.execute(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@ -721,8 +691,15 @@ impl Cmd for AddRecordRow {
|
||||
"add-record-row"
|
||||
}
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
if ctx.records_col.is_none() {
|
||||
return vec![effect::set_status("add-record-row only works in records mode")];
|
||||
let is_records = ctx
|
||||
.cell_key()
|
||||
.as_ref()
|
||||
.and_then(|k| crate::view::synthetic_record_info(k))
|
||||
.is_some();
|
||||
if !is_records {
|
||||
return vec![effect::set_status(
|
||||
"add-record-row only works in records mode",
|
||||
)];
|
||||
}
|
||||
// Build a CellKey from the current page filters
|
||||
let view = ctx.model.active_view();
|
||||
@ -751,6 +728,30 @@ impl Cmd for AddRecordRow {
|
||||
}
|
||||
}
|
||||
|
||||
/// Vim-style 'o': add a new record row below cursor and enter edit mode.
|
||||
#[derive(Debug)]
|
||||
pub struct OpenRecordRow;
|
||||
impl Cmd for OpenRecordRow {
|
||||
fn name(&self) -> &'static str {
|
||||
"open-record-row"
|
||||
}
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let is_records = ctx
|
||||
.cell_key()
|
||||
.as_ref()
|
||||
.and_then(|k| crate::view::synthetic_record_info(k))
|
||||
.is_some();
|
||||
if !is_records {
|
||||
return vec![effect::set_status(
|
||||
"open-record-row only works in records mode",
|
||||
)];
|
||||
}
|
||||
let mut effects = AddRecordRow.execute(ctx);
|
||||
effects.push(Box::new(effect::EnterEditAtCursor));
|
||||
effects
|
||||
}
|
||||
}
|
||||
|
||||
/// Typewriter-style advance: move down, wrap to top of next column at bottom.
|
||||
#[derive(Debug)]
|
||||
pub struct EnterAdvance {
|
||||
@ -804,10 +805,9 @@ impl Cmd for SearchNavigate {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let layout = GridLayout::new(ctx.model, ctx.model.active_view());
|
||||
let (cur_row, cur_col) = ctx.selected;
|
||||
let total_rows = layout.row_count().max(1);
|
||||
let total_cols = layout.col_count().max(1);
|
||||
let total_rows = ctx.row_count().max(1);
|
||||
let total_cols = ctx.col_count().max(1);
|
||||
let total = total_rows * total_cols;
|
||||
let cur_flat = cur_row * total_cols + cur_col;
|
||||
|
||||
@ -815,11 +815,11 @@ impl Cmd for SearchNavigate {
|
||||
.filter(|&flat| {
|
||||
let ri = flat / total_cols;
|
||||
let ci = flat % total_cols;
|
||||
let key = match layout.cell_key(ri, ci) {
|
||||
let key = match ctx.layout.cell_key(ri, ci) {
|
||||
Some(k) => k,
|
||||
None => return false,
|
||||
};
|
||||
let s = match ctx.model.evaluate_aggregated(&key, &layout.none_cats) {
|
||||
let s = match ctx.model.evaluate_aggregated(&key, ctx.none_cats()) {
|
||||
Some(CellValue::Number(n)) => format!("{n}"),
|
||||
Some(CellValue::Text(t)) => t,
|
||||
None => String::new(),
|
||||
@ -1018,9 +1018,8 @@ impl Cmd for ToggleGroupUnderCursor {
|
||||
"toggle-group-under-cursor"
|
||||
}
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let layout = GridLayout::new(ctx.model, ctx.model.active_view());
|
||||
let sel_row = ctx.selected.0;
|
||||
let Some((cat, group)) = layout.row_group_for(sel_row) else {
|
||||
let Some((cat, group)) = ctx.layout.row_group_for(sel_row) else {
|
||||
return vec![];
|
||||
};
|
||||
vec![
|
||||
@ -1041,9 +1040,8 @@ impl Cmd for ToggleColGroupUnderCursor {
|
||||
"toggle-col-group-under-cursor"
|
||||
}
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let layout = GridLayout::new(ctx.model, ctx.model.active_view());
|
||||
let sel_col = ctx.selected.1;
|
||||
let Some((cat, group)) = layout.col_group_for(sel_col) else {
|
||||
let Some((cat, group)) = ctx.layout.col_group_for(sel_col) else {
|
||||
return vec![];
|
||||
};
|
||||
// After toggling, col_count may shrink — clamp selection
|
||||
@ -1067,12 +1065,12 @@ impl Cmd for HideSelectedRowItem {
|
||||
"hide-selected-row-item"
|
||||
}
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let layout = GridLayout::new(ctx.model, ctx.model.active_view());
|
||||
let Some(cat_name) = layout.row_cats.first().cloned() else {
|
||||
let Some(cat_name) = ctx.layout.row_cats.first().cloned() else {
|
||||
return vec![];
|
||||
};
|
||||
let sel_row = ctx.selected.0;
|
||||
let Some(items) = layout
|
||||
let Some(items) = ctx
|
||||
.layout
|
||||
.row_items
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
@ -1199,7 +1197,7 @@ impl Cmd for DrillIntoCell {
|
||||
// Previously-aggregated categories (none_cats) stay on Axis::None so
|
||||
// they don't filter records; they'll appear as columns in records mode.
|
||||
// Skip virtual categories — we already set _Index/_Dim above.
|
||||
for cat in &ctx.none_cats {
|
||||
for cat in ctx.none_cats() {
|
||||
if fixed_cats.contains(cat) || cat.starts_with('_') {
|
||||
continue;
|
||||
}
|
||||
@ -1436,64 +1434,38 @@ impl Cmd for ToggleRecordsMode {
|
||||
"toggle-records-mode"
|
||||
}
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
use crate::view::Axis;
|
||||
let view = ctx.model.active_view();
|
||||
|
||||
// Detect current state
|
||||
let is_records = view
|
||||
.category_axes
|
||||
.get("_Index")
|
||||
.copied()
|
||||
== Some(Axis::Row)
|
||||
&& view.category_axes.get("_Dim").copied() == Some(Axis::Column);
|
||||
|
||||
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
||||
let is_records = ctx.layout.is_records_mode();
|
||||
|
||||
if is_records {
|
||||
// Switch back to pivot: auto-assign axes
|
||||
// First regular category → Row, second → Column, rest → Page,
|
||||
// virtuals/labels → None.
|
||||
let mut row_done = false;
|
||||
let mut col_done = false;
|
||||
for (name, cat) in &ctx.model.categories {
|
||||
let axis = if !cat.kind.is_regular() {
|
||||
Axis::None
|
||||
} else if !row_done {
|
||||
row_done = true;
|
||||
Axis::Row
|
||||
} else if !col_done {
|
||||
col_done = true;
|
||||
Axis::Column
|
||||
} else {
|
||||
Axis::Page
|
||||
};
|
||||
effects.push(Box::new(effect::SetAxis {
|
||||
category: name.clone(),
|
||||
axis,
|
||||
}));
|
||||
// Navigate back to the previous view (restores original axes)
|
||||
return vec![Box::new(effect::ViewBack), effect::set_status("Pivot mode")];
|
||||
}
|
||||
effects.push(effect::set_status("Pivot mode"));
|
||||
} else {
|
||||
// Switch to records mode
|
||||
|
||||
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
||||
let records_name = "_Records".to_string();
|
||||
|
||||
// Create (or replace) a _Records view and switch to it
|
||||
effects.push(Box::new(effect::CreateView(records_name.clone())));
|
||||
effects.push(Box::new(effect::SwitchView(records_name)));
|
||||
|
||||
// _Index on Row, _Dim on Column, everything else → None
|
||||
effects.push(Box::new(effect::SetAxis {
|
||||
category: "_Index".to_string(),
|
||||
axis: Axis::Row,
|
||||
axis: crate::view::Axis::Row,
|
||||
}));
|
||||
effects.push(Box::new(effect::SetAxis {
|
||||
category: "_Dim".to_string(),
|
||||
axis: Axis::Column,
|
||||
axis: crate::view::Axis::Column,
|
||||
}));
|
||||
// Everything else → None
|
||||
for name in ctx.model.categories.keys() {
|
||||
if name != "_Index" && name != "_Dim" {
|
||||
effects.push(Box::new(effect::SetAxis {
|
||||
category: name.clone(),
|
||||
axis: Axis::None,
|
||||
axis: crate::view::Axis::None,
|
||||
}));
|
||||
}
|
||||
}
|
||||
effects.push(effect::set_status("Records mode"));
|
||||
}
|
||||
effects
|
||||
}
|
||||
}
|
||||
@ -1928,14 +1900,34 @@ impl Cmd for PopChar {
|
||||
|
||||
/// Commit a cell edit: set cell value, advance cursor, return to Normal.
|
||||
/// In records mode, stages the edit in drill_state.pending_edits instead of
|
||||
/// writing directly to the model.
|
||||
/// Commit a cell value: for synthetic records keys, stage in drill pending edits
|
||||
/// or apply directly; for real keys, write to the model.
|
||||
fn commit_cell_value(key: &CellKey, value: &str, effects: &mut Vec<Box<dyn Effect>>) {
|
||||
if let Some((record_idx, col_name)) = crate::view::synthetic_record_info(key) {
|
||||
effects.push(Box::new(effect::SetDrillPendingEdit {
|
||||
record_idx,
|
||||
col_name,
|
||||
new_value: value.to_string(),
|
||||
}));
|
||||
} else if value.is_empty() {
|
||||
effects.push(Box::new(effect::ClearCell(key.clone())));
|
||||
effects.push(effect::mark_dirty());
|
||||
} else if let Ok(n) = value.parse::<f64>() {
|
||||
effects.push(Box::new(effect::SetCell(key.clone(), CellValue::Number(n))));
|
||||
effects.push(effect::mark_dirty());
|
||||
} else {
|
||||
effects.push(Box::new(effect::SetCell(key.clone(), CellValue::Text(value.to_string()))));
|
||||
effects.push(effect::mark_dirty());
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit a cell edit: set cell value, advance cursor, return to editing.
|
||||
/// In records mode with drill, stages the edit in drill_state.pending_edits.
|
||||
/// In records mode without drill or in pivot mode, writes directly to the model.
|
||||
#[derive(Debug)]
|
||||
pub struct CommitCellEdit {
|
||||
pub key: crate::model::cell::CellKey,
|
||||
pub key: CellKey,
|
||||
pub value: String,
|
||||
/// Records-mode edit: (record_idx, column_name). When Some, stage in
|
||||
/// pending_edits; otherwise write to the model directly.
|
||||
pub records_edit: Option<(usize, String)>,
|
||||
}
|
||||
impl Cmd for CommitCellEdit {
|
||||
fn name(&self) -> &'static str {
|
||||
@ -1944,29 +1936,7 @@ impl Cmd for CommitCellEdit {
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
||||
|
||||
if let Some((record_idx, col_name)) = &self.records_edit {
|
||||
// Stage the edit in drill_state.pending_edits
|
||||
effects.push(Box::new(effect::SetDrillPendingEdit {
|
||||
record_idx: *record_idx,
|
||||
col_name: col_name.clone(),
|
||||
new_value: self.value.clone(),
|
||||
}));
|
||||
} else if self.value.is_empty() {
|
||||
effects.push(Box::new(effect::ClearCell(self.key.clone())));
|
||||
effects.push(effect::mark_dirty());
|
||||
} else if let Ok(n) = self.value.parse::<f64>() {
|
||||
effects.push(Box::new(effect::SetCell(
|
||||
self.key.clone(),
|
||||
CellValue::Number(n),
|
||||
)));
|
||||
effects.push(effect::mark_dirty());
|
||||
} else {
|
||||
effects.push(Box::new(effect::SetCell(
|
||||
self.key.clone(),
|
||||
CellValue::Text(self.value.clone()),
|
||||
)));
|
||||
effects.push(effect::mark_dirty());
|
||||
}
|
||||
commit_cell_value(&self.key, &self.value, &mut effects);
|
||||
// Advance cursor down (typewriter-style) and re-enter edit mode
|
||||
// at the new cell so the user can continue data entry.
|
||||
let adv = EnterAdvance {
|
||||
@ -1978,6 +1948,36 @@ impl Cmd for CommitCellEdit {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tab in editing: commit cell, move right, re-enter edit mode (Excel-style).
|
||||
#[derive(Debug)]
|
||||
pub struct CommitAndAdvanceRight {
|
||||
pub key: CellKey,
|
||||
pub value: String,
|
||||
pub cursor: CursorState,
|
||||
}
|
||||
impl Cmd for CommitAndAdvanceRight {
|
||||
fn name(&self) -> &'static str {
|
||||
"commit-and-advance-right"
|
||||
}
|
||||
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
||||
commit_cell_value(&self.key, &self.value, &mut effects);
|
||||
// Move right instead of down
|
||||
let col_max = self.cursor.col_count.saturating_sub(1);
|
||||
let nc = (self.cursor.col + 1).min(col_max);
|
||||
effects.extend(viewport_effects(
|
||||
self.cursor.row,
|
||||
nc,
|
||||
self.cursor.row_offset,
|
||||
self.cursor.col_offset,
|
||||
self.cursor.visible_rows,
|
||||
self.cursor.visible_cols,
|
||||
));
|
||||
effects.push(Box::new(effect::EnterEditAtCursor));
|
||||
effects
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit a formula from the formula edit buffer.
|
||||
#[derive(Debug)]
|
||||
pub struct CommitFormula;
|
||||
@ -2430,7 +2430,7 @@ pub fn default_registry() -> CmdRegistry {
|
||||
}))
|
||||
},
|
||||
|_args, ctx| {
|
||||
let key = ctx.cell_key.clone().ok_or("no cell at cursor")?;
|
||||
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
|
||||
Ok(Box::new(ClearCellCommand { key }))
|
||||
},
|
||||
);
|
||||
@ -2482,58 +2482,20 @@ pub fn default_registry() -> CmdRegistry {
|
||||
}))
|
||||
},
|
||||
);
|
||||
r.register(
|
||||
&JumpToFirstRow { col: 0 },
|
||||
|_| Ok(Box::new(JumpToFirstRow { col: 0 })),
|
||||
|_, ctx| {
|
||||
Ok(Box::new(JumpToFirstRow {
|
||||
col: ctx.selected.1,
|
||||
}))
|
||||
},
|
||||
);
|
||||
r.register(
|
||||
&JumpToLastRow { col: 0, row_count: 0, row_offset: 0 },
|
||||
|_| {
|
||||
Ok(Box::new(JumpToLastRow {
|
||||
col: 0,
|
||||
row_count: 0,
|
||||
row_offset: 0,
|
||||
}))
|
||||
},
|
||||
|_, ctx| {
|
||||
Ok(Box::new(JumpToLastRow {
|
||||
col: ctx.selected.1,
|
||||
row_count: ctx.row_count,
|
||||
row_offset: ctx.row_offset,
|
||||
}))
|
||||
},
|
||||
);
|
||||
r.register(
|
||||
&JumpToFirstCol { row: 0 },
|
||||
|_| Ok(Box::new(JumpToFirstCol { row: 0 })),
|
||||
|_, ctx| {
|
||||
Ok(Box::new(JumpToFirstCol {
|
||||
row: ctx.selected.0,
|
||||
}))
|
||||
},
|
||||
);
|
||||
r.register(
|
||||
&JumpToLastCol { row: 0, col_count: 0, col_offset: 0 },
|
||||
|_| {
|
||||
Ok(Box::new(JumpToLastCol {
|
||||
row: 0,
|
||||
col_count: 0,
|
||||
col_offset: 0,
|
||||
}))
|
||||
},
|
||||
|_, ctx| {
|
||||
Ok(Box::new(JumpToLastCol {
|
||||
row: ctx.selected.0,
|
||||
col_count: ctx.col_count,
|
||||
col_offset: ctx.col_offset,
|
||||
}))
|
||||
},
|
||||
// Jump-to-edge commands: first/last row/col
|
||||
macro_rules! reg_jump {
|
||||
($r:expr, $is_row:expr, $end:expr, $name:expr) => {
|
||||
$r.register(
|
||||
&JumpToEdge { cursor: CursorState::default(), is_row: $is_row, end: $end, cmd_name: $name },
|
||||
|_| Ok(Box::new(JumpToEdge { cursor: CursorState::default(), is_row: $is_row, end: $end, cmd_name: $name })),
|
||||
|_, ctx| Ok(Box::new(JumpToEdge { cursor: CursorState::from_ctx(ctx), is_row: $is_row, end: $end, cmd_name: $name })),
|
||||
);
|
||||
};
|
||||
}
|
||||
reg_jump!(r, true, false, "jump-first-row");
|
||||
reg_jump!(r, true, true, "jump-last-row");
|
||||
reg_jump!(r, false, false, "jump-first-col");
|
||||
reg_jump!(r, false, true, "jump-last-col");
|
||||
r.register(
|
||||
&ScrollRows { delta: 0, cursor: CursorState::default() },
|
||||
|args| {
|
||||
@ -2562,6 +2524,25 @@ pub fn default_registry() -> CmdRegistry {
|
||||
}))
|
||||
},
|
||||
);
|
||||
r.register(
|
||||
&PageScroll { direction: 0, cursor: CursorState::default() },
|
||||
|args| {
|
||||
require_args("page-scroll", args, 1)?;
|
||||
let dir = args[0].parse::<i32>().map_err(|e| e.to_string())?;
|
||||
Ok(Box::new(PageScroll {
|
||||
direction: dir,
|
||||
cursor: CursorState::default(),
|
||||
}))
|
||||
},
|
||||
|args, ctx| {
|
||||
require_args("page-scroll", args, 1)?;
|
||||
let dir = args[0].parse::<i32>().map_err(|e| e.to_string())?;
|
||||
Ok(Box::new(PageScroll {
|
||||
direction: dir,
|
||||
cursor: CursorState::from_ctx(ctx),
|
||||
}))
|
||||
},
|
||||
);
|
||||
r.register(
|
||||
&EnterAdvance { cursor: CursorState::default() },
|
||||
|_| {
|
||||
@ -2597,12 +2578,14 @@ pub fn default_registry() -> CmdRegistry {
|
||||
}))
|
||||
},
|
||||
|_args, ctx| {
|
||||
let key = ctx.cell_key.clone().ok_or("no cell at cursor")?;
|
||||
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
|
||||
Ok(Box::new(YankCell { key }))
|
||||
},
|
||||
);
|
||||
r.register(
|
||||
&PasteCell { key: CellKey::new(vec![]) },
|
||||
&PasteCell {
|
||||
key: CellKey::new(vec![]),
|
||||
},
|
||||
|args| {
|
||||
if args.is_empty() {
|
||||
return Err("paste requires at least one Cat/Item coordinate".into());
|
||||
@ -2612,11 +2595,11 @@ pub fn default_registry() -> CmdRegistry {
|
||||
}))
|
||||
},
|
||||
|_args, ctx| {
|
||||
let key = ctx.cell_key.clone().ok_or("no cell at cursor")?;
|
||||
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
|
||||
Ok(Box::new(PasteCell { key }))
|
||||
},
|
||||
);
|
||||
// clear-cell is registered above (unified: ctx.cell_key or explicit coords)
|
||||
// clear-cell is registered above (unified: ctx.cell_key() or explicit coords)
|
||||
|
||||
// ── View / page ──────────────────────────────────────────────────────
|
||||
r.register_nullary(|| Box::new(TransposeAxes));
|
||||
@ -2663,7 +2646,7 @@ pub fn default_registry() -> CmdRegistry {
|
||||
}))
|
||||
},
|
||||
|_args, ctx| {
|
||||
let key = ctx.cell_key.clone().ok_or("no cell at cursor")?;
|
||||
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
|
||||
Ok(Box::new(DrillIntoCell { key }))
|
||||
},
|
||||
);
|
||||
@ -2824,6 +2807,7 @@ pub fn default_registry() -> CmdRegistry {
|
||||
Box::new(DeleteFormulaAtCursor)
|
||||
});
|
||||
r.register_nullary(|| Box::new(AddRecordRow));
|
||||
r.register_nullary(|| Box::new(OpenRecordRow));
|
||||
r.register_nullary(|| Box::new(TogglePruneEmpty));
|
||||
r.register_nullary(|| Box::new(ToggleRecordsMode));
|
||||
r.register_nullary(|| Box::new(CycleAxisAtCursor));
|
||||
@ -2877,35 +2861,36 @@ pub fn default_registry() -> CmdRegistry {
|
||||
&CommitCellEdit {
|
||||
key: CellKey::new(vec![]),
|
||||
value: String::new(),
|
||||
records_edit: None,
|
||||
},
|
||||
|args| {
|
||||
// parse: commit-cell-edit <value> <Cat/Item>...
|
||||
if args.len() < 2 {
|
||||
return Err("commit-cell-edit requires a value and coords".into());
|
||||
}
|
||||
Ok(Box::new(CommitCellEdit {
|
||||
key: parse_cell_key_from_args(&args[1..]),
|
||||
value: args[0].clone(),
|
||||
records_edit: None,
|
||||
}))
|
||||
},
|
||||
|_args, ctx| {
|
||||
let value = read_buffer(ctx, "edit");
|
||||
// In records mode, stage the edit instead of writing to the model
|
||||
if let Some(col_name) = &ctx.records_col {
|
||||
let record_idx = ctx.selected.0;
|
||||
return Ok(Box::new(CommitCellEdit {
|
||||
key: CellKey::new(vec![]), // ignored in records mode
|
||||
value,
|
||||
records_edit: Some((record_idx, col_name.clone())),
|
||||
}));
|
||||
}
|
||||
let key = ctx.cell_key.clone().ok_or("no cell at cursor")?;
|
||||
Ok(Box::new(CommitCellEdit {
|
||||
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
|
||||
Ok(Box::new(CommitCellEdit { key, value }))
|
||||
},
|
||||
);
|
||||
r.register(
|
||||
&CommitAndAdvanceRight {
|
||||
key: CellKey::new(vec![]),
|
||||
value: String::new(),
|
||||
cursor: CursorState::default(),
|
||||
},
|
||||
|_| Err("commit-and-advance-right requires context".into()),
|
||||
|_args, ctx| {
|
||||
let value = read_buffer(ctx, "edit");
|
||||
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
|
||||
Ok(Box::new(CommitAndAdvanceRight {
|
||||
key,
|
||||
value,
|
||||
records_edit: None,
|
||||
cursor: CursorState::from_ctx(ctx),
|
||||
}))
|
||||
},
|
||||
);
|
||||
@ -2951,12 +2936,16 @@ mod tests {
|
||||
static EMPTY_EXPANDED: std::sync::LazyLock<std::collections::HashSet<String>> =
|
||||
std::sync::LazyLock::new(std::collections::HashSet::new);
|
||||
|
||||
fn make_ctx(model: &Model) -> CmdContext<'_> {
|
||||
fn make_layout(model: &Model) -> GridLayout {
|
||||
GridLayout::new(model, model.active_view())
|
||||
}
|
||||
|
||||
fn make_ctx<'a>(model: &'a Model, layout: &'a GridLayout) -> CmdContext<'a> {
|
||||
let view = model.active_view();
|
||||
let layout = GridLayout::new(model, view);
|
||||
let (sr, sc) = view.selected;
|
||||
CmdContext {
|
||||
model,
|
||||
layout,
|
||||
mode: &AppMode::Normal,
|
||||
selected: view.selected,
|
||||
row_offset: view.row_offset,
|
||||
@ -2973,14 +2962,15 @@ mod tests {
|
||||
view_panel_cursor: 0,
|
||||
tile_cat_idx: 0,
|
||||
buffers: &EMPTY_BUFFERS,
|
||||
none_cats: layout.none_cats.clone(),
|
||||
view_back_stack: Vec::new(),
|
||||
view_forward_stack: Vec::new(),
|
||||
records_col: None,
|
||||
records_value: None,
|
||||
cell_key: layout.cell_key(sr, sc),
|
||||
row_count: layout.row_count(),
|
||||
col_count: layout.col_count(),
|
||||
view_back_stack: &[],
|
||||
view_forward_stack: &[],
|
||||
display_value: {
|
||||
let key = layout.cell_key(sr, sc);
|
||||
key.as_ref()
|
||||
.and_then(|k| model.get_cell(k).cloned())
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_default()
|
||||
},
|
||||
visible_rows: 20,
|
||||
visible_cols: 8,
|
||||
expanded_cats: &EMPTY_EXPANDED,
|
||||
@ -3002,7 +2992,8 @@ mod tests {
|
||||
#[test]
|
||||
fn move_selection_down_produces_set_selected() {
|
||||
let m = two_cat_model();
|
||||
let ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let ctx = make_ctx(&m, &layout);
|
||||
let cmd = MoveSelection {
|
||||
dr: 1,
|
||||
dc: 0,
|
||||
@ -3016,7 +3007,8 @@ mod tests {
|
||||
#[test]
|
||||
fn move_selection_clamps_to_bounds() {
|
||||
let m = two_cat_model();
|
||||
let ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let ctx = make_ctx(&m, &layout);
|
||||
// Try to move way past the end
|
||||
let cmd = MoveSelection {
|
||||
dr: 100,
|
||||
@ -3032,7 +3024,8 @@ mod tests {
|
||||
let m = two_cat_model();
|
||||
let mut bufs = HashMap::new();
|
||||
bufs.insert("command".to_string(), "q".to_string());
|
||||
let mut ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let mut ctx = make_ctx(&m, &layout);
|
||||
ctx.dirty = true;
|
||||
ctx.buffers = &bufs;
|
||||
let cmd = ExecuteCommand;
|
||||
@ -3046,7 +3039,8 @@ mod tests {
|
||||
let m = two_cat_model();
|
||||
let mut bufs = HashMap::new();
|
||||
bufs.insert("command".to_string(), "q".to_string());
|
||||
let mut ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let mut ctx = make_ctx(&m, &layout);
|
||||
ctx.buffers = &bufs;
|
||||
let cmd = ExecuteCommand;
|
||||
let effects = cmd.execute(&ctx);
|
||||
@ -3065,9 +3059,10 @@ mod tests {
|
||||
("Month".to_string(), "Jan".to_string()),
|
||||
]);
|
||||
m.set_cell(key, CellValue::Number(42.0));
|
||||
let ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let ctx = make_ctx(&m, &layout);
|
||||
let cmd = ClearCellCommand {
|
||||
key: ctx.cell_key.clone().unwrap(),
|
||||
key: ctx.cell_key().clone().unwrap(),
|
||||
};
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 2); // ClearCell + MarkDirty
|
||||
@ -3081,9 +3076,10 @@ mod tests {
|
||||
("Month".to_string(), "Jan".to_string()),
|
||||
]);
|
||||
m.set_cell(key, CellValue::Number(99.0));
|
||||
let ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let ctx = make_ctx(&m, &layout);
|
||||
let cmd = YankCell {
|
||||
key: ctx.cell_key.clone().unwrap(),
|
||||
key: ctx.cell_key().clone().unwrap(),
|
||||
};
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 2); // SetYanked + SetStatus
|
||||
@ -3092,7 +3088,8 @@ mod tests {
|
||||
#[test]
|
||||
fn toggle_panel_open_and_focus() {
|
||||
let m = two_cat_model();
|
||||
let ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let ctx = make_ctx(&m, &layout);
|
||||
let cmd = TogglePanelAndFocus {
|
||||
panel: effect::Panel::Formula,
|
||||
open: true,
|
||||
@ -3110,7 +3107,8 @@ mod tests {
|
||||
#[test]
|
||||
fn toggle_panel_close_and_unfocus() {
|
||||
let m = two_cat_model();
|
||||
let ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let ctx = make_ctx(&m, &layout);
|
||||
let cmd = TogglePanelAndFocus {
|
||||
panel: effect::Panel::Formula,
|
||||
open: false,
|
||||
@ -3123,7 +3121,8 @@ mod tests {
|
||||
#[test]
|
||||
fn enter_advance_moves_down() {
|
||||
let m = two_cat_model();
|
||||
let ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let ctx = make_ctx(&m, &layout);
|
||||
let cmd = EnterAdvance {
|
||||
cursor: CursorState::from_ctx(&ctx),
|
||||
};
|
||||
@ -3139,7 +3138,8 @@ mod tests {
|
||||
#[test]
|
||||
fn search_navigate_with_empty_query_returns_nothing() {
|
||||
let m = two_cat_model();
|
||||
let ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let ctx = make_ctx(&m, &layout);
|
||||
let cmd = SearchNavigate(true);
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert!(effects.is_empty());
|
||||
@ -3148,7 +3148,8 @@ mod tests {
|
||||
#[test]
|
||||
fn enter_edit_mode_produces_editing_mode() {
|
||||
let m = two_cat_model();
|
||||
let ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let ctx = make_ctx(&m, &layout);
|
||||
let cmd = EnterEditMode {
|
||||
initial_value: String::new(),
|
||||
};
|
||||
@ -3161,7 +3162,8 @@ mod tests {
|
||||
#[test]
|
||||
fn enter_tile_select_with_categories() {
|
||||
let m = two_cat_model();
|
||||
let ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let ctx = make_ctx(&m, &layout);
|
||||
let cmd = EnterTileSelect;
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 2); // SetTileCatIdx + ChangeMode
|
||||
@ -3177,7 +3179,8 @@ mod tests {
|
||||
// Models always have virtual categories (_Index, _Dim), so tile
|
||||
// select always has something to operate on.
|
||||
let m = Model::new("Empty");
|
||||
let ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let ctx = make_ctx(&m, &layout);
|
||||
let cmd = EnterTileSelect;
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 2); // SetTileCatIdx + ChangeMode
|
||||
@ -3186,7 +3189,8 @@ mod tests {
|
||||
#[test]
|
||||
fn toggle_group_under_cursor_returns_empty_without_groups() {
|
||||
let m = two_cat_model();
|
||||
let ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let ctx = make_ctx(&m, &layout);
|
||||
let cmd = ToggleGroupUnderCursor;
|
||||
let effects = cmd.execute(&ctx);
|
||||
// No groups defined, so nothing to toggle
|
||||
@ -3196,7 +3200,8 @@ mod tests {
|
||||
#[test]
|
||||
fn search_or_category_add_without_query_opens_category_add() {
|
||||
let m = two_cat_model();
|
||||
let ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let ctx = make_ctx(&m, &layout);
|
||||
let cmd = SearchOrCategoryAdd;
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 2); // SetPanelOpen + ChangeMode
|
||||
@ -3210,7 +3215,8 @@ mod tests {
|
||||
#[test]
|
||||
fn cycle_panel_focus_with_no_panels_open() {
|
||||
let m = two_cat_model();
|
||||
let ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let ctx = make_ctx(&m, &layout);
|
||||
let cmd = CyclePanelFocus {
|
||||
formula_open: false,
|
||||
category_open: false,
|
||||
@ -3223,7 +3229,8 @@ mod tests {
|
||||
#[test]
|
||||
fn cycle_panel_focus_with_formula_panel_open() {
|
||||
let m = two_cat_model();
|
||||
let mut ctx = make_ctx(&m);
|
||||
let layout = make_layout(&m);
|
||||
let mut ctx = make_ctx(&m, &layout);
|
||||
ctx.formula_panel_open = true;
|
||||
let cmd = CyclePanelFocus {
|
||||
formula_open: true,
|
||||
|
||||
Reference in New Issue
Block a user