bdf9bff472
PR #162 (improvise-xg1z) made render.rs paint edit_seed when Editing arrived without its edit buffer, but commit and append-char still read the absent buffer as the empty string. So the overlay showed the value, Enter composed ClearCell, and a typed character replaced the text instead of extending it: two derivations of what is in the editor. The fold now seeds the missing buffer (ClientState::settle_editor, next to settle_mode), and render.rs reads the buffer through read_buffer, as commit does. edit_seed goes back to pub(crate) and no renderer calls it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
557 lines
20 KiB
Rust
557 lines
20 KiB
Rust
//! Portable session state ([`ViewState`]), the wire vocabulary ([`Command`])
|
|
//! and the framing ([`frame`]). See `context/code/protocol.md`.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
|
|
pub mod basis;
|
|
pub mod client;
|
|
pub mod frame;
|
|
pub mod help;
|
|
pub mod key;
|
|
pub mod keymap;
|
|
mod reduce;
|
|
pub mod render;
|
|
pub mod resolve;
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
pub mod variants;
|
|
pub mod viewmodel;
|
|
#[cfg(test)]
|
|
mod vocabulary_is_composed;
|
|
|
|
pub use basis::{Basis, BasisCache, Rejection};
|
|
pub use client::{ClientState, Sent};
|
|
pub use frame::{ClientFrame, ServerFrame};
|
|
pub use help::{HelpLine, Hint, hint_line, hints, mode_label};
|
|
pub use key::{KeyCode, KeyEvent, KeyModifiers};
|
|
pub use keymap::{Binding, KeyPattern, Keymap, Keymaps, ModeKey};
|
|
pub use reduce::{Command, HelpPage, ModelCommand, records_named, reduce_view};
|
|
pub use render::{
|
|
AxisFrame, CategoryRow, CategoryTreeCache, CategoryTreeUpdate, CellDisplay, CellId, CreateKind,
|
|
DocumentCache, FormulaCache, FrameEntry, FrameGroup, GridCache, GridExtent, GridUpdate,
|
|
MAX_ROW_HEADER_W, MIN_ROW_HEADER_W, Projection, RecordCell, Snapshot, TileBarCache, TileEntry,
|
|
ViewListCache, ViewSummary, WidgetCache, WizardCache, WizardDateField, WizardFieldKind,
|
|
WizardProposal, WizardStepKind, header_level_widths,
|
|
};
|
|
pub use resolve::{
|
|
Click, KeyOutcome, Request, click, read_buffer, resolve, typed, typed_cell_command,
|
|
};
|
|
pub use viewmodel::{CatTreeEntry, flatten_cat_tree, view_axis_summary};
|
|
// `improvise-core`'s, because the index that resolves one lives in
|
|
// `DataStore`. Re-exported so a client depends on the protocol alone.
|
|
pub use improvise_core::model::record::{AggregateHash, RecordHash};
|
|
pub use improvise_core::view::layout::PageAxis;
|
|
|
|
// On the wire in `ModelCommand::SetCell`, so a client needs to name it
|
|
// without depending on `improvise-core` directly.
|
|
pub use improvise_core::model::cell::CellValue;
|
|
|
|
/// Number of pages in the Help screen. Lives here with the `help_page` state
|
|
/// it bounds, so help-page effects stay terminal-independent.
|
|
pub const HELP_PAGE_COUNT: usize = 5;
|
|
|
|
/// Where one client is looking within one view. Per-client (improvise-8uz);
|
|
/// `View` no longer carries it — `context/code/protocol.md` §1.
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ViewCursor {
|
|
/// Selected cell, as (row index, column index).
|
|
pub selected: (usize, usize),
|
|
pub row_offset: usize,
|
|
pub col_offset: usize,
|
|
}
|
|
|
|
/// A saved view+mode pair for the navigation stack.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct ViewFrame {
|
|
pub view_name: String,
|
|
pub mode: AppMode,
|
|
}
|
|
|
|
/// Accent color for a minibuffer prompt. Semantic palette only; the terminal
|
|
/// mapping lives in `draw.rs` (gotcha 26).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum MinibufColor {
|
|
Green,
|
|
Cyan,
|
|
Yellow,
|
|
}
|
|
|
|
/// Display configuration for the bottom-bar minibuffer.
|
|
/// Carried structurally by text-entry `AppMode` variants.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct MinibufferConfig {
|
|
pub buffer_key: String,
|
|
pub prompt: String,
|
|
pub color: MinibufColor,
|
|
}
|
|
|
|
/// One of the three side panels. Paired with [`AppMode::panel`], which names
|
|
/// the panel a mode is focused in.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum Panel {
|
|
Formula,
|
|
Category,
|
|
View,
|
|
}
|
|
|
|
impl Panel {
|
|
/// Every panel, in the order their toggle keys are bound.
|
|
pub const ALL: [Panel; 3] = [Panel::Formula, Panel::Category, Panel::View];
|
|
|
|
/// The name commands use for this panel — the spelling `parse_panel`
|
|
/// accepts, so panel command args are generated rather than spelled out.
|
|
pub fn name(self) -> &'static str {
|
|
match self {
|
|
Panel::Formula => "formula",
|
|
Panel::Category => "category",
|
|
Panel::View => "view",
|
|
}
|
|
}
|
|
|
|
/// The key that toggles focus to this panel, bound in Normal mode and in
|
|
/// every panel's own keymap.
|
|
pub fn key(self) -> char {
|
|
match self {
|
|
Panel::Formula => 'F',
|
|
Panel::Category => 'C',
|
|
Panel::View => 'V',
|
|
}
|
|
}
|
|
|
|
/// The mode that focuses this panel. Inverse of [`AppMode::panel`] for the
|
|
/// three top-level panel modes only — `context/code/protocol.md` §1.
|
|
pub fn mode(self) -> AppMode {
|
|
match self {
|
|
Panel::Formula => AppMode::FormulaPanel,
|
|
Panel::Category => AppMode::CategoryPanel,
|
|
Panel::View => AppMode::ViewPanel,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub enum AppMode {
|
|
Normal,
|
|
Editing {
|
|
minibuf: MinibufferConfig,
|
|
},
|
|
FormulaEdit {
|
|
minibuf: MinibufferConfig,
|
|
},
|
|
FormulaPanel,
|
|
CategoryPanel,
|
|
/// Quick-add a new category: Enter adds and stays open, Esc closes.
|
|
CategoryAdd {
|
|
minibuf: MinibufferConfig,
|
|
},
|
|
/// Quick-add items to `category`: Enter adds and stays open, Esc closes.
|
|
ItemAdd {
|
|
category: String,
|
|
minibuf: MinibufferConfig,
|
|
},
|
|
ViewPanel,
|
|
TileSelect,
|
|
ImportWizard,
|
|
ExportPrompt {
|
|
minibuf: MinibufferConfig,
|
|
},
|
|
/// Ask for a path to write the *model* to. The sibling of `ExportPrompt`,
|
|
/// which writes CSV; this one commits to `SaveAs` (improvise-lae).
|
|
SavePrompt {
|
|
minibuf: MinibufferConfig,
|
|
},
|
|
/// Vim-style `:` command line
|
|
CommandMode {
|
|
minibuf: MinibufferConfig,
|
|
},
|
|
Help,
|
|
Quit,
|
|
/// Records-mode normal: inherits from Normal with records-specific bindings.
|
|
RecordsNormal,
|
|
/// Records-mode editing: inherits from Editing with boundary-aware Tab/Enter.
|
|
RecordsEditing {
|
|
minibuf: MinibufferConfig,
|
|
},
|
|
}
|
|
|
|
/// `Normal` and `RecordsNormal` are one mode over two layouts, so the mode an
|
|
/// end holds must answer for the grid it holds — `context/code/protocol.md`.
|
|
pub fn settled_mode(mode: &AppMode, records: bool) -> AppMode {
|
|
// The pair and nothing else: `Editing`/`RecordsEditing` are NOT settled,
|
|
// which is why a records decision asks the grid and never the mode.
|
|
match (mode, records) {
|
|
(AppMode::Normal | AppMode::RecordsNormal, true) => AppMode::RecordsNormal,
|
|
(AppMode::Normal | AppMode::RecordsNormal, false) => AppMode::Normal,
|
|
(other, _) => other.clone(),
|
|
}
|
|
}
|
|
|
|
impl AppMode {
|
|
/// The mode a keymap's `enter-mode <name>` argument names. **Not every
|
|
/// variant is nameable** — `context/code/protocol.md` §1 says which and why.
|
|
pub fn from_name(s: &str) -> Option<Self> {
|
|
match s {
|
|
"normal" => Some(Self::Normal),
|
|
"help" => Some(Self::Help),
|
|
"formula-panel" => Some(Self::FormulaPanel),
|
|
"category-panel" => Some(Self::CategoryPanel),
|
|
"view-panel" => Some(Self::ViewPanel),
|
|
"tile-select" => Some(Self::TileSelect),
|
|
"command" => Some(Self::command_mode()),
|
|
"category-add" => Some(Self::category_add()),
|
|
"editing" => Some(Self::editing()),
|
|
"records-normal" => Some(Self::RecordsNormal),
|
|
"records-editing" => Some(Self::records_editing()),
|
|
"formula-edit" => Some(Self::formula_edit()),
|
|
"export-prompt" => Some(Self::export_prompt()),
|
|
"save-prompt" => Some(Self::save_prompt()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The side panel this mode is focused in. A panel's sub-modes count as
|
|
/// that panel, so its styling survives the user typing into it.
|
|
pub fn panel(&self) -> Option<Panel> {
|
|
match self {
|
|
Self::FormulaPanel | Self::FormulaEdit { .. } => Some(Panel::Formula),
|
|
Self::CategoryPanel | Self::CategoryAdd { .. } | Self::ItemAdd { .. } => {
|
|
Some(Panel::Category)
|
|
}
|
|
Self::ViewPanel => Some(Panel::View),
|
|
Self::Normal
|
|
| Self::Editing { .. }
|
|
| Self::TileSelect
|
|
| Self::ImportWizard
|
|
| Self::ExportPrompt { .. }
|
|
| Self::SavePrompt { .. }
|
|
| Self::CommandMode { .. }
|
|
| Self::Help
|
|
| Self::Quit
|
|
| Self::RecordsNormal
|
|
| Self::RecordsEditing { .. } => None,
|
|
}
|
|
}
|
|
|
|
/// Extract the minibuffer config from text-entry modes, if present.
|
|
pub fn minibuffer(&self) -> Option<&MinibufferConfig> {
|
|
match self {
|
|
Self::Editing { minibuf, .. }
|
|
| Self::RecordsEditing { minibuf, .. }
|
|
| Self::FormulaEdit { minibuf, .. }
|
|
| Self::CommandMode { minibuf, .. }
|
|
| Self::CategoryAdd { minibuf, .. }
|
|
| Self::ItemAdd { minibuf, .. }
|
|
| Self::ExportPrompt { minibuf, .. }
|
|
| Self::SavePrompt { minibuf, .. } => Some(minibuf),
|
|
Self::Normal
|
|
| Self::FormulaPanel
|
|
| Self::CategoryPanel
|
|
| Self::ViewPanel
|
|
| Self::TileSelect
|
|
| Self::ImportWizard
|
|
| Self::Help
|
|
| Self::Quit
|
|
| Self::RecordsNormal => None,
|
|
}
|
|
}
|
|
|
|
/// Every variant, one sample each, walked rather than listed. Totality is
|
|
/// **tested, not structural** — `context/code/protocol.md` §1, improvise-5ubs.
|
|
pub fn all() -> Vec<Self> {
|
|
let mut out = vec![Self::Normal];
|
|
let mut next = Self::Normal.after();
|
|
while !out.contains(&next) {
|
|
out.push(next.clone());
|
|
next = next.after();
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The modes that take typed text: the ones carrying a `MinibufferConfig`,
|
|
/// derived from [`AppMode::minibuffer`] rather than listed (improvise-g9jb).
|
|
pub fn text_entry() -> Vec<Self> {
|
|
Self::all()
|
|
.into_iter()
|
|
.filter(|mode| mode.minibuffer().is_some())
|
|
.collect()
|
|
}
|
|
|
|
/// The next variant in the cycle [`AppMode::all`] walks. A new variant must
|
|
/// answer here; that it is also POINTED AT is what §1's test checks.
|
|
fn after(&self) -> Self {
|
|
match self {
|
|
Self::Normal => Self::editing(),
|
|
Self::Editing { .. } => Self::records_editing(),
|
|
Self::RecordsEditing { .. } => Self::RecordsNormal,
|
|
Self::RecordsNormal => Self::formula_edit(),
|
|
Self::FormulaEdit { .. } => Self::FormulaPanel,
|
|
Self::FormulaPanel => Self::CategoryPanel,
|
|
Self::CategoryPanel => Self::category_add(),
|
|
Self::CategoryAdd { .. } => Self::item_add("Region".into()),
|
|
Self::ItemAdd { .. } => Self::ViewPanel,
|
|
Self::ViewPanel => Self::TileSelect,
|
|
Self::TileSelect => Self::ImportWizard,
|
|
Self::ImportWizard => Self::export_prompt(),
|
|
Self::ExportPrompt { .. } => Self::save_prompt(),
|
|
Self::SavePrompt { .. } => Self::command_mode(),
|
|
Self::CommandMode { .. } => Self::Help,
|
|
Self::Help => Self::Quit,
|
|
Self::Quit => Self::Normal,
|
|
}
|
|
}
|
|
|
|
/// True for any cell-editing mode (normal or records).
|
|
pub fn is_editing(&self) -> bool {
|
|
matches!(self, Self::Editing { .. } | Self::RecordsEditing { .. })
|
|
}
|
|
|
|
pub fn editing() -> Self {
|
|
Self::Editing {
|
|
minibuf: MinibufferConfig {
|
|
buffer_key: "edit".into(),
|
|
prompt: "edit: ".into(),
|
|
color: MinibufColor::Green,
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn records_editing() -> Self {
|
|
Self::RecordsEditing {
|
|
minibuf: MinibufferConfig {
|
|
buffer_key: "edit".into(),
|
|
prompt: "edit: ".into(),
|
|
color: MinibufColor::Green,
|
|
},
|
|
}
|
|
}
|
|
|
|
/// True when this mode is a records-mode variant.
|
|
pub fn is_records(&self) -> bool {
|
|
matches!(self, Self::RecordsNormal | Self::RecordsEditing { .. })
|
|
}
|
|
|
|
pub fn formula_edit() -> Self {
|
|
Self::FormulaEdit {
|
|
minibuf: MinibufferConfig {
|
|
buffer_key: "formula".into(),
|
|
prompt: "formula: ".into(),
|
|
color: MinibufColor::Cyan,
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn command_mode() -> Self {
|
|
Self::CommandMode {
|
|
minibuf: MinibufferConfig {
|
|
buffer_key: "command".into(),
|
|
prompt: ":".into(),
|
|
color: MinibufColor::Yellow,
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn category_add() -> Self {
|
|
Self::CategoryAdd {
|
|
minibuf: MinibufferConfig {
|
|
buffer_key: "category".into(),
|
|
prompt: "new category: ".into(),
|
|
color: MinibufColor::Yellow,
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn item_add(category: String) -> Self {
|
|
let prompt = format!("add item to {category}: ");
|
|
Self::ItemAdd {
|
|
category,
|
|
minibuf: MinibufferConfig {
|
|
buffer_key: "item".into(),
|
|
prompt,
|
|
color: MinibufColor::Green,
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn export_prompt() -> Self {
|
|
Self::ExportPrompt {
|
|
minibuf: MinibufferConfig {
|
|
buffer_key: "export".into(),
|
|
prompt: "export path: ".into(),
|
|
color: MinibufColor::Yellow,
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn save_prompt() -> Self {
|
|
Self::SavePrompt {
|
|
minibuf: MinibufferConfig {
|
|
buffer_key: "save".into(),
|
|
prompt: "save as: ".into(),
|
|
color: MinibufColor::Yellow,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The **portable** half of the session — the one thing serialized into the
|
|
/// Snapshot. Membership is gated: `context/code/protocol.md` §1.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct ViewState {
|
|
pub mode: AppMode,
|
|
pub status_msg: String,
|
|
/// Which view this client is looking at. Duplicated from
|
|
/// `Workbook::active_view` on purpose (improvise-cqi); see gotcha 29.
|
|
pub active_view: String,
|
|
/// Cursor and scroll position per view name, so switching views restores
|
|
/// where you were in each. Absent key means the origin (improvise-8uz).
|
|
pub cursors: HashMap<String, ViewCursor>,
|
|
pub search_query: String,
|
|
pub search_mode: bool,
|
|
pub formula_panel_open: bool,
|
|
pub category_panel_open: bool,
|
|
pub view_panel_open: bool,
|
|
pub cat_panel_cursor: usize,
|
|
pub view_panel_cursor: usize,
|
|
pub formula_cursor: usize,
|
|
/// Yanked cell value for `p` paste
|
|
pub yanked: Option<CellValue>,
|
|
/// Tile select cursor (which category index is highlighted)
|
|
pub tile_cat_idx: usize,
|
|
/// View navigation history: views visited before the current one.
|
|
/// Pushed on SwitchView, popped by `<` (back).
|
|
pub view_back_stack: Vec<ViewFrame>,
|
|
/// Views that were "back-ed" from, available for forward navigation (`>`).
|
|
pub view_forward_stack: Vec<ViewFrame>,
|
|
/// Current page index in the Help screen (0-based).
|
|
pub help_page: usize,
|
|
/// Categories expanded in the category panel tree view.
|
|
pub expanded_cats: std::collections::HashSet<String>,
|
|
/// Named text buffers for text-entry modes
|
|
pub buffers: HashMap<String, String>,
|
|
/// The prefix key currently armed (g→gg, y→yy, Z→ZZ). The key that opened
|
|
/// it, not the sub-keymap it names — gotcha 13, improvise-jc8.
|
|
pub transient_prefix: Option<(KeyCode, KeyModifiers)>,
|
|
}
|
|
|
|
impl Default for ViewState {
|
|
fn default() -> Self {
|
|
Self {
|
|
mode: AppMode::Normal,
|
|
status_msg: String::new(),
|
|
active_view: "Default".to_string(),
|
|
cursors: HashMap::new(),
|
|
search_query: String::new(),
|
|
search_mode: false,
|
|
formula_panel_open: false,
|
|
category_panel_open: true,
|
|
view_panel_open: false,
|
|
cat_panel_cursor: 0,
|
|
view_panel_cursor: 0,
|
|
formula_cursor: 0,
|
|
yanked: None,
|
|
tile_cat_idx: 0,
|
|
view_back_stack: Vec::new(),
|
|
view_forward_stack: Vec::new(),
|
|
help_page: 0,
|
|
expanded_cats: std::collections::HashSet::new(),
|
|
buffers: HashMap::new(),
|
|
transient_prefix: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ViewState {
|
|
/// This client's cursor in the active view. Absent means the origin, so
|
|
/// a view visited for the first time opens at (0, 0).
|
|
pub fn cursor(&self) -> ViewCursor {
|
|
self.cursors
|
|
.get(&self.active_view)
|
|
.copied()
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Drop every history frame naming `view`. A frame is somewhere you can go
|
|
/// back to, so a deleted view must leave — `context/code/protocol.md` §2.
|
|
pub fn forget_view(&mut self, view: &str) {
|
|
self.view_back_stack.retain(|f| f.view_name != view);
|
|
self.view_forward_stack.retain(|f| f.view_name != view);
|
|
}
|
|
|
|
/// Mutable cursor for the active view, created at the origin if absent.
|
|
pub fn cursor_mut(&mut self) -> &mut ViewCursor {
|
|
self.cursors.entry(self.active_view.clone()).or_default()
|
|
}
|
|
|
|
/// The **one** place a [`Panel`] becomes a field (improvise-56ek). The TUI's
|
|
/// `SetPanelOpen` effect and `reduce_view`'s arm both come here.
|
|
pub fn panel_open_mut(&mut self, panel: Panel) -> &mut bool {
|
|
match panel {
|
|
Panel::Formula => &mut self.formula_panel_open,
|
|
Panel::Category => &mut self.category_panel_open,
|
|
Panel::View => &mut self.view_panel_open,
|
|
}
|
|
}
|
|
|
|
/// Read side of [`ViewState::panel_open_mut`]. A caller that matched on the
|
|
/// panel itself would be the second derivation this pair exists to remove.
|
|
pub fn panel_open(&self, panel: Panel) -> bool {
|
|
match panel {
|
|
Panel::Formula => self.formula_panel_open,
|
|
Panel::Category => self.category_panel_open,
|
|
Panel::View => self.view_panel_open,
|
|
}
|
|
}
|
|
|
|
/// The same single home for the panel cursors, which carried the identical
|
|
/// three-way match in the identical two crates.
|
|
pub fn panel_cursor_mut(&mut self, panel: Panel) -> &mut usize {
|
|
match panel {
|
|
Panel::Formula => &mut self.formula_cursor,
|
|
Panel::Category => &mut self.cat_panel_cursor,
|
|
Panel::View => &mut self.view_panel_cursor,
|
|
}
|
|
}
|
|
|
|
/// Read side of [`ViewState::panel_cursor_mut`].
|
|
pub fn panel_cursor(&self, panel: Panel) -> usize {
|
|
match panel {
|
|
Panel::Formula => self.formula_cursor,
|
|
Panel::Category => self.cat_panel_cursor,
|
|
Panel::View => self.view_panel_cursor,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod boundary_tests {
|
|
/// improvise-cqi AC 2: the part of the boundary the compiler can hold.
|
|
/// See `context/test-evidence/protocol.md`.
|
|
#[test]
|
|
fn the_wire_types_need_nothing_but_core_and_serde() {
|
|
fn portable<T: serde::Serialize + serde::de::DeserializeOwned>() {}
|
|
portable::<super::ViewState>();
|
|
portable::<super::AppMode>();
|
|
portable::<super::ViewCursor>();
|
|
portable::<super::ViewFrame>();
|
|
portable::<super::Panel>();
|
|
portable::<super::Command>();
|
|
portable::<super::Projection>();
|
|
portable::<super::CellDisplay>();
|
|
portable::<super::GridCache>();
|
|
portable::<super::CategoryTreeCache>();
|
|
portable::<super::FormulaCache>();
|
|
portable::<super::ViewListCache>();
|
|
portable::<super::TileBarCache>();
|
|
portable::<super::Snapshot>();
|
|
portable::<super::KeyCode>();
|
|
portable::<super::KeyModifiers>();
|
|
}
|
|
|
|
/// improvise-5ubs: a cyclic `after()` can silently omit a variant nothing
|
|
/// points at. Evidence: `context/test-evidence/protocol.md`.
|
|
#[test]
|
|
fn the_app_mode_cycle_reaches_every_variant_serde_declares() {
|
|
crate::variants::cycle_is_total(&super::AppMode::all(), "AppMode");
|
|
}
|
|
}
|