feat: left right movement done

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
Kasper Juul Hermansen 2024-05-02 23:47:47 +02:00
parent d7e21a256d
commit 9de5e6bbff
Signed by: kjuulh
GPG Key ID: 57B6E1465221F912
11 changed files with 522 additions and 224 deletions

2
Cargo.lock generated
View File

@ -919,8 +919,10 @@ dependencies = [
"crossterm", "crossterm",
"directories", "directories",
"hyperlog-core", "hyperlog-core",
"itertools",
"ratatui", "ratatui",
"serde_json", "serde_json",
"similar-asserts",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",

View File

@ -14,3 +14,4 @@ clap = { version = "4", features = ["derive", "env"] }
dotenv = { version = "0.15" } dotenv = { version = "0.15" }
axum = { version = "0.7" } axum = { version = "0.7" }
serde_json = "1.0.116" serde_json = "1.0.116"
itertools = "0.12.1"

View File

@ -3,6 +3,7 @@ use crate::{
storage::Storage, storage::Storage,
}; };
#[allow(dead_code)]
pub struct State { pub struct State {
engine: SharedEngine, engine: SharedEngine,
pub storage: Storage, pub storage: Storage,

View File

@ -11,7 +11,11 @@ tokio.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
serde_json.workspace = true serde_json.workspace = true
itertools.workspace = true
ratatui = "0.26.2" ratatui = "0.26.2"
crossterm = { version = "0.27.0", features = ["event-stream"] } crossterm = { version = "0.27.0", features = ["event-stream"] }
directories = "5.0.1" directories = "5.0.1"
[dev-dependencies]
similar-asserts = "1.5.0"

View File

@ -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])
}

View File

@ -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<usize>,
graph: Option<GraphItem>,
}
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::<Vec<_>>())
.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<MovementGraph> {
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::<Vec<_>>();
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<MovementGraphItem>,
}
impl MovementGraph {
fn next_right(&self, items: &[usize]) -> Option<MovementGraphItem> {
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<Box<GraphItem>> for MovementGraph {
fn from(value: Box<GraphItem>) -> Self {
value.deref().clone().into()
}
}
impl From<GraphItem> 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::<Vec<_>>();
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
);
}
}

View File

@ -1,80 +1,23 @@
use std::{ use std::{io::Stdout, time::Duration};
io::{self, Stdout},
ops::{Deref, DerefMut},
time::Duration,
};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use crossterm::{ use app::{render_app, App};
event::{self, Event, KeyCode}, use components::GraphExplorer;
execute, use crossterm::event::{self, Event, KeyCode};
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, use hyperlog_core::state::State;
}; use models::Msg;
use hyperlog_core::{log::GraphItem, state::State}; use ratatui::{backend::CrosstermBackend, Terminal};
use ratatui::{backend::CrosstermBackend, prelude::*, widgets::*, Frame, Terminal};
use crate::state::SharedState; use crate::{state::SharedState, terminal::TerminalInstance};
struct TerminalInstance { pub mod models;
terminal: Terminal<CrosstermBackend<Stdout>>,
}
impl TerminalInstance { pub(crate) mod app;
fn new() -> Result<Self> { pub(crate) mod components;
Ok(Self { pub(crate) mod state;
terminal: setup_terminal().context("setup failed")?,
})
}
}
impl Deref for TerminalInstance { mod logging;
type Target = Terminal<CrosstermBackend<Stdout>>; mod 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<State>,
}
impl Deref for SharedState {
type Target = State;
fn deref(&self) -> &Self::Target {
&self.state
}
}
impl From<State> for SharedState {
fn from(value: State) -> Self {
Self {
state: Arc::new(value),
}
}
}
}
pub async fn execute(state: State) -> Result<()> { pub async fn execute(state: State) -> Result<()> {
tracing::debug!("starting hyperlog tui"); tracing::debug!("starting hyperlog tui");
@ -90,35 +33,6 @@ pub async fn execute(state: State) -> Result<()> {
Ok(()) 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<CrosstermBackend<Stdout>>, state: SharedState) -> Result<()> { fn run(terminal: &mut Terminal<CrosstermBackend<Stdout>>, state: SharedState) -> Result<()> {
let mut graph_explorer = GraphExplorer::new(state.clone()); let mut graph_explorer = GraphExplorer::new(state.clone());
graph_explorer.update_graph()?; graph_explorer.update_graph()?;
@ -126,142 +40,51 @@ fn run(terminal: &mut Terminal<CrosstermBackend<Stdout>>, state: SharedState) ->
let mut app = App::new(state.clone(), graph_explorer); let mut app = App::new(state.clone(), graph_explorer);
loop { loop {
terminal.draw(|f| crate::render_app(f, &mut app))?; terminal.draw(|f| render_app(f, &mut app))?;
if should_quit()? {
if update(terminal, &mut app)?.should_quit() {
break; break;
} }
} }
Ok(()) Ok(())
} }
fn render_app(frame: &mut Frame, state: &mut App) { pub struct UpdateConclusion(bool);
let chunks =
Layout::vertical(vec![Constraint::Length(2), Constraint::Min(0)]).split(frame.size());
let heading = Paragraph::new(text::Line::from( impl UpdateConclusion {
Span::styled("hyperlog", Style::default()).fg(Color::Green), pub fn new(should_quit: bool) -> Self {
)); Self(should_quit)
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<GraphItem>,
}
impl GraphExplorer<'_> {
pub fn new(state: SharedState) -> Self {
Self {
state,
inner: GraphExplorerState::<'_> {
current_path: None,
graph: None,
},
}
} }
pub fn update_graph(&mut self) -> Result<&mut Self> { pub fn should_quit(self) -> bool {
let graph = self self.0
.state
.querier
.get(
"something",
self.inner
.current_path
.map(|p| p.split('.').collect::<Vec<_>>())
.unwrap_or_default(),
)
.ok_or(anyhow::anyhow!("graph should've had an item"))?;
self.inner.graph = Some(graph);
Ok(self)
} }
} }
impl<'a> StatefulWidget for GraphExplorer<'a> { fn update(
type State = GraphExplorerState<'a>; _terminal: &mut Terminal<CrosstermBackend<Stdout>>,
app: &mut App,
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { ) -> Result<UpdateConclusion> {
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::<Vec<_>>();
let para = Paragraph::new(lines);
para.render(area, buf);
}
}
}
}
fn should_quit() -> Result<bool> {
if event::poll(Duration::from_millis(250)).context("event poll failed")? { if event::poll(Duration::from_millis(250)).context("event poll failed")? {
if let Event::Key(key) = event::read().context("event read failed")? { if let Event::Key(key) = event::read().context("event read failed")? {
return Ok(KeyCode::Char('q') == key.code); 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)?;
}
_ => {}
}
} }
} }
Ok(false)
}
fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> { Ok(UpdateConclusion::new(false))
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<CrosstermBackend<Stdout>>) -> 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;

View File

@ -0,0 +1,7 @@
#[derive(Debug)]
pub enum Msg {
MoveRight,
MoveLeft,
MoveDown,
MoveUp,
}

View File

@ -0,0 +1,24 @@
use std::{ops::Deref, sync::Arc};
use hyperlog_core::state::State;
#[derive(Clone)]
pub struct SharedState {
state: Arc<State>,
}
impl Deref for SharedState {
type Target = State;
fn deref(&self) -> &Self::Target {
&self.state
}
}
impl From<State> for SharedState {
fn from(value: State) -> Self {
Self {
state: Arc::new(value),
}
}
}

View File

@ -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<CrosstermBackend<Stdout>>,
}
impl TerminalInstance {
pub fn new() -> Result<Self> {
Ok(Self {
terminal: setup_terminal().context("setup failed")?,
})
}
}
impl Deref for TerminalInstance {
type Target = Terminal<CrosstermBackend<Stdout>>;
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<Terminal<CrosstermBackend<Stdout>>> {
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<CrosstermBackend<Stdout>>) -> 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")
}

View File

@ -15,7 +15,7 @@ impl Deref for SharedState {
} }
pub struct State { pub struct State {
pub db: Pool<Postgres>, pub _db: Pool<Postgres>,
} }
impl State { impl State {
@ -32,6 +32,6 @@ impl State {
let _ = sqlx::query("SELECT 1;").fetch_one(&db).await?; let _ = sqlx::query("SELECT 1;").fetch_one(&db).await?;
Ok(Self { db }) Ok(Self { _db: db })
} }
} }