refactor: migrate monolithic crate to Cargo Workspace with Clean Architecture
Transform the single binary crate into a 9-crate workspace monorepo: - Root Cargo.toml as [workspace] manager with resolver = "2" - zesdex-entities: Domain entity types (session, settings, store, message, etc.) - zesdex-utils: Pure utility functions (error, logger, pagination, slug, clipboard) - zesdex-dto: Data Transfer Objects for LLM provider API communication - zesdex-ipc: Unix-socket IPC layer (client/server/framing/protocol) - zesdex-iam: Identity & Access Management (Clean Architecture: domain/application/infrastructure) - zesdex-cms: Content Management (Clean Architecture: domain/application/infrastructure) - zesdex-middleware: HTTP middleware (Auth, CORS, Rate Limiting) - zesdex-libs: Composition root (AppContext, DB init, JWT, Argon2) - zesdex-backend: Main binary entry point + seed/migrate binaries - DevOps: Dockerfile, docker-compose, Nix (flake/shell/default), CI/CD updates - Remove dead root src/ and src-misc/ directories All crate re-exports maintain backward compatibility with original crate::model::*, crate::dto::*, crate::ipc::* module paths. Feature crates enforce strict layer separation: domain -> application -> infrastructure with generic trait-based dependency injection.
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
//! Bash mode: handles submitting a shell command from the bash input panel.
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
/// Launch a background bash job for the submitted command.
|
||||
///
|
||||
/// Flow: ignore empty input → spawn the job (fire-and-forget, the job's
|
||||
/// output is polled elsewhere via `bgbash::control`) → mark state dirty
|
||||
/// so the TUI re-renders.
|
||||
///
|
||||
/// Why: the returned `BashJob` handle is intentionally dropped — this
|
||||
/// function only needs to kick the job off; the job registers itself in
|
||||
/// the shared jobs map for later polling.
|
||||
pub fn handle_bash_submit(state: &mut AppStateRest, command: String) {
|
||||
if !command.is_empty() {
|
||||
let _ = crate::app::bgbash::job::spawn_bash_job(command);
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Editor mode: a minimal in-TUI line editor for viewing/modifying a file,
|
||||
//! with bounded undo history.
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::types::Overlay;
|
||||
|
||||
/// State for the built-in line editor overlay: buffer contents, cursor
|
||||
/// position, and a bounded undo stack.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditorState {
|
||||
pub path: String,
|
||||
pub content: Vec<String>,
|
||||
pub undo_stack: Vec<Vec<String>>,
|
||||
pub cursor_line: usize,
|
||||
pub cursor_col: usize,
|
||||
}
|
||||
|
||||
impl Default for EditorState {
|
||||
fn default() -> Self {
|
||||
EditorState {
|
||||
path: String::new(),
|
||||
content: vec![String::new()],
|
||||
undo_stack: Vec::new(),
|
||||
cursor_line: 0,
|
||||
cursor_col: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EditorState {
|
||||
/// Create a fresh editor state for `path`, seeded with existing content
|
||||
/// (or a single empty line for a new file).
|
||||
pub fn open(path: String, existing_content: Option<Vec<String>>) -> Self {
|
||||
let content = existing_content.unwrap_or_else(|| vec![String::new()]);
|
||||
EditorState {
|
||||
path,
|
||||
content,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a new empty line immediately after the cursor line.
|
||||
///
|
||||
/// Why: snapshots content to the undo stack first, matching every other
|
||||
/// mutating method here.
|
||||
pub fn insert_line_after(&mut self) {
|
||||
self.save_undo();
|
||||
let pos = (self.cursor_line + 1).min(self.content.len());
|
||||
self.content.insert(pos, String::new());
|
||||
}
|
||||
|
||||
/// Push a snapshot of the current content onto the undo stack, capped at 50 entries.
|
||||
///
|
||||
/// Why: `remove(0)` on overflow bounds memory use at the cost of O(n)
|
||||
/// shifting; the cap (50) keeps that cost negligible in practice.
|
||||
fn save_undo(&mut self) {
|
||||
self.undo_stack.push(self.content.clone());
|
||||
if self.undo_stack.len() > 50 {
|
||||
self.undo_stack.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the cursor down one line, clamping the column to the new line's length.
|
||||
pub fn cursor_down(&mut self) {
|
||||
if self.cursor_line + 1 < self.content.len() {
|
||||
self.cursor_line += 1;
|
||||
}
|
||||
self.cursor_col = self.cursor_col.min(
|
||||
self.content
|
||||
.get(self.cursor_line)
|
||||
.map_or(0, std::string::String::len),
|
||||
);
|
||||
}
|
||||
|
||||
/// Insert a character at the cursor and advance the cursor past it.
|
||||
pub fn insert_char(&mut self, c: char) {
|
||||
self.save_undo();
|
||||
if let Some(line) = self.content.get_mut(self.cursor_line) {
|
||||
line.insert(self.cursor_col, c);
|
||||
self.cursor_col += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the character before the cursor (backspace).
|
||||
///
|
||||
/// Flow: if not at column 0, remove the preceding char on this line →
|
||||
/// otherwise (start of line, not the first line) merge this line into
|
||||
/// the previous one, joining at the old line's end.
|
||||
pub fn delete_left(&mut self) {
|
||||
self.save_undo();
|
||||
if let Some(line) = self.content.get_mut(self.cursor_line) {
|
||||
if self.cursor_col > 0 {
|
||||
self.cursor_col -= 1;
|
||||
line.remove(self.cursor_col);
|
||||
} else if self.cursor_line > 0 {
|
||||
let prev_len = self.content[self.cursor_line - 1].len();
|
||||
let rest = self.content.remove(self.cursor_line);
|
||||
self.cursor_line -= 1;
|
||||
self.cursor_col = prev_len;
|
||||
self.content[self.cursor_line].push_str(&rest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Join all lines with `\n` into the full file contents, for saving.
|
||||
pub fn as_string(&self) -> String {
|
||||
self.content.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed a chunk of typed text into the active editor, translating newlines
|
||||
/// and tabs into editor operations.
|
||||
///
|
||||
/// Flow: no-op if no editor is open → for each char: `\n`/`\r` inserts a
|
||||
/// line and moves down, `\t` inserts two spaces, everything else inserts
|
||||
/// the char directly → mark state dirty.
|
||||
pub fn handle_editor_input(state: &mut AppStateRest, text: &str) {
|
||||
let editor = &mut state.misc.editor;
|
||||
let Some(ed) = editor.as_mut() else {
|
||||
return;
|
||||
};
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'\n' | '\r' => {
|
||||
ed.insert_line_after();
|
||||
ed.cursor_down();
|
||||
ed.cursor_col = 0;
|
||||
}
|
||||
'\t' => {
|
||||
ed.insert_char(' ');
|
||||
ed.insert_char(' ');
|
||||
}
|
||||
_ => {
|
||||
ed.insert_char(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
/// Close the editor overlay without saving, clearing editor state.
|
||||
pub fn handle_editor_dismiss(state: &mut AppStateRest) {
|
||||
state.misc.editor = None;
|
||||
state.misc.overlay = Overlay::None;
|
||||
state.dirty = true;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Effort mode: cycles the agent's reasoning effort level, which scales the
|
||||
//! LLM's temperature and `max_tokens` for subsequent turns.
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
pub const EFFORT_LEVELS: &[&str] = &["low", "medium", "high", "xhigh", "max"];
|
||||
|
||||
/// Multiplier applied to the user's configured `max_tokens`, and the temperature to use,
|
||||
/// for each entry in `EFFORT_LEVELS` (same index). Higher effort trades a larger token
|
||||
/// budget for lower temperature (more deterministic, more room to reason/act).
|
||||
const MAX_TOKENS_MULTIPLIER: &[f32] = &[0.5, 1.0, 1.5, 2.0, 3.0];
|
||||
const TEMPERATURE_OVERRIDE: &[f32] = &[0.9, 0.7, 0.5, 0.3, 0.1];
|
||||
|
||||
/// Maps an effort level index to the `(temperature, max_tokens)` pair that should be sent
|
||||
/// to the LLM, scaling the user's configured `max_tokens` by the level's multiplier.
|
||||
pub fn generation_params(level: usize, base_max_tokens: Option<u32>) -> (f32, Option<u32>) {
|
||||
let idx = level.min(EFFORT_LEVELS.len() - 1);
|
||||
let temperature = TEMPERATURE_OVERRIDE[idx];
|
||||
let max_tokens = base_max_tokens.map(|t| ((t as f32) * MAX_TOKENS_MULTIPLIER[idx]) as u32);
|
||||
(temperature, max_tokens.map(|t| t.max(256)))
|
||||
}
|
||||
|
||||
/// Return the current effort level index, clamped to a valid `EFFORT_LEVELS` slot.
|
||||
///
|
||||
/// Why: clamping guards against a stale/out-of-range value in loaded state
|
||||
/// (e.g. after `EFFORT_LEVELS` shrinks between versions).
|
||||
pub fn current_effort(state: &AppStateRest) -> usize {
|
||||
state.misc.effort_level.min(EFFORT_LEVELS.len() - 1)
|
||||
}
|
||||
|
||||
/// Return the current effort level's display name (e.g. "medium").
|
||||
pub fn current_effort_str(state: &AppStateRest) -> &'static str {
|
||||
let idx = current_effort(state);
|
||||
EFFORT_LEVELS[idx]
|
||||
}
|
||||
|
||||
/// Advance to the next effort level, wrapping around, and toast the new value.
|
||||
///
|
||||
/// Flow: compute `(current + 1) % len` → store it → push an info toast with
|
||||
/// the new level's label → mark state dirty.
|
||||
pub fn cycle_effort(state: &mut AppStateRest) {
|
||||
let current = current_effort(state);
|
||||
state.misc.effort_level = (current + 1) % EFFORT_LEVELS.len();
|
||||
let label = current_effort_str(state);
|
||||
state.push_toast(crate::app::state::types::Toast::new(
|
||||
crate::app::state::types::ToastKind::Info,
|
||||
format!("Effort: {label}"),
|
||||
));
|
||||
state.dirty = true;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Help mode: static help text and the action that opens/closes the help overlay.
|
||||
use crate::app::runtime::actions::Action;
|
||||
use crate::app::state::types::Overlay;
|
||||
|
||||
pub const HELP_TEXT: &str = "\
|
||||
Keybindings:
|
||||
Ctrl+C Quit
|
||||
Ctrl+D Close overlay
|
||||
Ctrl+H Help
|
||||
Ctrl+P Settings
|
||||
Ctrl+A Toggle yolo arm
|
||||
Ctrl+B Bash panel
|
||||
Ctrl+T Todo panel
|
||||
Ctrl+W Workflow panel
|
||||
Ctrl+K Key input
|
||||
Ctrl+L Learning dashboard
|
||||
Ctrl+U Usage dashboard
|
||||
Esc Close overlay
|
||||
Enter Submit / confirm
|
||||
|
||||
Slash commands:
|
||||
/help Show this help
|
||||
/quit Quit session
|
||||
/mode <name> Switch mode (chat, bash, workflow)
|
||||
/clear Clear transcript";
|
||||
|
||||
/// Route an incoming action while the help overlay is open.
|
||||
///
|
||||
/// Flow: `CloseOverlay` passes through unchanged; any other action is
|
||||
/// treated as "open help" (idempotent — re-opens the overlay it's already on).
|
||||
///
|
||||
/// Return: the `Action` to actually dispatch.
|
||||
pub fn handle_help_action(action: &Action) -> Action {
|
||||
match action {
|
||||
Action::CloseOverlay => Action::CloseOverlay,
|
||||
_ => Action::OpenOverlay(Overlay::Help),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Key input mode: raw text capture overlay used for one-off key/text prompts.
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
/// Replace the input buffer with the given text and mark state dirty.
|
||||
pub fn handle_key_text(state: &mut AppStateRest, text: String) {
|
||||
state.input.buffer = text;
|
||||
state.dirty = true;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
/// A unified representation of a lesson item for the interactive TUI overlay.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LearningItem {
|
||||
Pending {
|
||||
name: String,
|
||||
content: String,
|
||||
scope: String,
|
||||
confidence: String,
|
||||
},
|
||||
Stored {
|
||||
name: String,
|
||||
content: String,
|
||||
lifecycle: String,
|
||||
scope: String,
|
||||
description: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Dynamically read all pending and stored lessons.
|
||||
pub fn get_learning_items(state: &AppStateRest) -> Vec<LearningItem> {
|
||||
let mut items = Vec::new();
|
||||
|
||||
// 1. Load pending lessons from session directory
|
||||
let pending = if let Some(ref rt) = state.session_runtime {
|
||||
crate::app::review::load_pending_lessons(&rt.session_dir)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
for p in pending {
|
||||
let scope_str = match p.lesson.scope {
|
||||
crate::app::review::LessonScope::Project => "project",
|
||||
crate::app::review::LessonScope::Global => "global",
|
||||
}
|
||||
.to_string();
|
||||
|
||||
let conf_str = match p.lesson.confidence {
|
||||
crate::app::review::Confidence::Human => "human",
|
||||
crate::app::review::Confidence::Verified => "verified",
|
||||
crate::app::review::Confidence::Unverified => "unverified",
|
||||
crate::app::review::Confidence::Auto => "auto",
|
||||
}
|
||||
.to_string();
|
||||
|
||||
items.push(LearningItem::Pending {
|
||||
name: p.lesson.name,
|
||||
content: p.lesson.content,
|
||||
scope: scope_str,
|
||||
confidence: conf_str,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Load stored memory lessons from long-term memory directory
|
||||
let names = crate::model::memory::Memory::list(&state.memory_dir);
|
||||
for name in names {
|
||||
if let Ok(mem) = crate::model::memory::Memory::read(&state.memory_dir, &name) {
|
||||
if mem.kind == "lesson" {
|
||||
items.push(LearningItem::Stored {
|
||||
name: mem.name,
|
||||
content: mem.content,
|
||||
lifecycle: mem.lifecycle,
|
||||
scope: mem.scope.unwrap_or_else(|| "project".to_string()),
|
||||
description: mem.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! Loading mode: transient overlay shown while waiting on an async operation.
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
pub const LOADING_MESSAGES: &[&str] = &[
|
||||
"processing...",
|
||||
"thinking...",
|
||||
"working...",
|
||||
"almost done...",
|
||||
];
|
||||
|
||||
/// Mark state dirty to force a re-render (e.g. to advance the loading spinner/message).
|
||||
pub fn resolve_loading(state: &mut AppStateRest) {
|
||||
state.dirty = true;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//! MCP mode: overlay for connecting to a configured MCP server.
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
/// Placeholder entry point for connecting to an MCP server by name.
|
||||
///
|
||||
/// Why: not yet wired to `McpManager::connect_stdio` — currently just
|
||||
/// marks state dirty so the overlay re-renders.
|
||||
pub fn connect_mcp(state: &mut AppStateRest, server_name: &str) {
|
||||
let _ = server_name;
|
||||
state.dirty = true;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//! TUI mode definitions and per-mode input/action handlers, one submodule
|
||||
//! per overlay/mode (bash, editor, effort, mcp, quit confirm, rewind, etc.).
|
||||
pub mod bash;
|
||||
pub mod editor;
|
||||
pub mod effort;
|
||||
pub mod key_input;
|
||||
pub mod mcp;
|
||||
|
||||
pub mod learning;
|
||||
pub mod quit_confirm;
|
||||
pub mod rewind;
|
||||
pub mod settings;
|
||||
pub mod todo;
|
||||
@@ -0,0 +1,14 @@
|
||||
//! Quit-confirm mode: the "are you sure?" overlay shown before exiting.
|
||||
use crate::app::runtime::actions::Action;
|
||||
|
||||
/// Translate the user's yes/no answer on the quit-confirm overlay into an action.
|
||||
///
|
||||
/// Return: `Action::ForceQuit` if confirmed, otherwise `Action::CloseOverlay`
|
||||
/// to dismiss the prompt without quitting.
|
||||
pub fn handle_quit_confirm(yes: bool) -> Action {
|
||||
if yes {
|
||||
Action::ForceQuit
|
||||
} else {
|
||||
Action::CloseOverlay
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Rewind mode: restores a file to a pre-edit snapshot stored in the
|
||||
//! session's `SQLite` blob store.
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use sha2::Digest;
|
||||
|
||||
/// Returns the number of stored pre-edit blobs (snapshots) for this session.
|
||||
pub fn rewind_count(state: &AppStateRest) -> usize {
|
||||
let Ok(conn) = open_session_db(&state.session_dir) else {
|
||||
return 0;
|
||||
};
|
||||
crate::model::msglog::blobs::list_blob_keys(&conn, &state.session_id)
|
||||
.ok()
|
||||
.map_or(0, |keys| keys.len())
|
||||
}
|
||||
|
||||
/// Restores a file to its pre-edit state by retrieving the blob stored under index
|
||||
/// `index` (0 = oldest). Opens a fresh `SQLite` connection so this works outside
|
||||
/// of a running turn (e.g. from the Rewind overlay).
|
||||
pub fn rewind_to(state: &mut AppStateRest, index: usize) {
|
||||
let conn = match open_session_db(&state.session_dir) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
state.push_toast(crate::app::state::types::Toast::new(
|
||||
crate::app::state::types::ToastKind::Error,
|
||||
format!("Failed to open session DB: {e}"),
|
||||
));
|
||||
state.dirty = true;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let keys = match crate::model::msglog::blobs::list_blob_keys(&conn, &state.session_id) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
state.push_toast(crate::app::state::types::Toast::new(
|
||||
crate::app::state::types::ToastKind::Error,
|
||||
format!("Failed to list snapshots: {e}"),
|
||||
));
|
||||
state.dirty = true;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if keys.is_empty() || index >= keys.len() {
|
||||
state.push_toast(crate::app::state::types::Toast::new(
|
||||
crate::app::state::types::ToastKind::Warning,
|
||||
"No snapshot available at that index".to_string(),
|
||||
));
|
||||
state.dirty = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let blob_key = &keys[index];
|
||||
let bytes = match crate::model::msglog::blobs::retrieve_blob(&conn, &state.session_id, blob_key)
|
||||
{
|
||||
Ok(Some(b)) => b,
|
||||
Ok(None) => {
|
||||
state.push_toast(crate::app::state::types::Toast::new(
|
||||
crate::app::state::types::ToastKind::Error,
|
||||
"Snapshot data not found".to_string(),
|
||||
));
|
||||
state.dirty = true;
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
state.push_toast(crate::app::state::types::Toast::new(
|
||||
crate::app::state::types::ToastKind::Error,
|
||||
format!("Failed to retrieve snapshot: {e}"),
|
||||
));
|
||||
state.dirty = true;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Look up the path from the edit log — the blob key is the tool_call_id.
|
||||
// The edit log doesn't store the tool_call_id directly, so fall back to the
|
||||
// path from the most recent write/edit entry.
|
||||
let restore_path =
|
||||
find_edit_path(state, blob_key).unwrap_or_else(|| state.session_dir.join("snapshot.dat"));
|
||||
|
||||
match std::fs::write(&restore_path, &bytes) {
|
||||
Ok(()) => {
|
||||
state.push_toast(crate::app::state::types::Toast::new(
|
||||
crate::app::state::types::ToastKind::Success,
|
||||
format!("Restored {} from snapshot", restore_path.display()),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
state.push_toast(crate::app::state::types::Toast::new(
|
||||
crate::app::state::types::ToastKind::Error,
|
||||
format!("Failed to write restored file: {e}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Log the rewind itself as an edit entry
|
||||
let mut el = crate::model::editlog::EditLog::new(&state.session_dir);
|
||||
let entry = crate::model::editlog::EditLogEntry {
|
||||
ts: chrono::Utc::now().timestamp_millis(),
|
||||
tool: "rewind".to_string(),
|
||||
path: restore_path.to_string_lossy().to_string(),
|
||||
reason: format!("rewind_to({index})"),
|
||||
content_sha256: hex::encode(sha2::Sha256::digest(&bytes)),
|
||||
bytes_delta: bytes.len() as i64,
|
||||
origin: crate::app::state::types::Origin::Main.tag(),
|
||||
session_id: state.session_id.clone(),
|
||||
};
|
||||
let _ = el.append(entry);
|
||||
|
||||
// Clear the transcript to force a refresh
|
||||
state.transcript_cache.dirty = true;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
fn open_session_db(session_dir: &std::path::Path) -> anyhow::Result<rusqlite::Connection> {
|
||||
let path = session_dir.join("messages.sqlite");
|
||||
let conn = rusqlite::Connection::open(&path)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
fn find_edit_path(state: &AppStateRest, _blob_key: &str) -> Option<std::path::PathBuf> {
|
||||
let el = crate::model::editlog::EditLog::new(&state.session_dir);
|
||||
let entry = el
|
||||
.entries
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|e| e.tool == "write" || e.tool == "edit")?;
|
||||
Some(std::path::PathBuf::from(&entry.path))
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! Settings-mode helper logic for the TUI settings overlay.
|
||||
//!
|
||||
//! Flow: exposes small mutation functions (currently just cycling the
|
||||
//! internet access mode) invoked by keybindings while the settings overlay
|
||||
//! is active.
|
||||
use crate::model::settings::{InternetMode, Settings};
|
||||
|
||||
/// Advance the internet access mode to the next value in the cycle.
|
||||
///
|
||||
/// Flow: Off -> `ReadOnly` -> Full -> Off, wrapping around.
|
||||
///
|
||||
/// Why: used by a settings-toggle keybinding to step through modes
|
||||
/// without needing a dropdown/menu.
|
||||
///
|
||||
/// Return: nothing; mutates `settings.internet_mode` in place.
|
||||
pub fn cycle_internet_mode(settings: &mut Settings) {
|
||||
settings.internet_mode = match settings.internet_mode {
|
||||
InternetMode::Off => InternetMode::ReadOnly,
|
||||
InternetMode::ReadOnly => InternetMode::Full,
|
||||
InternetMode::Full => InternetMode::Off,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! Todo-mode helper logic for the TUI todo-list overlay.
|
||||
//!
|
||||
//! Flow: exposes the toggle handler invoked by a keybinding to show/hide
|
||||
//! the todo overlay.
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::types::Overlay;
|
||||
|
||||
/// Toggle the todo-list overlay open or closed.
|
||||
///
|
||||
/// Flow: if the todo overlay is currently shown, hide it (set to `Overlay::None`);
|
||||
/// otherwise show it.
|
||||
///
|
||||
/// Why: marks state dirty so the TUI re-renders on the next frame.
|
||||
///
|
||||
/// Return: nothing; mutates `state.misc.overlay` and `state.dirty` in place.
|
||||
pub fn handle_todo_toggle(state: &mut AppStateRest) {
|
||||
if state.misc.overlay == Overlay::Todo {
|
||||
state.misc.overlay = Overlay::None;
|
||||
} else {
|
||||
state.misc.overlay = Overlay::Todo;
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
Reference in New Issue
Block a user