From 9de5e6bbff173bca8cf703fb37e8270e85c61233 Mon Sep 17 00:00:00 2001 From: kjuulh Date: Thu, 2 May 2024 23:47:47 +0200 Subject: [PATCH] feat: left right movement done Signed-off-by: kjuulh --- Cargo.lock | 2 + Cargo.toml | 1 + crates/hyperlog-core/src/state.rs | 1 + crates/hyperlog-tui/Cargo.toml | 4 + crates/hyperlog-tui/src/app.rs | 86 ++++++++ crates/hyperlog-tui/src/components.rs | 289 ++++++++++++++++++++++++++ crates/hyperlog-tui/src/lib.rs | 267 ++++-------------------- crates/hyperlog-tui/src/models.rs | 7 + crates/hyperlog-tui/src/state.rs | 24 +++ crates/hyperlog-tui/src/terminal.rs | 61 ++++++ crates/hyperlog/src/state.rs | 4 +- 11 files changed, 522 insertions(+), 224 deletions(-) create mode 100644 crates/hyperlog-tui/src/app.rs create mode 100644 crates/hyperlog-tui/src/components.rs create mode 100644 crates/hyperlog-tui/src/models.rs create mode 100644 crates/hyperlog-tui/src/state.rs create mode 100644 crates/hyperlog-tui/src/terminal.rs diff --git a/Cargo.lock b/Cargo.lock index a6c7562..94c7aa1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -919,8 +919,10 @@ dependencies = [ "crossterm", "directories", "hyperlog-core", + "itertools", "ratatui", "serde_json", + "similar-asserts", "tokio", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index 6003edd..509e87a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,3 +14,4 @@ clap = { version = "4", features = ["derive", "env"] } dotenv = { version = "0.15" } axum = { version = "0.7" } serde_json = "1.0.116" +itertools = "0.12.1" diff --git a/crates/hyperlog-core/src/state.rs b/crates/hyperlog-core/src/state.rs index 6dff701..f99299f 100644 --- a/crates/hyperlog-core/src/state.rs +++ b/crates/hyperlog-core/src/state.rs @@ -3,6 +3,7 @@ use crate::{ storage::Storage, }; +#[allow(dead_code)] pub struct State { engine: SharedEngine, pub storage: Storage, diff --git a/crates/hyperlog-tui/Cargo.toml b/crates/hyperlog-tui/Cargo.toml index 0826879..1bc5d6e 100644 --- a/crates/hyperlog-tui/Cargo.toml +++ b/crates/hyperlog-tui/Cargo.toml @@ -11,7 +11,11 @@ tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true serde_json.workspace = true +itertools.workspace = true ratatui = "0.26.2" crossterm = { version = "0.27.0", features = ["event-stream"] } directories = "5.0.1" + +[dev-dependencies] +similar-asserts = "1.5.0" diff --git a/crates/hyperlog-tui/src/app.rs b/crates/hyperlog-tui/src/app.rs new file mode 100644 index 0000000..ae8e089 --- /dev/null +++ b/crates/hyperlog-tui/src/app.rs @@ -0,0 +1,86 @@ +use ratatui::{ + prelude::*, + widgets::{Block, Borders, Padding, Paragraph}, +}; + +use crate::{components::GraphExplorer, state::SharedState, Msg}; + +pub struct App<'a> { + state: SharedState, + + graph_explorer: GraphExplorer<'a>, +} + +impl<'a> App<'a> { + pub fn new(state: SharedState, graph_explorer: GraphExplorer<'a>) -> Self { + Self { + state, + graph_explorer, + } + } + + pub fn update(&mut self, msg: Msg) -> anyhow::Result<()> { + tracing::trace!("handling msg: {:?}", msg); + + match msg { + Msg::MoveRight => self.graph_explorer.move_right(), + Msg::MoveLeft => self.graph_explorer.move_left(), + Msg::MoveDown => self.graph_explorer.move_down(), + Msg::MoveUp => self.graph_explorer.move_up(), + } + } +} + +impl<'a> Widget for &mut App<'a> { + fn render(self, area: Rect, buf: &mut Buffer) + where + Self: Sized, + { + StatefulWidget::render( + GraphExplorer::new(self.state.clone()), + area, + buf, + &mut self.graph_explorer.inner, + ) + } +} + +pub fn render_app(frame: &mut Frame, state: &mut App) { + let chunks = + Layout::vertical(vec![Constraint::Length(2), Constraint::Min(0)]).split(frame.size()); + + let heading = Paragraph::new(text::Line::from( + Span::styled("hyperlog", Style::default()).fg(Color::Green), + )); + let block_heading = Block::default().borders(Borders::BOTTOM); + + frame.render_widget(heading.block(block_heading), chunks[0]); + + let Rect { width, height, .. } = chunks[1]; + + let height = height as usize; + let width = width as usize; + + let mut lines = Vec::new(); + for y in 0..height { + if !y % 2 == 0 { + lines.push(text::Line::default()); + } else { + lines.push(text::Line::raw(" ~ ".repeat(width / 3))); + } + } + let _background = Paragraph::new(lines); + + let _bg_block = Block::default() + .fg(Color::DarkGray) + .bold() + .padding(Padding { + left: 4, + right: 4, + top: 2, + bottom: 2, + }); + //frame.render_widget(background.block(bg_block), chunks[1]); + + frame.render_widget(state, chunks[1]) +} diff --git a/crates/hyperlog-tui/src/components.rs b/crates/hyperlog-tui/src/components.rs new file mode 100644 index 0000000..57b369e --- /dev/null +++ b/crates/hyperlog-tui/src/components.rs @@ -0,0 +1,289 @@ +use std::{collections::HashMap, ops::Deref}; + +use anyhow::Result; +use hyperlog_core::log::GraphItem; +use itertools::Itertools; +use ratatui::{prelude::*, widgets::*}; + +use crate::state::SharedState; + +pub struct GraphExplorer<'a> { + state: SharedState, + + pub inner: GraphExplorerState<'a>, +} + +pub struct GraphExplorerState<'a> { + current_path: Option<&'a str>, + current_postition: Vec, + + graph: Option, +} + +impl GraphExplorer<'_> { + pub fn new(state: SharedState) -> Self { + Self { + state, + inner: GraphExplorerState::<'_> { + current_path: None, + current_postition: Vec::new(), + graph: None, + }, + } + } + + pub fn update_graph(&mut self) -> Result<&mut Self> { + let graph = self + .state + .querier + .get( + "something", + self.inner + .current_path + .map(|p| p.split('.').collect::>()) + .unwrap_or_default(), + ) + .ok_or(anyhow::anyhow!("graph should've had an item"))?; + + self.inner.graph = Some(graph); + + Ok(self) + } + + fn linearize_graph(&self) -> Option { + self.inner.graph.clone().map(|g| g.into()) + } + + /// Will only incrmeent to the next level + /// + /// Current: 0.1.0 + /// Available: 0.1.0.[0,1,2] + /// Choses: 0.1.0.0 else nothing + pub(crate) fn move_right(&mut self) -> Result<()> { + if let Some(graph) = self.linearize_graph() { + let position_items = &self.inner.current_postition; + + if let Some(next_item) = graph.next_right(position_items) { + self.inner.current_postition.push(next_item.index); + tracing::trace!("found next item: {:?}", self.inner.current_postition); + } + } + + Ok(()) + } + + /// Will only incrmeent to the next level + /// + /// Current: 0.1.0 + /// Available: 0.[0.1.2].0 + /// Choses: 0.1 else nothing + pub(crate) fn move_left(&mut self) -> Result<()> { + if let Some(last) = self.inner.current_postition.pop() { + tracing::trace!( + "found last item: {:?}, popped: {}", + self.inner.current_postition, + last + ); + } + + Ok(()) + } + + pub(crate) fn move_up(&self) -> Result<()> { + Ok(()) + } + + pub(crate) fn move_down(&self) -> Result<()> { + Ok(()) + } +} + +impl<'a> StatefulWidget for GraphExplorer<'a> { + type State = GraphExplorerState<'a>; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let Rect { height, .. } = area; + let height = height as usize; + + if let Some(graph) = &state.graph { + if let Ok(graph) = serde_json::to_string_pretty(graph) { + let lines = graph + .split('\n') + .take(height) + .map(Line::raw) + .collect::>(); + + let para = Paragraph::new(lines); + + para.render(area, buf); + } + } + } +} + +#[derive(PartialEq, Eq, Debug, Clone)] +struct MovementGraphItem { + index: usize, + name: String, + values: MovementGraph, +} + +#[derive(Default, PartialEq, Eq, Debug, Clone)] +struct MovementGraph { + items: Vec, +} + +impl MovementGraph { + fn next_right(&self, items: &[usize]) -> Option { + match items.split_first() { + Some((current_index, rest)) => match self.items.get(*current_index) { + Some(next_item) => next_item.values.next_right(rest), + None => None, + }, + None => self.items.first().cloned(), + } + } +} + +impl From> for MovementGraph { + fn from(value: Box) -> Self { + value.deref().clone().into() + } +} + +impl From for MovementGraph { + fn from(value: GraphItem) -> Self { + let mut graph = MovementGraph::default(); + + match value { + GraphItem::User(sections) | GraphItem::Section(sections) => { + let graph_items = sections + .iter() + .sorted_by(|(a, _), (b, _)| Ord::cmp(a, b)) + .enumerate() + .map(|(i, (key, value))| MovementGraphItem { + index: i, + name: key.clone(), + values: value.clone().into(), + }) + .collect::>(); + + graph.items = graph_items; + } + GraphItem::Item { .. } => {} + } + + graph + } +} + +#[cfg(test)] +mod test { + use std::collections::BTreeMap; + + use hyperlog_core::log::{GraphItem, ItemState}; + use similar_asserts::assert_eq; + + use crate::components::MovementGraphItem; + + use super::MovementGraph; + + /// Lets say we've got a graph + /// ```json + /// { + /// "type": "user", + /// "something": { + /// "type": "section", + /// "something": { + /// "type": "section", + /// "something-else": { + /// "type": "section", + /// "blabla": { + /// "type": "section" + /// } + /// } + /// } + /// } + /// } + /// ``` + /// We can get something out like + /// [ + /// 0: {key: something, values: [ + /// 0: {key: something, values: [ + /// ... + /// ]} + /// ]} + /// ] + #[test] + fn test_can_transform_to_movement_graph() { + let graph = GraphItem::User(BTreeMap::from([( + "0".to_string(), + Box::new(GraphItem::Section(BTreeMap::from([ + ( + "00".to_string(), + Box::new(GraphItem::Section(BTreeMap::new())), + ), + ( + "01".to_string(), + Box::new(GraphItem::Section(BTreeMap::from([ + ( + "010".to_string(), + Box::new(GraphItem::Item { + title: "some-title".into(), + description: "some-desc".into(), + state: ItemState::NotDone, + }), + ), + ( + "011".to_string(), + Box::new(GraphItem::Item { + title: "some-title".into(), + description: "some-desc".into(), + state: ItemState::NotDone, + }), + ), + ]))), + ), + ]))), + )])); + + let actual: MovementGraph = graph.into(); + + assert_eq!( + MovementGraph { + items: vec![MovementGraphItem { + index: 0, + name: "0".into(), + values: MovementGraph { + items: vec![ + MovementGraphItem { + index: 0, + name: "00".into(), + values: MovementGraph::default() + }, + MovementGraphItem { + index: 1, + name: "01".into(), + values: MovementGraph { + items: vec![ + MovementGraphItem { + index: 0, + name: "010".into(), + values: MovementGraph::default(), + }, + MovementGraphItem { + index: 1, + name: "011".into(), + values: MovementGraph::default(), + }, + ] + } + }, + ] + } + }] + }, + actual + ); + } +} diff --git a/crates/hyperlog-tui/src/lib.rs b/crates/hyperlog-tui/src/lib.rs index b744dfb..e3314f9 100644 --- a/crates/hyperlog-tui/src/lib.rs +++ b/crates/hyperlog-tui/src/lib.rs @@ -1,80 +1,23 @@ -use std::{ - io::{self, Stdout}, - ops::{Deref, DerefMut}, - time::Duration, -}; +use std::{io::Stdout, time::Duration}; use anyhow::{Context, Result}; -use crossterm::{ - event::{self, Event, KeyCode}, - execute, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, -}; -use hyperlog_core::{log::GraphItem, state::State}; -use ratatui::{backend::CrosstermBackend, prelude::*, widgets::*, Frame, Terminal}; +use app::{render_app, App}; +use components::GraphExplorer; +use crossterm::event::{self, Event, KeyCode}; +use hyperlog_core::state::State; +use models::Msg; +use ratatui::{backend::CrosstermBackend, Terminal}; -use crate::state::SharedState; +use crate::{state::SharedState, terminal::TerminalInstance}; -struct TerminalInstance { - terminal: Terminal>, -} +pub mod models; -impl TerminalInstance { - fn new() -> Result { - Ok(Self { - terminal: setup_terminal().context("setup failed")?, - }) - } -} +pub(crate) mod app; +pub(crate) mod components; +pub(crate) mod state; -impl Deref for TerminalInstance { - type Target = Terminal>; - - fn deref(&self) -> &Self::Target { - &self.terminal - } -} - -impl DerefMut for TerminalInstance { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.terminal - } -} - -impl Drop for TerminalInstance { - fn drop(&mut self) { - if let Err(e) = restore_terminal(&mut self.terminal).context("restore terminal failed") { - tracing::error!("failed to restore terminal: {}", e); - } - } -} - -mod state { - use std::{ops::Deref, sync::Arc}; - - use hyperlog_core::state::State; - - #[derive(Clone)] - pub struct SharedState { - state: Arc, - } - - impl Deref for SharedState { - type Target = State; - - fn deref(&self) -> &Self::Target { - &self.state - } - } - - impl From for SharedState { - fn from(value: State) -> Self { - Self { - state: Arc::new(value), - } - } - } -} +mod logging; +mod terminal; pub async fn execute(state: State) -> Result<()> { tracing::debug!("starting hyperlog tui"); @@ -90,35 +33,6 @@ pub async fn execute(state: State) -> Result<()> { Ok(()) } -pub struct App<'a> { - state: SharedState, - - graph_explorer: GraphExplorer<'a>, -} - -impl<'a> App<'a> { - pub fn new(state: SharedState, graph_explorer: GraphExplorer<'a>) -> Self { - Self { - state, - graph_explorer, - } - } -} - -impl<'a> Widget for &mut App<'a> { - fn render(self, area: Rect, buf: &mut Buffer) - where - Self: Sized, - { - StatefulWidget::render( - GraphExplorer::new(self.state.clone()), - area, - buf, - &mut self.graph_explorer.inner, - ) - } -} - fn run(terminal: &mut Terminal>, state: SharedState) -> Result<()> { let mut graph_explorer = GraphExplorer::new(state.clone()); graph_explorer.update_graph()?; @@ -126,142 +40,51 @@ fn run(terminal: &mut Terminal>, state: SharedState) -> let mut app = App::new(state.clone(), graph_explorer); loop { - terminal.draw(|f| crate::render_app(f, &mut app))?; - if should_quit()? { + terminal.draw(|f| render_app(f, &mut app))?; + + if update(terminal, &mut app)?.should_quit() { break; } } Ok(()) } -fn render_app(frame: &mut Frame, state: &mut App) { - let chunks = - Layout::vertical(vec![Constraint::Length(2), Constraint::Min(0)]).split(frame.size()); +pub struct UpdateConclusion(bool); - let heading = Paragraph::new(text::Line::from( - Span::styled("hyperlog", Style::default()).fg(Color::Green), - )); - let block_heading = Block::default().borders(Borders::BOTTOM); - - frame.render_widget(heading.block(block_heading), chunks[0]); - - let Rect { width, height, .. } = chunks[1]; - - let height = height as usize; - let width = width as usize; - - let mut lines = Vec::new(); - for y in 0..height { - if !y % 2 == 0 { - lines.push(text::Line::default()); - } else { - lines.push(text::Line::raw(" ~ ".repeat(width / 3))); - } - } - let background = Paragraph::new(lines); - - let bg_block = Block::default() - .fg(Color::DarkGray) - .bold() - .padding(Padding { - left: 4, - right: 4, - top: 2, - bottom: 2, - }); - //frame.render_widget(background.block(bg_block), chunks[1]); - - frame.render_widget(state, chunks[1]) -} - -struct GraphExplorer<'a> { - state: SharedState, - - inner: GraphExplorerState<'a>, -} - -struct GraphExplorerState<'a> { - current_path: Option<&'a str>, - - graph: Option, -} - -impl GraphExplorer<'_> { - pub fn new(state: SharedState) -> Self { - Self { - state, - inner: GraphExplorerState::<'_> { - current_path: None, - graph: None, - }, - } +impl UpdateConclusion { + pub fn new(should_quit: bool) -> Self { + Self(should_quit) } - pub fn update_graph(&mut self) -> Result<&mut Self> { - let graph = self - .state - .querier - .get( - "something", - self.inner - .current_path - .map(|p| p.split('.').collect::>()) - .unwrap_or_default(), - ) - .ok_or(anyhow::anyhow!("graph should've had an item"))?; - - self.inner.graph = Some(graph); - - Ok(self) + pub fn should_quit(self) -> bool { + self.0 } } -impl<'a> StatefulWidget for GraphExplorer<'a> { - type State = GraphExplorerState<'a>; - - fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { - let Rect { height, .. } = area; - let height = height as usize; - - if let Some(graph) = &state.graph { - if let Ok(graph) = serde_json::to_string_pretty(graph) { - let lines = graph - .split('\n') - .take(height) - .map(Line::raw) - .collect::>(); - - let para = Paragraph::new(lines); - - para.render(area, buf); +fn update( + _terminal: &mut Terminal>, + app: &mut App, +) -> Result { + if event::poll(Duration::from_millis(250)).context("event poll failed")? { + if let Event::Key(key) = event::read().context("event read failed")? { + match key.code { + KeyCode::Char('q') => return Ok(UpdateConclusion::new(true)), + KeyCode::Char('l') => { + app.update(Msg::MoveRight)?; + } + KeyCode::Char('h') => { + app.update(Msg::MoveLeft)?; + } + KeyCode::Char('j') => { + app.update(Msg::MoveDown)?; + } + KeyCode::Char('k') => { + app.update(Msg::MoveUp)?; + } + _ => {} } } } -} -fn should_quit() -> Result { - if event::poll(Duration::from_millis(250)).context("event poll failed")? { - if let Event::Key(key) = event::read().context("event read failed")? { - return Ok(KeyCode::Char('q') == key.code); - } - } - Ok(false) + Ok(UpdateConclusion::new(false)) } - -fn setup_terminal() -> Result>> { - let mut stdout = io::stdout(); - enable_raw_mode().context("failed to enable raw mode")?; - execute!(stdout, EnterAlternateScreen).context("unable to enter alternate screen")?; - Terminal::new(CrosstermBackend::new(stdout)).context("creating terminal failed") -} - -/// Restore the terminal. This is where you disable raw mode, leave the alternate screen, and show -/// the cursor. -fn restore_terminal(terminal: &mut Terminal>) -> Result<()> { - disable_raw_mode().context("failed to disable raw mode")?; - execute!(terminal.backend_mut(), LeaveAlternateScreen) - .context("unable to switch to main screen")?; - terminal.show_cursor().context("unable to show cursor") -} - -mod logging; diff --git a/crates/hyperlog-tui/src/models.rs b/crates/hyperlog-tui/src/models.rs new file mode 100644 index 0000000..6a39256 --- /dev/null +++ b/crates/hyperlog-tui/src/models.rs @@ -0,0 +1,7 @@ +#[derive(Debug)] +pub enum Msg { + MoveRight, + MoveLeft, + MoveDown, + MoveUp, +} diff --git a/crates/hyperlog-tui/src/state.rs b/crates/hyperlog-tui/src/state.rs new file mode 100644 index 0000000..38188d1 --- /dev/null +++ b/crates/hyperlog-tui/src/state.rs @@ -0,0 +1,24 @@ +use std::{ops::Deref, sync::Arc}; + +use hyperlog_core::state::State; + +#[derive(Clone)] +pub struct SharedState { + state: Arc, +} + +impl Deref for SharedState { + type Target = State; + + fn deref(&self) -> &Self::Target { + &self.state + } +} + +impl From for SharedState { + fn from(value: State) -> Self { + Self { + state: Arc::new(value), + } + } +} diff --git a/crates/hyperlog-tui/src/terminal.rs b/crates/hyperlog-tui/src/terminal.rs new file mode 100644 index 0000000..5559fec --- /dev/null +++ b/crates/hyperlog-tui/src/terminal.rs @@ -0,0 +1,61 @@ +use std::{ + io::{self, Stdout}, + ops::{Deref, DerefMut}, +}; + +use anyhow::{Context, Result}; +use crossterm::{ + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{backend::CrosstermBackend, Terminal}; + +pub struct TerminalInstance { + terminal: Terminal>, +} + +impl TerminalInstance { + pub fn new() -> Result { + Ok(Self { + terminal: setup_terminal().context("setup failed")?, + }) + } +} + +impl Deref for TerminalInstance { + type Target = Terminal>; + + fn deref(&self) -> &Self::Target { + &self.terminal + } +} + +impl DerefMut for TerminalInstance { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.terminal + } +} + +impl Drop for TerminalInstance { + fn drop(&mut self) { + if let Err(e) = restore_terminal(&mut self.terminal).context("restore terminal failed") { + tracing::error!("failed to restore terminal: {}", e); + } + } +} + +fn setup_terminal() -> Result>> { + let mut stdout = io::stdout(); + enable_raw_mode().context("failed to enable raw mode")?; + execute!(stdout, EnterAlternateScreen).context("unable to enter alternate screen")?; + Terminal::new(CrosstermBackend::new(stdout)).context("creating terminal failed") +} + +/// Restore the terminal. This is where you disable raw mode, leave the alternate screen, and show +/// the cursor. +fn restore_terminal(terminal: &mut Terminal>) -> Result<()> { + disable_raw_mode().context("failed to disable raw mode")?; + execute!(terminal.backend_mut(), LeaveAlternateScreen) + .context("unable to switch to main screen")?; + terminal.show_cursor().context("unable to show cursor") +} diff --git a/crates/hyperlog/src/state.rs b/crates/hyperlog/src/state.rs index f008f4a..68a7309 100644 --- a/crates/hyperlog/src/state.rs +++ b/crates/hyperlog/src/state.rs @@ -15,7 +15,7 @@ impl Deref for SharedState { } pub struct State { - pub db: Pool, + pub _db: Pool, } impl State { @@ -32,6 +32,6 @@ impl State { let _ = sqlx::query("SELECT 1;").fetch_one(&db).await?; - Ok(Self { db }) + Ok(Self { _db: db }) } }