diff --git a/Cargo.lock b/Cargo.lock index bb91e5a..8a482fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1072,6 +1072,7 @@ dependencies = [ "tracing", "tracing-error", "tracing-subscriber", + "tui-scrollview", "ulid", "warp", ] @@ -2385,6 +2386,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "indoc" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" + [[package]] name = "inotify" version = "0.9.6" @@ -4292,6 +4299,17 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tui-scrollview" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a65189ac0c5f8af32660c453a1babae3ac7e72791b9dbeb1221073569f44ea" +dependencies = [ + "indoc", + "ratatui", + "rstest", +] + [[package]] name = "tungstenite" version = "0.21.0" diff --git a/Cargo.toml b/Cargo.toml index 1bd5ab7..69ee248 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,9 +28,10 @@ git-next-forge-github = { path = "crates/forge-github", version = "0.13" } # TUI ratatui = "0.28" -directories = "5.0.1" -lazy_static = "1.5.0" -color-eyre = "0.6.3" +directories = "5.0" +lazy_static = "1.5" +color-eyre = "0.6" +tui-scrollview = "0.4" # CLI parsing clap = { version = "4.5", features = ["cargo", "derive"] } diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 2871162..6dbe62f 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -16,7 +16,7 @@ categories = { workspace = true } default = ["forgejo", "github", "tui"] forgejo = ["git-next-forge-forgejo"] github = ["git-next-forge-github"] -tui = ["ratatui", "directories", "lazy_static"] +tui = ["ratatui", "directories", "lazy_static", "tui-scrollview"] [dependencies] git-next-core = { workspace = true } @@ -28,6 +28,7 @@ ratatui = { workspace = true, optional = true } directories = { workspace = true, optional = true } lazy_static = { workspace = true, optional = true } color-eyre = { workspace = true } +tui-scrollview = { workspace = true, optional = true } # CLI parsing clap = { workspace = true } diff --git a/crates/cli/src/tui/README.md b/crates/cli/src/tui/README.md index bafa379..3f608b0 100644 --- a/crates/cli/src/tui/README.md +++ b/crates/cli/src/tui/README.md @@ -1,121 +1,27 @@ -# TUI Actor +# Terminal UI -- Maintains it's own copy of data for holding state -- Is notified of update via actor messages from the Server and Repo Actors -- State is somewhat heirarchical +Currently the Terminal UI is an experimental feature, controlled by the feature flag `tui`. -## State +## Build & Run -```rust -enum TuiState { - Initial, - Configured { - app_config: ValidAppConfig, - forges: BTreeMap, - }, -} -enum RepoState { - Identified { repo_alias: RepoAlias }, - Configured { repo_alias: RepoAlias, branches: RepoBranches }, - Ready { repo_alias: RepoAlias, branches: RepoBranches, main: Commit, next: Commit, dev: Commit, log: Log }, - Alert { repo_alias: RepoAlias, message: String, branches: RepoBranches, main: Commit, next: Commit, dev: Commit, log: Log }, -} -``` +The build `git-next` with the Terminal UI use: `cargo install git-next --features tui` -## State Transitions: +To run `git-next` with the Terminal UI use: `git-next server start --ui` -### `TuiState` +## logs -```mermaid -stateDiagram-v2 - * --> Initial - Initial --> Configured -``` +When the Terminal UI is enabled via the `--ui` parameter, logs are written to the file: -- `message!(Configure, ValidAppConfig, "Initialise UI with valid config");` +- `???` on Linux +- `~/Library/Application Support/net.kemitix.git-next/git-next.log` on MacOS +- `???` on Windows -### `RepoState` +## Keys -```mermaid -stateDiagram-v2 - * --> Identified - Identified --> Configured - Identified --> Ready - Configured --> Ready - Ready --> Alert - Configured --> Alert - Identified --> Alert - Alert --> Ready -``` - -- `Identified` - from AppConfig where a repo alias is listed, but repo config needs to be loaded from `.git-next.toml` -- `Configured` - as `Identified` but either branches are identified in server config, OR the `.git-next.toml` file has been loaded -- `Ready` - as `Configured` but the branch positions have been validated and do not require user intervention -- `Alert` - as `Ready` but user intervention is required - -## Widget - -Initial mock up of UI. Possibly add some borders and padding as it looks a little squached together. - -``` -+ gh -- jo - + test (main/next/dev) - - tasyn (main/next/dev) - * 12ab32f (dev) added feature X - * bce43b1 (next) added feature Y - * f43e379 (main) added feature Z - - git-next (main/next/dev) DEV NOT AHEAD OF MAIN - rebase onto main - * 239cefd (main/next) fix bug A - * b4c290a (dev) -``` - -Adding a border around open forges: - -``` -+ gh -- jo --------------------------------------------------------------------+ -| + test (main/next/dev) | -| - tasyn (main/next/dev) | -| * 12ab32f (dev) added feature X | -| * bce43b1 (next) added feature Y | -| * f43e379 (main) added feature Z | -| - git-next (main/next/dev) DEV NOT AHEAD OF MAIN - rebase onto main | -| * 239cefd (main/next) fix bug A | -| * b4c290a (dev) | -+------------------------------------------------------------------------+ -``` - -Adding a border around open forges and repos (I like this one the best): - -``` -+ gh -- jo --------------------------------------------------------------------+ -| + test (main/next/dev) | -| - tasyn (main/next/dev) ---------------------------------------------+ | -| | * 12ab32f (dev) added feature X | | -| | * bce43b1 (next) added feature Y | | -| | * f43e379 (main) added feature Z | | -| +--------------------------------------------------------------------+ | -| - git-next (main/next/dev) DEV NOT AHEAD OF MAIN - rebase onto main -+ | -| | * 239cefd (main/next) fix bug A | | -| | * b4c290a (dev) | | -| +--------------------------------------------------------------------+ | -+------------------------------------------------------------------------+ -``` - -## Logging - -- tui-logger to create an optional panel to show the normal server logs - -## Branch Graph - -- tui-nodes ? - -## Scrolling - -- tui-scrollview - -## Tree View - -- tui-tree-widget +- `q` - Quit +- `j` - Down +- `k` - Up +- `f` - Page Down +- `b` - Page Up +- `g` - Top/Home +- `G` - Bottom/End diff --git a/crates/cli/src/tui/actor/mod.rs b/crates/cli/src/tui/actor/mod.rs index 855b43c..dc894d6 100644 --- a/crates/cli/src/tui/actor/mod.rs +++ b/crates/cli/src/tui/actor/mod.rs @@ -13,12 +13,14 @@ use ratatui::{ crossterm::event::{self, KeyCode, KeyEventKind}, DefaultTerminal, }; +use tui_scrollview::ScrollViewState; #[derive(Debug)] pub struct Tui { terminal: Option, signal_shutdown: Sender<()>, pub state: State, + scroll_view_state: ScrollViewState, } impl Actor for Tui { type Context = Context; @@ -36,18 +38,17 @@ impl Tui { terminal: None, signal_shutdown, state: State::initial(), + scroll_view_state: ScrollViewState::default(), } } - pub const fn state(&self) -> &State { - &self.state - } - fn draw(&mut self) -> std::io::Result<()> { let t = self.terminal.take(); + let scroll_view_state = &mut self.scroll_view_state; + let state = &self.state; if let Some(mut terminal) = t { terminal.draw(|frame| { - frame.render_widget(self.state(), frame.area()); + frame.render_stateful_widget(state, frame.area(), scroll_view_state); })?; self.terminal = Some(terminal); } else { @@ -56,7 +57,7 @@ impl Tui { Ok(()) } - fn handle_input(&self, ctx: &mut ::Context) -> std::io::Result<()> { + fn handle_input(&mut self, ctx: &mut ::Context) -> std::io::Result<()> { if event::poll(std::time::Duration::from_millis(16))? { if let event::Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Press { @@ -67,8 +68,19 @@ impl Tui { tracing::error!(?err, "Failed to signal shutdown"); } } - KeyCode::Esc => { - // + KeyCode::Char('j') | KeyCode::Down => self.scroll_view_state.scroll_down(), + KeyCode::Char('k') | KeyCode::Up => self.scroll_view_state.scroll_up(), + KeyCode::Char('f') | KeyCode::PageDown => { + self.scroll_view_state.scroll_page_down(); + } + KeyCode::Char('b') | KeyCode::PageUp => { + self.scroll_view_state.scroll_page_up(); + } + KeyCode::Char('g') | KeyCode::Home => { + self.scroll_view_state.scroll_to_top(); + } + KeyCode::Char('G') | KeyCode::End => { + self.scroll_view_state.scroll_to_bottom(); } _ => (), } diff --git a/crates/cli/src/tui/actor/model.rs b/crates/cli/src/tui/actor/model.rs index 6b15a97..0d77fd5 100644 --- a/crates/cli/src/tui/actor/model.rs +++ b/crates/cli/src/tui/actor/model.rs @@ -5,7 +5,7 @@ use ratatui::{ style::Stylize as _, symbols::border, text::Line, - widgets::{block::Title, Block, Paragraph, Widget}, + widgets::{block::Title, Block, Paragraph, StatefulWidget, Widget}, }; use git_next_core::{ @@ -13,6 +13,7 @@ use git_next_core::{ ForgeAlias, RepoAlias, RepoBranches, }; use tracing::info; +use tui_scrollview::ScrollViewState; use std::{collections::BTreeMap, fmt::Display, time::Instant}; @@ -314,8 +315,9 @@ impl RepoState { } } -impl Widget for &State { - fn render(self, area: Rect, buf: &mut Buffer) +impl StatefulWidget for &State { + type State = ScrollViewState; + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) where Self: Sized, { @@ -341,7 +343,7 @@ impl Widget for &State { .centered() .render(interior, buf), ServerState::Configured { forges } => { - ConfiguredAppWidget { forges }.render(interior, buf); + ConfiguredAppWidget { forges }.render(interior, buf, state); } } } diff --git a/crates/cli/src/tui/components/configured_app.rs b/crates/cli/src/tui/components/configured_app.rs index 908b410..114a161 100644 --- a/crates/cli/src/tui/components/configured_app.rs +++ b/crates/cli/src/tui/components/configured_app.rs @@ -3,9 +3,10 @@ use std::collections::BTreeMap; use git_next_core::ForgeAlias; use ratatui::{ buffer::Buffer, - layout::{Direction, Layout, Rect}, - widgets::Widget, + layout::{Direction, Layout, Rect, Size}, + widgets::StatefulWidget, }; +use tui_scrollview::{ScrollView, ScrollViewState}; use crate::tui::actor::ForgeState; @@ -23,11 +24,19 @@ impl<'a> HeightContraintLength for ConfiguredAppWidget<'a> { + 2 // top + bottom borders } } -impl<'a> Widget for ConfiguredAppWidget<'a> { - fn render(self, area: Rect, buf: &mut Buffer) +impl<'a> StatefulWidget for ConfiguredAppWidget<'a> { + type State = ScrollViewState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) where Self: Sized, { + let height = self + .children() + .iter() + .map(HeightContraintLength::height_constraint_length) + .sum::(); + let mut scroll = ScrollView::new(Size::new(area.width - 1, height)); let layout_forge_list = Layout::default() .direction(Direction::Vertical) .constraints( @@ -35,12 +44,14 @@ impl<'a> Widget for ConfiguredAppWidget<'a> { .iter() .map(HeightContraintLength::height_constraint_length), ) - .split(area); + .split(scroll.area()); self.children() .into_iter() .enumerate() - .for_each(|(i, w)| w.render(layout_forge_list[i], buf)); + .for_each(|(i, w)| scroll.render_widget(w, layout_forge_list[i])); + + scroll.render(area, buf, state); } } impl<'a> ConfiguredAppWidget<'a> {