diff --git a/.config/config.ron b/.config/config.ron index e66366c..7feb41d 100644 --- a/.config/config.ron +++ b/.config/config.ron @@ -5,9 +5,13 @@ "down": MoveDown, "up": MoveUp, "ctrl-c": Quit, + "q": Quit, }, zk: ( keybinds: { + "j": MoveDown, + "k": MoveUp, + "/": Search, "tab": SwitchTo( page: Todo(Explorer), ), @@ -38,6 +42,8 @@ "j": MoveDown, "g": NewSubGroup, "t": NewTask, + "l": EnterScope, + "h": ExitScope, }, ), inspector: ( @@ -81,6 +87,7 @@ page: Todo(TaskList), ), "j": MoveDown, + "h": ExitScope, }, ), ), diff --git a/.config/default_config.ron b/.config/default_config.ron index 9b9da02..894a3f6 100644 --- a/.config/default_config.ron +++ b/.config/default_config.ron @@ -1,17 +1,22 @@ ( directory: "{INSERT_ROOT_HERE}", daemon_url: "http://localhost:6767", + serve_addr: "0.0.0.0:6767", global_key_binds: { "up": MoveUp, "down": MoveDown, "ctrl-z": Suspend, "ctrl-c": Quit, + "q": Quit, }, zk: ( keybinds: { "tab": SwitchTo(page: Todo(Explorer)), "ctrl-n": NewZettel, "enter": OpenZettel, + "j": MoveDown, + "k": MoveUp, + "/": Search, }, ), todo: ( @@ -26,6 +31,8 @@ "shift-g": NewGroup, "g": NewSubGroup, "t": NewTask, + "l": EnterScope, + "h": ExitScope, "enter": SwitchTo(page: Todo(Inspector)) }, ), @@ -51,6 +58,7 @@ "3": SwitchTo(page: Todo(TaskList)), "j": MoveDown, "k": MoveUp, + "h": ExitScope, "enter": SwitchTo(page: Todo(Inspector)) }, ), diff --git a/src/cli/process.rs b/src/cli/process.rs index 22ad816..d921bea 100644 --- a/src/cli/process.rs +++ b/src/cli/process.rs @@ -129,7 +129,7 @@ impl Commands { Self::Serve => { let server = FilServer::new().await?; - server.run().await; + server.run().await?; } Self::Elm { @@ -137,7 +137,7 @@ impl Commands { frame_rate, } => { let conf = Config::parse()?; - let elmui = Elmui::new(tick_rate, frame_rate, &conf.daemon_url)?; + let elmui = Elmui::new(tick_rate, frame_rate, &conf.daemon_url, conf.keymap)?; elmui.run().await?; } diff --git a/src/config/file.rs b/src/config/file.rs index 53db029..c0860ef 100644 --- a/src/config/file.rs +++ b/src/config/file.rs @@ -14,6 +14,9 @@ pub struct RonConfig { /// configs written before it existed still parse. #[serde(default = "default_daemon_url")] pub daemon_url: String, + /// Socket address `fil serve` binds to. Optional, like `daemon_url`. + #[serde(default = "default_serve_addr")] + pub serve_addr: String, /// Keybinds to be used in every part of the TUI pub global_key_binds: HashMap, /// Config for the `ZK` component of the TUI @@ -26,6 +29,10 @@ fn default_daemon_url() -> String { "http://localhost:6767".to_owned() } +fn default_serve_addr() -> String { + "0.0.0.0:6767".to_owned() +} + #[derive(Debug, Deserialize, Serialize)] pub struct ZkConfig { pub keybinds: HashMap, diff --git a/src/config/keymap.rs b/src/config/keymap.rs index d0940a1..cd2534f 100644 --- a/src/config/keymap.rs +++ b/src/config/keymap.rs @@ -74,6 +74,38 @@ impl DerefMut for KeyMap { } } +/// Renders a key sequence for on-screen hints, roughly the inverse of +/// `parse_key_sequence`: `ctrl-n`, `G`, `enter`, `↑`. +pub fn key_label(seq: &[KeyEvent]) -> String { + seq.iter().map(key_event_label).collect() +} + +fn key_event_label(key: &KeyEvent) -> String { + let mut label = String::new(); + if key.modifiers.contains(KeyModifiers::CONTROL) { + label.push_str("ctrl-"); + } + if key.modifiers.contains(KeyModifiers::ALT) { + label.push_str("alt-"); + } + let code = match key.code { + KeyCode::Char(' ') => "space".to_owned(), + // shift is implied by the uppercase letter + KeyCode::Char(c) if key.modifiers.contains(KeyModifiers::SHIFT) => { + c.to_ascii_uppercase().to_string() + } + KeyCode::Char(c) => c.to_string(), + KeyCode::Up => "↑".to_owned(), + KeyCode::Down => "↓".to_owned(), + KeyCode::Left => "←".to_owned(), + KeyCode::Right => "→".to_owned(), + KeyCode::BackTab => "shift-tab".to_owned(), + code => code.to_string().to_ascii_lowercase(), + }; + label.push_str(&code); + label +} + pub fn parse_key_sequence(raw: &str) -> color_eyre::Result, String> { if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() { return Err(format!("Unable to parse `{raw}`")); @@ -184,6 +216,30 @@ mod test { tui::{Page, Signal, TodoRegion}, }; + #[test] + fn test_key_label_round_trip() { + use super::{key_label, parse_key_sequence}; + + for (raw, label) in [ + ("shift-g", "G"), + ("ctrl-n", "ctrl-n"), + ("up", "↑"), + ("enter", "enter"), + ("tab", "tab"), + ("/", "/"), + ("space", "space"), + ] { + assert_eq!(key_label(&parse_key_sequence(raw).unwrap()), label); + } + } + + #[test] + fn test_default_config_parses() { + let config: RonConfig = + ron::from_str(include_str!("../../.config/default_config.ron")).unwrap(); + let _: KeyMap = (&config).try_into().unwrap(); + } + #[test] fn test_quit() { let conf_str = r#" diff --git a/src/config/mod.rs b/src/config/mod.rs index 9bc5782..4d925ca 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -9,10 +9,11 @@ use color_eyre::eyre::{Context, Result}; use directories::ProjectDirs; use ron::ser::PrettyConfig; -use crate::config::{file::RonConfig, keymap::KeyMap}; +use crate::config::file::RonConfig; mod file; mod keymap; +pub use keymap::{KeyMap, key_label}; /// Project Name: Filaments pub static PROJECT_NAME: LazyLock = @@ -38,6 +39,7 @@ const DEFAULT_CONFIG: &str = include_str!("../../.config/default_config.ron"); pub struct Config { pub fil_dir: PathBuf, pub daemon_url: String, + pub serve_addr: String, pub keymap: KeyMap, } @@ -92,6 +94,7 @@ impl Config { .canonicalize() .with_context(|| "Failed to canonicalize the directory provided in the config!")?, daemon_url: ron.daemon_url, + serve_addr: ron.serve_addr, keymap, }) } diff --git a/src/elm/mod.rs b/src/elm/mod.rs index 9390c92..cb203d9 100644 --- a/src/elm/mod.rs +++ b/src/elm/mod.rs @@ -13,6 +13,7 @@ use serde_json::json; use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; use crate::{ + config::KeyMap, elm::{ model::Model, msg::{Cmd, Msg}, @@ -40,7 +41,7 @@ pub struct Elmui { impl Elmui { /// `daemon_url` is the base URL of `fil serve`, e.g. /// `http://localhost:6767`. - pub fn new(tick_rate: f64, frame_rate: f64, daemon_url: &str) -> Result { + pub fn new(tick_rate: f64, frame_rate: f64, daemon_url: &str, keymap: KeyMap) -> Result { let daemon = Daemon::new(daemon_url); let (msg_tx, msg_rx) = mpsc::unbounded_channel(); @@ -49,7 +50,7 @@ impl Elmui { .with_tick_rate(tick_rate) .with_frame_rate(frame_rate); - let model = Model::new(); + let model = Model::new(keymap); Ok(Self { daemon, diff --git a/src/elm/model.rs b/src/elm/model.rs index 19c391b..4d588a2 100644 --- a/src/elm/model.rs +++ b/src/elm/model.rs @@ -5,13 +5,18 @@ use nucleo_matcher::{ Matcher, Utf32Str, pattern::{CaseMatching, Normalization, Pattern}, }; +use tree::NodeId; -use crate::elm::msg::{Cmd, Msg}; +use crate::{ + config::KeyMap, + elm::msg::{Cmd, Msg}, + tui::{Page as TuiPage, Signal, TodoRegion}, +}; /// Which widget keystrokes go to. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Mode { - /// j/k move, q quits, `/` focuses search. + /// Keys go through the keymap from the config. #[default] Normal, /// Printable keys edit the query; Enter keeps it, Esc clears it. @@ -21,7 +26,7 @@ pub enum Mode { Prompt(NewItem), } -/// Top-level page, toggled with Tab. +/// Top-level page; `SwitchTo` binds move between them. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Page { #[default] @@ -29,7 +34,7 @@ pub enum Page { Todo, } -/// Which todo list j/k move in. +/// Which todo list moves act on and the inspector shows. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum TodoFocus { #[default] @@ -62,6 +67,10 @@ pub struct Model { pub todo: TodoState, /// Name being typed while in `Mode::Prompt`. pub prompt: String, + /// Binds from the config, shared with the old TUI. + pub keymap: KeyMap, + /// Keys typed so far towards a multi-key bind; cleared every tick. + pending_keys: Vec, pub status: Option, pub loading: bool, pub connected: bool, @@ -75,30 +84,112 @@ pub struct Model { pub struct TodoState { pub tree: Option, pub focus: TodoFocus, + /// The inspector region has focus; it still shows `focus`'s selection. + pub inspector_focused: bool, pub explorer_selected: Option, pub tasklist_selected: Option, + /// Group both lists are zoomed into; `None` is the root. + pub scope: Option, /// Item to select once the next tree arrives, e.g. one just created. pending_select: Option, } impl TodoState { - /// Every group and task in pre-order, root excluded, mirroring - /// `tui::components::todo::explorer::Explorer`. + /// The tree node of `scope`, or the root if unscoped or `scope` no + /// longer exists. + fn scope_node_id(&self, tree: &TodoTree) -> NodeId { + self.scope + .as_ref() + .and_then(|id| tree.nanoid_to_nodeid.get(id)) + .unwrap_or(&tree.root_id) + .clone() + } + + /// Every group and task under `scope` in pre-order, `scope` itself + /// excluded, mirroring `tui::components::todo::explorer::Explorer`. pub fn explorer_rows(&self) -> Vec<&TodoNode> { let Some(tree) = &self.tree else { return Vec::new(); }; tree.tree - .traverse_pre_order(&tree.root_id) - .map(|nodes| { - nodes - .map(tree::Node::data) - .filter(|node| !matches!(node.kind, TodoNodeKind::Root)) - .collect() - }) + .traverse_pre_order(&self.scope_node_id(tree)) + .map(|nodes| nodes.skip(1).map(tree::Node::data).collect()) .unwrap_or_default() } + /// Depth of the rows directly under `scope`, so the view can indent + /// relative to it. + pub fn base_depth(&self) -> usize { + let Some(tree) = &self.tree else { + return 0; + }; + match tree + .tree + .get(&self.scope_node_id(tree)) + .map(tree::Node::data) + { + Ok(node) if matches!(node.kind, TodoNodeKind::Group(_)) => node.depth + 1, + _ => 0, + } + } + + /// Names of `scope` and its ancestor groups, outermost first; empty at + /// the root. + pub fn breadcrumb(&self) -> String { + let Some(tree) = &self.tree else { + return String::new(); + }; + let scope = self.scope_node_id(tree); + let Ok(ancestors) = tree.tree.ancestors(&scope) else { + return String::new(); + }; + let mut names: Vec<&str> = tree + .tree + .get(&scope) + .into_iter() + .chain(ancestors) + .filter_map(|node| match &node.data().kind { + TodoNodeKind::Group(group) => Some(group.name.as_str()), + _ => None, + }) + .collect(); + names.reverse(); + names.join(" › ") + } + + /// Zooms into the selected group. + fn enter_scope(&mut self) { + let Some(TodoNodeKind::Group(group)) = self.selected_node().map(|node| &node.kind) else { + return; + }; + self.scope = Some(group.id.clone()); + self.explorer_selected = self + .explorer_rows() + .first() + .and_then(|n| nano_id(n)) + .cloned(); + self.normalize_selection(); + } + + /// Zooms out to the parent of `scope`, landing on the group just left. + fn exit_scope(&mut self) { + let Some(left) = self.scope.take() else { + return; + }; + self.scope = self + .tree + .as_ref() + .and_then(|tree| tree.nanoid_to_nodeid.get(&left).map(|id| (tree, id))) + .and_then(|(tree, id)| match &tree.tree.get(id).ok()?.data().kind { + TodoNodeKind::Group(group) => group.parent_id.clone(), + _ => None, + }); + self.focus = TodoFocus::Explorer; + self.inspector_focused = false; + self.explorer_selected = Some(left); + self.normalize_selection(); + } + /// Unfinished tasks, highest `p_score` first, mirroring /// `tui::components::todo::tasklist::TaskList`. pub fn task_rows(&self) -> Vec<&TodoNode> { @@ -143,12 +234,13 @@ impl TodoState { } /// The group new items go into: the selection itself if it is a group, - /// otherwise the selected task's group. + /// otherwise the selected task's group, otherwise (e.g. inside an empty + /// group) the scope. fn group_for_new_item(&self) -> Option { - match &self.selected_node()?.kind { - TodoNodeKind::Group(group) => Some(group.id.clone()), - TodoNodeKind::Task(task) => Some(task.group_id.clone()), - TodoNodeKind::Root => None, + match self.selected_node().map(|node| &node.kind) { + Some(TodoNodeKind::Group(group)) => Some(group.id.clone()), + Some(TodoNodeKind::Task(task)) => Some(task.group_id.clone()), + _ => self.scope.clone(), } } @@ -174,8 +266,27 @@ impl TodoState { /// Applies a pending selection, then points any selection that no longer /// exists at the first row of its list. fn normalize_selection(&mut self) { + let contains = |state: &Self, id: &NanoId| { + state + .tree + .as_ref() + .is_some_and(|tree| tree.nanoid_to_nodeid.contains_key(id)) + }; + if self + .scope + .as_ref() + .is_some_and(|scope| !contains(self, scope)) + { + self.scope = None; + } + if let Some(id) = self.pending_select.take() { + // e.g. a top-level group created while zoomed in + if Self::position(&self.explorer_rows(), Some(&id)).is_none() { + self.scope = None; + } self.focus = TodoFocus::Explorer; + self.inspector_focused = false; self.explorer_selected = Some(id); } @@ -206,7 +317,7 @@ pub fn nano_id(node: &TodoNode) -> Option<&NanoId> { } impl Model { - pub fn new() -> Self { + pub fn new(keymap: KeyMap) -> Self { Self { page: Page::Zettels, zettels: Vec::new(), @@ -216,6 +327,8 @@ impl Model { selected: None, todo: TodoState::default(), prompt: String::new(), + keymap, + pending_keys: Vec::new(), status: None, loading: true, connected: false, @@ -238,7 +351,11 @@ impl Model { /// effects for the event loop to spawn. Never awaits. pub fn update(&mut self, msg: Msg) -> Vec { match msg { - Msg::Tick | Msg::Render | Msg::Resize => Vec::new(), + Msg::Tick => { + self.pending_keys.clear(); + Vec::new() + } + Msg::Render | Msg::Resize => Vec::new(), Msg::Key(key) => self.on_key(key), Msg::ZettelsLoaded(zettels) => { self.loading = false; @@ -308,24 +425,29 @@ impl Model { } } - fn on_key(&mut self, key: KeyEvent) -> Vec { - if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { - self.quit = true; - return Vec::new(); + /// The old TUI's keymap page for what currently has focus, so both UIs + /// share one set of binds from the config. + pub const fn keymap_page(&self) -> TuiPage { + match self.page { + Page::Zettels => TuiPage::Zk, + Page::Todo if self.todo.inspector_focused => TuiPage::Todo(TodoRegion::Inspector), + Page::Todo => match self.todo.focus { + TodoFocus::Explorer => TuiPage::Todo(TodoRegion::Explorer), + TodoFocus::TaskList => TuiPage::Todo(TodoRegion::TaskList), + }, } + } + fn on_key(&mut self, key: KeyEvent) -> Vec { match self.mode { - Mode::Normal if key.code == KeyCode::Tab => { - self.page = match self.page { - Page::Zettels => Page::Todo, - Page::Todo => Page::Zettels, + Mode::Normal => { + let Some(signal) = self.resolve_key(key) else { + return Vec::new(); }; - Vec::new() + // a new action supersedes whatever the last one reported + self.status = None; + self.on_signal(&signal) } - Mode::Normal => match self.page { - Page::Zettels => self.on_zettel_key(key), - Page::Todo => self.on_todo_key(key), - }, Mode::Search => { self.on_search_key(key); Vec::new() @@ -334,67 +456,96 @@ impl Model { } } - fn on_zettel_key(&mut self, key: KeyEvent) -> Vec { - match key.code { - KeyCode::Enter => { - return self - .selected - .clone() - .map(Cmd::FetchFile) - .into_iter() - .collect(); - } - KeyCode::Char('q') if key.modifiers.is_empty() => self.quit = true, - // Esc drops an active filter first, quits otherwise. - KeyCode::Esc if !self.query.is_empty() => self.set_query(String::new()), - KeyCode::Esc => self.quit = true, - KeyCode::Char('/') => self.mode = Mode::Search, - KeyCode::Char('j') | KeyCode::Down => self.move_selection(1), - KeyCode::Char('k') | KeyCode::Up => self.move_selection(-1), - _ => {} + /// Looks `key` up in the keymap the way `tui::App::handle_key_event` + /// does: as a single key first, then as the latest key of a sequence + /// that is cleared every tick. + fn resolve_key(&mut self, key: KeyEvent) -> Option { + let binds = self.keymap.get(&self.keymap_page())?; + if let Some(signal) = binds.get(&vec![key]) { + self.pending_keys.clear(); + return Some(signal.clone()); } - Vec::new() + self.pending_keys.push(key); + let signal = binds.get(&self.pending_keys).cloned(); + if signal.is_some() { + self.pending_keys.clear(); + } + signal } - /// Keys mirror the old `todo` keymap in `.config/default_config.ron`. - fn on_todo_key(&mut self, key: KeyEvent) -> Vec { - match key.code { - KeyCode::Char('q') if key.modifiers.is_empty() => self.quit = true, - KeyCode::Esc => self.quit = true, - KeyCode::Char('1') => self.todo.focus = TodoFocus::Explorer, - KeyCode::Char('3') => self.todo.focus = TodoFocus::TaskList, - KeyCode::Char('j') | KeyCode::Down => self.todo.move_selection(1), - KeyCode::Char('k') | KeyCode::Up => self.todo.move_selection(-1), - KeyCode::Char('t') => { + /// Carries out a keymap action. Actions the Elm UI doesn't support yet + /// say so in the status line rather than silently doing nothing. + fn on_signal(&mut self, signal: &Signal) -> Vec { + match signal { + Signal::Quit => self.quit = true, + Signal::SwitchTo { page } => self.switch_to(*page), + Signal::MoveDown => self.move_down_up(1), + Signal::MoveUp => self.move_down_up(-1), + Signal::Search if self.page == Page::Zettels => self.mode = Mode::Search, + Signal::OpenZettel => return self.open_selected().into_iter().collect(), + Signal::EnterScope if self.page == Page::Todo => self.todo.enter_scope(), + Signal::ExitScope if self.page == Page::Todo => self.todo.exit_scope(), + Signal::NewTask if self.page == Page::Todo => { if self.todo.group_for_new_item().is_some() { self.open_prompt(NewItem::Task); } else { self.status = Some("select a group to add a task to".to_owned()); } } - KeyCode::Char('g') => self.open_prompt(NewItem::SubGroup), - KeyCode::Char('G') => self.open_prompt(NewItem::Group), - KeyCode::Char('f') => { + Signal::NewSubGroup if self.page == Page::Todo => { + self.open_prompt(NewItem::SubGroup); + } + Signal::NewGroup if self.page == Page::Todo => self.open_prompt(NewItem::Group), + Signal::ToggleFinish if self.page == Page::Todo => { if let Some(TodoNodeKind::Task(task)) = self.todo.selected_node().map(|node| &node.kind) { return vec![Cmd::ToggleTask(task.id.clone())]; } } - // opens the task's/group's zettel in the editor - KeyCode::Enter => { - let zettel = match self.todo.selected_node().map(|node| &node.kind) { - Some(TodoNodeKind::Task(task)) => Some(task.zettel.id.clone()), - Some(TodoNodeKind::Group(group)) => Some(group.zettel.id.clone()), - _ => None, - }; - return zettel.map(Cmd::FetchFile).into_iter().collect(); - } - _ => {} + other => self.status = Some(format!("`{other}` isn't supported in the Elm UI yet")), } Vec::new() } + fn switch_to(&mut self, page: TuiPage) { + match page { + TuiPage::Zk => self.page = Page::Zettels, + TuiPage::Todo(region) => { + self.page = Page::Todo; + self.todo.inspector_focused = region == TodoRegion::Inspector; + match region { + TodoRegion::Explorer => self.todo.focus = TodoFocus::Explorer, + TodoRegion::TaskList => self.todo.focus = TodoFocus::TaskList, + // the inspector keeps showing the list it came from + TodoRegion::Inspector => {} + } + } + } + } + + fn move_down_up(&mut self, delta: isize) { + match self.page { + Page::Zettels => self.move_selection(delta), + Page::Todo if self.todo.inspector_focused => {} + Page::Todo => self.todo.move_selection(delta), + } + } + + /// Fetches the selected zettel (or the selected task's/group's zettel) + /// for the editor. + fn open_selected(&self) -> Option { + let id = match self.page { + Page::Zettels => self.selected.clone(), + Page::Todo => match self.todo.selected_node().map(|node| &node.kind) { + Some(TodoNodeKind::Task(task)) => Some(task.zettel.id.clone()), + Some(TodoNodeKind::Group(group)) => Some(group.zettel.id.clone()), + _ => None, + }, + }; + id.map(Cmd::FetchFile) + } + fn open_prompt(&mut self, item: NewItem) { self.prompt.clear(); self.mode = Mode::Prompt(item); diff --git a/src/elm/todo.rs b/src/elm/todo.rs index 184b836..3679798 100644 --- a/src/elm/todo.rs +++ b/src/elm/todo.rs @@ -12,30 +12,34 @@ use ratatui::{ pub fn explorer( rows: &[&TodoNode], selected: Option, + base_depth: usize, + breadcrumb: &str, focused: bool, area: Rect, buf: &mut Buffer, ) { - let list = List::new(rows.iter().map(|node| explorer_item(node, area.width))) - .block(region_block( - "[1]", - "Explorer", - Borders::TOP | Borders::RIGHT, - focused, - )) - .style(Color::White) - .highlight_style(Modifier::ITALIC | Modifier::BOLD) - .highlight_symbol("> "); + let mut block = region_block("[1]", "Explorer", Borders::TOP | Borders::RIGHT, focused); + if !breadcrumb.is_empty() { + block = block.title(format!(" {breadcrumb} ")); + } + let list = List::new( + rows.iter() + .map(|node| explorer_item(node, node.depth.saturating_sub(base_depth), area.width)), + ) + .block(block) + .style(Color::White) + .highlight_style(Modifier::ITALIC | Modifier::BOLD) + .highlight_symbol("> "); let mut state = ListState::default(); state.select(selected); StatefulWidget::render(list, area, buf, &mut state); } -/// Indented by depth. Groups are a full-width bar in their tag colour; tasks +/// Indented by `indent` levels. Groups are a full-width bar in their tag colour; tasks /// are text in their group's colour, struck through once finished. -fn explorer_item(node: &TodoNode, width: u16) -> Text<'static> { - let spacer = " ".repeat(node.depth); +fn explorer_item(node: &TodoNode, indent: usize, width: u16) -> Text<'static> { + let spacer = " ".repeat(indent); let line = match &node.kind { TodoNodeKind::Group(group) => { let name = format!(" {}", group.name); @@ -120,13 +124,13 @@ fn task_item(node: &TodoNode, width: u16) -> Text<'static> { /// Read-only details of the selected node, mirroring the field layout of /// `tui::components::todo::inspector::{TaskView, GroupView}`. -pub fn inspector(node: Option<&TodoNode>, area: Rect, buf: &mut Buffer) { - let block = Block::new() - .title("[2]") - .title("Inspector") - .borders(Borders::LEFT | Borders::TOP | Borders::BOTTOM) - .border_style(Style::new().fg(Color::Gray)) - .border_type(BorderType::Rounded); +pub fn inspector(node: Option<&TodoNode>, focused: bool, area: Rect, buf: &mut Buffer) { + let block = region_block( + "[2]", + "Inspector", + Borders::LEFT | Borders::TOP | Borders::BOTTOM, + focused, + ); let inner = block.inner(area); block.render(area, buf); diff --git a/src/elm/view.rs b/src/elm/view.rs index ab94c7e..fa82171 100644 --- a/src/elm/view.rs +++ b/src/elm/view.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use ratatui::{ Frame, layout::{Constraint, Layout, Rect}, @@ -8,9 +10,13 @@ use ratatui::{ use fil_core::Zettel; -use crate::elm::{ - model::{Mode, Model, NewItem, Page, TodoFocus, TodoState}, - todo, zk, +use crate::{ + config::key_label, + elm::{ + model::{Mode, Model, NewItem, Page, TodoFocus, TodoState}, + todo, zk, + }, + tui::{Page as TuiPage, Signal, TodoRegion}, }; pub fn view(model: &Model, frame: &mut Frame) { @@ -102,11 +108,18 @@ fn todo_page(model: &Model, frame: &mut Frame, area: Rect) { todo::explorer( &explorer_rows, explorer_selected, + state.base_depth(), + &state.breadcrumb(), state.focus == TodoFocus::Explorer, explorer_area, buf, ); - todo::inspector(state.selected_node(), inspector_area, buf); + todo::inspector( + state.selected_node(), + state.inspector_focused, + inspector_area, + buf, + ); todo::task_list( &task_rows, task_selected, @@ -148,15 +161,76 @@ fn status_line(model: &Model) -> Paragraph<'static> { ))); } - let hint = match (model.mode, model.page) { - (Mode::Normal, Page::Zettels) => "· j/k move · / search · tab todo · q quit", - (Mode::Normal, Page::Todo) => { - "· 1/3 focus · j/k move · t task · g subgroup · G group · f finish · enter edit · tab zettels · q quit" - } - (Mode::Search, _) => "· ↑/↓ move · enter keep · esc clear", - (Mode::Prompt(_), _) => "· enter create · esc cancel", + // search and the name prompt take raw text, so their keys are fixed + let hint = match model.mode { + Mode::Normal => keymap_hints(model), + Mode::Search => "· ↑/↓ move · enter keep · esc clear".to_owned(), + Mode::Prompt(_) => "· enter create · esc cancel".to_owned(), }; spans.push(Span::styled(hint, Style::new().fg(Color::DarkGray))); Paragraph::new(Line::from(spans)) } + +/// Hints for every keymap bind the Elm UI acts on in the focused region, +/// built from the config so they follow whatever keys are bound. +fn keymap_hints(model: &Model) -> String { + let Some(binds) = model.keymap.get(&model.keymap_page()) else { + return String::new(); + }; + + // (display order, description) -> key labels + let mut hints: BTreeMap<(u8, &str), Vec> = BTreeMap::new(); + for (keys, signal) in binds { + // the inspector doesn't scroll + if model.todo.inspector_focused + && model.page == Page::Todo + && matches!(signal, Signal::MoveDown | Signal::MoveUp) + { + continue; + } + if let Some(hint) = hint(signal, model.page) { + hints.entry(hint).or_default().push(key_label(keys)); + } + } + + hints + .into_iter() + .map(|((_, description), mut keys)| { + keys.sort_by(|a, b| a.chars().count().cmp(&b.chars().count()).then(a.cmp(b))); + format!("· {} {description}", keys.join("/")) + }) + .collect::>() + .join(" ") +} + +/// Display order and description for a bind the Elm UI handles on `page`; +/// `None` hides it. +const fn hint(signal: &Signal, page: Page) -> Option<(u8, &'static str)> { + let todo = matches!(page, Page::Todo); + Some(match signal { + Signal::MoveDown => (0, "down"), + Signal::MoveUp => (1, "up"), + Signal::Search if !todo => (2, "search"), + Signal::EnterScope if todo => (3, "enter group"), + Signal::ExitScope if todo => (4, "leave group"), + Signal::SwitchTo { + page: TuiPage::Todo(region), + } if todo => match region { + TodoRegion::Explorer => (5, "explorer"), + TodoRegion::Inspector => (6, "inspector"), + TodoRegion::TaskList => (7, "tasks"), + }, + Signal::NewTask if todo => (8, "new task"), + Signal::NewSubGroup if todo => (9, "new subgroup"), + Signal::NewGroup if todo => (10, "new group"), + Signal::ToggleFinish if todo => (11, "finish"), + Signal::OpenZettel => (12, "edit"), + Signal::SwitchTo { page: TuiPage::Zk } if todo => (13, "zettels"), + Signal::SwitchTo { + page: TuiPage::Todo(_), + } => (13, "todo"), + Signal::Quit => (14, "quit"), + _ => return None, + }) +} diff --git a/src/main.rs b/src/main.rs index 8f769b7..2b9fb3f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,53 +43,53 @@ async fn main() -> color_eyre::Result<()> { // some need a kasten handle and some dont, some need // Kasten //WARN: this needs to be removed - let kh: KastenHandle = { - let cfg = Config::parse()?; - debug!("Config: {cfg:#?}"); - Arc::new(RwLock::new(Kasten::instansiate(cfg.fil_dir).await?)) - }; - - // what if we make this kasten handle optional and then pass that optional in. - debug!("Kasten Handle: {kh:#?}"); - - let (signal_tx, signal_rx) = mpsc::unbounded_channel(); - - // Spawn TUI on the existing tokio runtime - let tui_handle = { - let kh = kh.clone(); - let signal_tx = signal_tx.clone(); - let (tick_rate, frame_rate) = (args.tick_rate, args.frame_rate); - - tokio::spawn(async move { - let mut tui = TuiApp::new(tick_rate, frame_rate, kh, signal_tx)?; - tui.run().await?; - // Force close everything once TUI is done. - process::exit(0); - #[allow(unreachable_code)] - Ok::<(), color_eyre::Report>(()) - }) - }; - - if args.visualizer { - // Enter the guard so `egui_async` works properly - let _rt_guard = tokio::runtime::Handle::current().enter(); - - // spawn Deimos: file watcher - { - let kh = kh.clone(); - tokio::spawn(async move { - let deimos = Deimos::new(kh, signal_tx); - deimos.watch().await - }); - } - - // Create an Index of the current contents - let index = kh.read().await.index.clone(); - - // run the visualizer - FilViz::run(kh, signal_rx, &index)?; - } - - tui_handle.await.unwrap()?; + // let kh: KastenHandle = { + // let cfg = Config::parse()?; + // debug!("Config: {cfg:#?}"); + // Arc::new(RwLock::new(Kasten::instansiate(cfg.fil_dir).await?)) + // }; + + // // what if we make this kasten handle optional and then pass that optional in. + // debug!("Kasten Handle: {kh:#?}"); + + // let (signal_tx, signal_rx) = mpsc::unbounded_channel(); + + // // Spawn TUI on the existing tokio runtime + // let tui_handle = { + // let kh = kh.clone(); + // let signal_tx = signal_tx.clone(); + // let (tick_rate, frame_rate) = (args.tick_rate, args.frame_rate); + + // tokio::spawn(async move { + // let mut tui = TuiApp::new(tick_rate, frame_rate, kh, signal_tx)?; + // tui.run().await?; + // // Force close everything once TUI is done. + // process::exit(0); + // #[allow(unreachable_code)] + // Ok::<(), color_eyre::Report>(()) + // }) + // }; + + // if args.visualizer { + // // Enter the guard so `egui_async` works properly + // let _rt_guard = tokio::runtime::Handle::current().enter(); + + // // spawn Deimos: file watcher + // { + // let kh = kh.clone(); + // tokio::spawn(async move { + // let deimos = Deimos::new(kh, signal_tx); + // deimos.watch().await + // }); + // } + + // // Create an Index of the current contents + // let index = kh.read().await.index.clone(); + + // // run the visualizer + // FilViz::run(kh, signal_rx, &index)?; + // } + + // tui_handle.await.unwrap()?; Ok(()) } diff --git a/src/server/mod.rs b/src/server/mod.rs index 41c8612..05281b1 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use axum::{Router, routing::get}; -use color_eyre::eyre::Result; +use color_eyre::eyre::{Context as _, Result}; use fil_core::{Kasten, KastenHandle}; use nucleo_matcher::Matcher; use tokio::sync::{ @@ -14,8 +14,6 @@ use tracing::info; use crate::{config::Config, server::event::ServerEvent}; -const SERVER_ADDR: &str = "0.0.0.0:6767"; - mod error; mod event; mod todo_router; @@ -25,6 +23,8 @@ mod zettel_router; pub struct FilServer { router: Router, + /// Socket address to bind, from `serve_addr` in the config. + addr: String, } // state should be daemon state @@ -37,11 +37,8 @@ pub struct FilState { } impl FilState { - pub async fn new() -> Result { - let kh = { - let cfg = Config::parse()?; - Arc::new(RwLock::new(Kasten::instansiate(cfg.fil_dir).await?)) - }; + pub async fn new(cfg: &Config) -> Result { + let kh = Arc::new(RwLock::new(Kasten::instansiate(cfg.fil_dir.clone()).await?)); let (tx, _) = broadcast::channel(128); @@ -55,7 +52,8 @@ impl FilState { impl FilServer { pub async fn new() -> Result { - let state = FilState::new().await?; + let cfg = Config::parse()?; + let state = FilState::new(&cfg).await?; let router = Router::new() //TODO: this would eventually become the server for the visualizer @@ -65,15 +63,19 @@ impl FilServer { // .nest("/api/graph", viz_router::router()) .with_state(state); - Ok(Self { router }) + Ok(Self { + router, + addr: cfg.serve_addr, + }) } - pub async fn run(self) { - let listener = tokio::net::TcpListener::bind(SERVER_ADDR).await.unwrap(); - info!("Serving on address {SERVER_ADDR}"); - - axum::serve(listener, self.router) + pub async fn run(self) -> Result<()> { + let listener = tokio::net::TcpListener::bind(&self.addr) .await - .expect("Axum must not panic!"); + .with_context(|| format!("Failed to bind `serve_addr` {}", self.addr))?; + info!("Serving on address {}", self.addr); + + axum::serve(listener, self.router).await?; + Ok(()) } } diff --git a/src/tui/signal.rs b/src/tui/signal.rs index 8b5c4f1..73fcd7b 100644 --- a/src/tui/signal.rs +++ b/src/tui/signal.rs @@ -99,4 +99,13 @@ pub enum Signal { Select { nanoid: NanoId, }, + + /// Focus the search box (Elm UI). + Search, + + /// Zoom the todo lists into the selected `Group` (Elm UI). + EnterScope, + + /// Zoom the todo lists out to the parent `Group` (Elm UI). + ExitScope, }