diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..7d21619 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.aarch-apple-darwin] +rustflags = ["-C", "link-args=-L/opt/homebrew/lib"] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..75ad7e6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +Cargo.lock +/logs +/target +.DS_STORE +*.dot +*.log diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..d07ccd9 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "dot-graph"] + path = dot-graph + url = https://github.com/furiosa-ai/dot-graph.git + branch = main diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..798f6eb --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "dot-viewer" +version = "0.1.0" +authors = ["FuriosaAI, Inc."] +description = "A viewer/debugger for large DAGs in Vim-like TUI" +readme = "README.md" +keywords = ["graph", "dag", "dot", "visualize", "tui"] +license = "MIT" +repository = "https://github.com/furiosa-ai/dot-viewer" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +dot-graph = { path = "dot-graph" } +tui = "0.19.0" +tui-tree-widget = "0.11.0" +crossterm = "0.25" +clap = { version = "4.1.1", features = ["derive"] } +trie-rs = "0.1.1" +fuzzy-matcher = "0.3.7" +html_parser = "0.6.3" +thiserror = "1.0.38" +regex = "1.7.1" +rayon = "1.6.1" +better-panic = "0.3.0" +log = "0.4.17" +simplelog = "0.12.0" +chrono = "0.4.23" diff --git a/README.md b/README.md new file mode 100644 index 0000000..f6dd737 --- /dev/null +++ b/README.md @@ -0,0 +1,158 @@ +# dot-viewer + +`dot-viewer` is a dot-format graph debugger in TUI, inspired by Vim. + +# 1. Getting Started + +## a. Prerequisites + +### i. Graphviz + +`dot-viewer` parses a dot format file using C bindings to [Graphviz (v7.0.6)](https://gitlab.com/graphviz/graphviz/-/tree/7.0.6/lib). + +The system environment should be able to find and include the following header files. + +```C +#include +#include +``` + +#### Option 1. Installing Graphviz from Package Manager + +Coming from Linux, +```console +$ sudo apt install graphviz-dev +``` + +And coming from vanilla Ubuntu, you may want to install these too. +```console +$ sudo apt install build-essentials cmake +$ sudo apt install clang +``` + +Coming from Mac, +```console +$ brew install graphviz +``` + +And coming from Apple Silicon Mac, and [add an environment variable](https://apple.stackexchange.com/questions/414622/installing-a-c-c-library-with-homebrew-on-m1-macs), +```shell +export CPATH=/opt/homebrew/include +``` + +#### Option 2. Building Graphviz from Source + +Or, try building from the source code following the [guide](https://graphviz.org/download/source/). + +### ii. xdot.py + +`dot-viewer` renders a subgraph with `xdot.py`, an interactive dot visualizer. + +It is required that [xdot is executable in command-line](https://github.com/jrfonseca/xdot.py) beforehand such that the following works. +```console +$ xdot *.dot +``` + +## b. Installation + +### i. Initialize + +First initialize and update the submodule `dot-graph`. + +```console +$ git submodule init +$ git submodule update +``` + +### ii. Run + +Then run crate. + +```console +$ cargo run --release [path-to-dot-file] +``` + +This will open a TUI screen on the terminal. + +# 2. Features + +With `dot-viewer`, users may + +**traverse the graph in TUI** using, +- goto next/prev node of the currently selected node +- fuzzy search on node name +- regex search on node name and attributes + + +**make and export subgraphs** using, +- subgraph tree selection +- applying filter on search matches +- neighboring `n` nodes of the currently selected node + +## Keybindings + +### General + +Key | Command | Actions +--- | --- | --- +`q` | | quit `dot-viewer` +   | `:help` | show help +`esc` |   | go back to the main screen + +### Mode Switches + +Key | From | To +--- | --- | --- +`esc` | All | Normal +`/` | Normal | Fuzzy Search +`r` | Normal | Regex Search +`:` | Normal | Command + +### Normal + +Key | Actions +--- | --- +`c` | close the current tab(view) +`h/l` | move focus between current, prevs, nexts list +`j/k` | traverse in focused list +`n/N` | move between matched nodes +`gg` | move to the topmost node in focused list +`G` | move to the bottom node in focused list +`tab`/`backtab` | move between tabs + +### Search +Key | Actions +--- | --- +`tab` | autocomplete search keyword +`enter` | apply search + +e.g., in fuzzy search mode, `/g1_s14_t100` and in regex search mode, `r\(H: ., D: .\)` + +### Command + +Key | Command | Actions +--- | --- | --- +  | `filter` | apply filter on current matches, opening a new tab(view) +  | `neighbors [depth]` | get up to `depth` neighbors of the current node in a new tab(view) +  | `export [(opt) filename]` | export the current tab(view) to dot +  | `xdot [(opt) filename]` | launch `xdot` with the filename or `exports/current.dot` by default +  | `subgraph` | open a popup showing subgraph tree +`tab` |   | autocomplete command +`enter` |   | execute command + +All exported files are saved in `exports` directory in the project root. + +Most recently exported file is copied in `exports/current.dot`. + +### Subgraph Popup + +Key | Actions +--- | --- +`h/j/k/l` | traverse the tree +`enter` | change root to the selected subgraph, opening a new tab(view) + +### Help Popup + +Key | Actions +--- | --- +`h/j/k/l` | traverse help messages diff --git a/dot-graph b/dot-graph new file mode 160000 index 0000000..f463a2d --- /dev/null +++ b/dot-graph @@ -0,0 +1 @@ +Subproject commit f463a2d2fbd4cc50bf9f926b9d75ee4e0b339f5d diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..c6b1580 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,4 @@ +edition = "2021" + +# https://github.com/rust-lang/rustfmt/blob/master/Configurations.md#use_small_heuristics +use_small_heuristics = "Max" diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..24c36a0 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,29 @@ +mod terminal; +mod ui; +mod viewer; + +use std::error::Error; +use std::fs; + +use chrono::prelude::*; +use clap::Parser; +use simplelog::{Config, LevelFilter, WriteLogger}; + +use terminal::launch; + +#[derive(Parser, Default, Debug)] +struct Cli { + path: String, +} + +fn main() -> Result<(), Box> { + let args = Cli::parse(); + + fs::create_dir_all("./logs")?; + let file = fs::File::create(format!("logs/log_{}.log", Local::now()))?; + WriteLogger::init(LevelFilter::Info, Config::default(), file)?; + + launch(args.path)?; + + Ok(()) +} diff --git a/src/terminal.rs b/src/terminal.rs new file mode 100644 index 0000000..5e38a75 --- /dev/null +++ b/src/terminal.rs @@ -0,0 +1,81 @@ +use crate::{ui, viewer::App}; + +use std::io::Stdout; +use std::{error::Error, io}; + +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use log::error; +use tui::{ + backend::{Backend, CrosstermBackend}, + Terminal, +}; + +pub fn launch(path: String) -> Result<(), Box> { + // setup terminal + let mut terminal = setup()?; + + // create and run app + let app = App::new(&path).map_err(|_| { + let _ = cleanup(); + + Box::::from("user should provide path to a valid dot file") + })?; + let _ = run(&mut terminal, app); + + // restore terminal + cleanup()?; + + Ok(()) +} + +fn setup() -> Result>, Box> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let terminal = Terminal::new(backend)?; + + // setup panic hook to ensure that the terminal is restored even on panics + setup_panic_hook(); + + Ok(terminal) +} + +fn setup_panic_hook() { + let panic_handler = better_panic::Settings::auto().create_panic_handler(); + std::panic::set_hook(Box::new(move |info| { + let _ = cleanup(); + + error!("dot-viewer {}", info); + + panic_handler(info); + })); +} + +fn run(terminal: &mut Terminal, mut app: App) -> io::Result<()> { + loop { + terminal.draw(|f| ui::draw_app(f, &mut app))?; + + if let Event::Key(key) = event::read()? { + app.key(key); + } + + if app.quit { + break; + } + } + + Ok(()) +} + +fn cleanup() -> Result<(), Box> { + let mut stdout = io::stdout(); + execute!(stdout, LeaveAlternateScreen, DisableMouseCapture)?; + disable_raw_mode()?; + + Ok(()) +} diff --git a/src/ui/app.rs b/src/ui/app.rs new file mode 100644 index 0000000..9a48883 --- /dev/null +++ b/src/ui/app.rs @@ -0,0 +1,36 @@ +use crate::ui::{input::draw_input, popup::draw_popup, tabs::draw_tabs}; +use crate::viewer::{App, Mode}; + +use tui::{ + backend::Backend, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + widgets::{Block, BorderType, Borders}, + Frame, +}; + +pub(crate) fn draw_app(f: &mut Frame, app: &mut App) { + let size = f.size(); + + let block = Block::default() + .borders(Borders::ALL) + .title("Dot-Viewer (v0.1.0)") + .title_alignment(Alignment::Center) + .border_type(BorderType::Rounded); + + f.render_widget(block, size); + + match &app.mode { + Mode::Normal | Mode::Command | Mode::Search(_) => draw_main(f, size, app), + Mode::Popup(_) => draw_popup(f, size, app), + } +} + +fn draw_main(f: &mut Frame, size: Rect, app: &mut App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(90), Constraint::Percentage(10)].as_ref()) + .split(size); + + draw_tabs(f, chunks[0], app); + draw_input(f, chunks[1], app); +} diff --git a/src/ui/input.rs b/src/ui/input.rs new file mode 100644 index 0000000..4020906 --- /dev/null +++ b/src/ui/input.rs @@ -0,0 +1,67 @@ +use crate::ui::surrounding_block; +use crate::viewer::{App, Mode, SearchMode}; + +use tui::{ + backend::Backend, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + widgets::Paragraph, + Frame, +}; + +pub(super) fn draw_input(f: &mut Frame, chunk: Rect, app: &mut App) { + let title = match &app.mode { + Mode::Normal => "Normal", + Mode::Command => "Command", + Mode::Search(smode) => match smode { + SearchMode::Fuzzy => "Fuzzy Search", + SearchMode::Regex => "Regex Search", + }, + _ => unreachable!(), + }; + + let block = surrounding_block( + title.to_string(), + matches!(app.mode, Mode::Command) || matches!(app.mode, Mode::Search(_)), + ); + + f.render_widget(block, chunk); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) + .split(chunk); + + draw_result(f, chunks[0], app); + draw_form(f, chunks[1], app); +} + +fn draw_result(f: &mut Frame, chunk: Rect, app: &mut App) { + let (msg, color) = match &app.result { + Ok(succ) => (succ.to_string(), Color::Green), + Err(err) => (err.to_string(), Color::Red), + }; + + if !msg.is_empty() { + let msg = + Paragraph::new(msg).style(Style::default().fg(color).add_modifier(Modifier::BOLD)); + f.render_widget(msg, chunk); + } +} + +fn draw_form(f: &mut Frame, chunk: Rect, app: &mut App) { + let input = Paragraph::new(app.input.key.clone()).style(match &app.mode { + Mode::Normal => Style::default(), + Mode::Command | Mode::Search(_) => Style::default().fg(Color::Yellow), + _ => unreachable!(), + }); + f.render_widget(input, chunk); + + // cursor + match &app.mode { + Mode::Normal => {} + Mode::Command | Mode::Search(_) => f.set_cursor(chunk.x + app.input.cursor as u16, chunk.y), + _ => unreachable!(), + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..2b12c9c --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,46 @@ +mod app; +mod input; +mod popup; +mod tabs; +mod utils; +mod view; + +use tui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Style}, + widgets::{Block, Borders}, +}; + +pub(crate) use crate::ui::app::draw_app; + +pub(super) fn surrounding_block(title: String, highlight: bool) -> Block<'static> { + let color = if highlight { Color::Yellow } else { Color::White }; + + Block::default().borders(Borders::ALL).border_style(Style::default().fg(color)).title(title) +} + +pub(super) fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ] + .as_ref(), + ) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints( + [ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ] + .as_ref(), + ) + .split(popup_layout[1])[1] +} diff --git a/src/ui/popup.rs b/src/ui/popup.rs new file mode 100644 index 0000000..3ddc527 --- /dev/null +++ b/src/ui/popup.rs @@ -0,0 +1,67 @@ +use crate::ui::{centered_rect, surrounding_block}; +use crate::viewer::{App, Mode, PopupMode}; + +use tui::{ + backend::Backend, + layout::{Constraint, Rect}, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Cell, Row, Table}, + Frame, +}; +use tui_tree_widget::Tree as TUITree; + +pub(super) fn draw_popup(f: &mut Frame, size: Rect, app: &mut App) { + let popup = centered_rect(90, 90, size); + + match &app.mode { + Mode::Popup(pmode) => match pmode { + PopupMode::Tree => draw_tree(f, popup, app), + PopupMode::Help => draw_help(f, popup, app), + }, + _ => unreachable!(), + }; +} + +fn draw_tree(f: &mut Frame, chunk: Rect, app: &mut App) { + let block = surrounding_block("Select a subgraph".to_string(), false); + + let view = app.tabs.selected(); + let subtree = &mut view.subtree; + + let tree = TUITree::new(subtree.tree.clone()) + .block(Block::default().borders(Borders::ALL)) + .highlight_style( + Style::default().fg(Color::Black).bg(Color::LightGreen).add_modifier(Modifier::BOLD), + ) + .highlight_symbol(">> "); + + f.render_stateful_widget(tree, chunk, &mut subtree.state); + + f.render_widget(block, chunk); +} + +fn draw_help(f: &mut Frame, chunk: Rect, app: &mut App) { + let header = app.help.header.iter().map(|s| { + Cell::from(s.as_str()).style(Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)) + }); + let header = Row::new(header).height(1).bottom_margin(1); + + let rows = (app.help.rows.iter()).map(|row| { + let row = row.iter().map(|s| Cell::from(s.as_str())); + Row::new(row).height(1).bottom_margin(1) + }); + + let table = Table::new(rows) + .header(header) + .block(Block::default().borders(Borders::ALL)) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) + .highlight_symbol("> ") + .widths(&[ + Constraint::Percentage(15), + Constraint::Percentage(10), + Constraint::Percentage(15), + Constraint::Percentage(60), + ]); + + f.render_stateful_widget(table, chunk, &mut app.help.state); +} diff --git a/src/ui/tabs.rs b/src/ui/tabs.rs new file mode 100644 index 0000000..6cb6a06 --- /dev/null +++ b/src/ui/tabs.rs @@ -0,0 +1,40 @@ +use crate::ui::view::draw_view; +use crate::viewer::App; + +use tui::{ + backend::Backend, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Span, Spans}, + widgets::{Block, Borders, Tabs}, + Frame, +}; + +pub(super) fn draw_tabs(f: &mut Frame, chunk: Rect, app: &mut App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Length(3), Constraint::Min(0)].as_ref()) + .split(chunk); + + draw_nav_bar(f, chunks[0], app); + + let view = app.tabs.selected(); + draw_view(f, chunks[1], view); +} + +fn draw_nav_bar(f: &mut Frame, chunk: Rect, app: &mut App) { + let block = Block::default().borders(Borders::ALL).title("Views"); + + let titles: Vec = app.tabs.tabs.iter().map(|view| view.title.clone()).collect(); + let titles = (titles.iter()) + .map(|title| Spans::from(vec![Span::styled(title, Style::default().fg(Color::Yellow))])) + .collect(); + + let tabs = Tabs::new(titles) + .block(block) + .select(app.tabs.state) + .highlight_style(Style::default().add_modifier(Modifier::BOLD).bg(Color::Black)); + + f.render_widget(tabs, chunk) +} diff --git a/src/ui/utils/htmlparser.rs b/src/ui/utils/htmlparser.rs new file mode 100644 index 0000000..cb50299 --- /dev/null +++ b/src/ui/utils/htmlparser.rs @@ -0,0 +1,31 @@ +use html_parser::{Dom, Element, Node}; + +pub fn parse(html: &str) -> Vec { + let dom = Dom::parse(html); + + dom.map(parse_dom).unwrap_or_default() +} + +fn parse_dom(dom: Dom) -> Vec { + let mut texts = Vec::new(); + + for node in &dom.children { + parse_node(node, &mut texts); + } + + texts +} + +fn parse_element(element: &Element, texts: &mut Vec) { + for node in &element.children { + parse_node(node, texts); + } +} + +fn parse_node(node: &Node, texts: &mut Vec) { + match &node { + Node::Element(element) => parse_element(element, texts), + Node::Text(text) => texts.push(text.clone()), + _ => {} + } +} diff --git a/src/ui/utils/mod.rs b/src/ui/utils/mod.rs new file mode 100644 index 0000000..33d4000 --- /dev/null +++ b/src/ui/utils/mod.rs @@ -0,0 +1 @@ +pub mod htmlparser; diff --git a/src/ui/view.rs b/src/ui/view.rs new file mode 100644 index 0000000..1cc2e55 --- /dev/null +++ b/src/ui/view.rs @@ -0,0 +1,186 @@ +use crate::{ + ui::{surrounding_block, utils::htmlparser}, + viewer::{Focus, View}, +}; + +use std::collections::{HashMap, HashSet}; +use std::fmt::Write; + +use dot_graph::Node; + +use rayon::prelude::*; +use tui::{ + backend::Backend, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Span, Spans}, + widgets::{Block, List, ListItem, Paragraph, Wrap}, + Frame, +}; + +pub(super) fn draw_view(f: &mut Frame, chunk: Rect, view: &mut View) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(35), Constraint::Percentage(65)].as_ref()) + .split(chunk); + + draw_left(f, chunks[0], view); + draw_right(f, chunks[1], view); +} + +fn draw_left(f: &mut Frame, chunk: Rect, view: &mut View) { + if view.matches.items.is_empty() { + draw_current(f, chunk, view); + } else { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(99), Constraint::Percentage(1)].as_ref()) + .split(chunk); + + draw_current(f, chunks[0], view); + draw_match(f, chunks[1], view); + } +} + +fn draw_right(f: &mut Frame, chunk: Rect, view: &mut View) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) + .split(chunk); + + draw_adjacent(f, chunks[0], view); + draw_metadata(f, chunks[1], view); +} + +fn draw_current(f: &mut Frame, chunk: Rect, view: &mut View) { + let progress = view.progress_current(); + let title = format!("Nodes {progress}"); + let block = surrounding_block(title, view.focus == Focus::Current); + + let froms: HashSet<&String> = HashSet::from_iter(&view.prevs.items); + let tos: HashSet<&String> = HashSet::from_iter(&view.nexts.items); + let mut matches = HashMap::new(); + for (idx, highlight) in &view.matches.items { + matches.insert(*idx, highlight); + } + + let list: Vec = (view.current.items.par_iter()) + .enumerate() + .map(|(idx, id)| { + let mut spans: Vec = id.chars().map(|c| Span::raw(c.to_string())).collect(); + if let Some(&highlight) = matches.get(&idx) { + for &idx in highlight { + spans[idx].style = + Style::default().bg(Color::Rgb(120, 120, 120)).add_modifier(Modifier::BOLD); + } + } + + let mut item = ListItem::new(Spans(spans)); + + if froms.contains(&id) { + item = item.style(Style::default().fg(Color::Rgb(255, 150, 150))); + } else if tos.contains(&id) { + item = item.style(Style::default().fg(Color::Rgb(150, 150, 255))); + } + + item + }) + .collect(); + + let list = List::new(list) + .block(block) + .highlight_style(Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)) + .highlight_symbol("> "); + + f.render_stateful_widget(list, chunk, &mut view.current.state); +} + +fn draw_match(f: &mut Frame, chunk: Rect, view: &mut View) { + let title = if view.matches.items.is_empty() { String::new() } else { view.progress_matches() }; + let block = Block::default().title(title).title_alignment(Alignment::Right); + + f.render_widget(block, chunk); +} + +fn draw_adjacent(f: &mut Frame, chunk: Rect, view: &mut View) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) + .split(chunk); + + draw_prevs(f, chunks[0], view); + draw_nexts(f, chunks[1], view); +} + +fn draw_prevs(f: &mut Frame, chunk: Rect, view: &mut View) { + let block = surrounding_block("Prev Nodes".to_string(), view.focus == Focus::Prev); + + let list: Vec = (view.prevs.items.par_iter()) + .map(|id| ListItem::new(vec![Spans::from(Span::raw(id.as_str()))])) + .collect(); + + let list = List::new(list) + .block(block) + .highlight_style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)) + .highlight_symbol("> "); + + f.render_stateful_widget(list, chunk, &mut view.prevs.state); +} + +fn draw_nexts(f: &mut Frame, chunk: Rect, view: &mut View) { + let block = surrounding_block("Next Nodes".to_string(), view.focus == Focus::Next); + + let list: Vec = (view.nexts.items.par_iter()) + .map(|id| ListItem::new(vec![Spans::from(Span::raw(id.as_str()))])) + .collect(); + + let list = List::new(list) + .block(block) + .highlight_style(Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD)) + .highlight_symbol("> "); + + f.render_stateful_widget(list, chunk, &mut view.nexts.state); +} + +fn draw_metadata(f: &mut Frame, chunk: Rect, view: &mut View) { + let block = surrounding_block("Attrs".to_string(), false); + + let id = view.current_id(); + let node = view.graph.search_node(&id).unwrap(); + + let paragraph = Paragraph::new(pretty_metadata(node)).block(block).wrap(Wrap { trim: true }); + + f.render_widget(paragraph, chunk); +} + +fn pretty_metadata(node: &Node) -> String { + let mut metadata = String::new(); + + let id = node.id(); + writeln!(metadata, "[{id}]").unwrap(); + writeln!(metadata).unwrap(); + + let empty = String::new(); + let attrs = node.attrs(); + let attrs_label = attrs.get("label").unwrap_or(&empty); + let attrs_label = htmlparser::parse(attrs_label); + + if attrs.is_empty() { + for (key, value) in attrs { + writeln!(metadata, "{key} : {value}").unwrap(); + } + } else { + for attr in attrs_label { + if attr.starts_with("Input") { + continue; + } + + let vals = attr.split("\\l"); + for val in vals { + writeln!(metadata, "{val}").unwrap(); + } + } + } + + metadata +} diff --git a/src/viewer/app.rs b/src/viewer/app.rs new file mode 100644 index 0000000..87c006d --- /dev/null +++ b/src/viewer/app.rs @@ -0,0 +1,310 @@ +use crate::viewer::{ + command::{Command, CommandTrie}, + error::{DotViewerError, DotViewerResult}, + help, + modes::{Mode, PopupMode, SearchMode}, + success::Success, + utils::{Input, List, Table, Tabs}, + view::View, +}; + +use std::fs; + +use dot_graph::{parser, Graph}; + +use crossterm::event::KeyCode; + +/// `App` holds `dot-viewer` application states. +/// +/// `tui-rs` simply redraws the entire screen in a loop while accepting keyboard inputs. +/// Thus `App` should keep track of the application context in its fields. +pub(crate) struct App { + /// Whether to quit the application or not, by `q` keybinding + pub quit: bool, + + /// Current mode the application is in + pub mode: Mode, + + /// Result of the last command that was made + pub result: DotViewerResult, + + /// Tabs to be shown in the main screen + pub tabs: Tabs, + + /// Input form to be shown in the main screen + pub input: Input, + + /// Most recent key event + pub lookback: Option, + + /// Autocomplete support for commands + pub trie: CommandTrie, + + /// Keybinding helps + pub help: Table, +} + +impl App { + /// Constructs a new `App`, given a `path` to a dot format DAG. + pub fn new(path: &str) -> DotViewerResult { + let quit = false; + + let mode = Mode::Normal; + + let result: DotViewerResult = Ok(Success::default()); + + let graph = parser::parse(path)?; + + let view = View::new(graph.id().clone(), graph)?; + let tabs = Tabs::from_iter(vec![view]); + + let input = Input::default(); + + let lookback = None; + + let trie = CommandTrie::new(); + + let help = Table::new(help::HEADER, help::ROWS); + + Ok(Self { quit, mode, result, tabs, input, lookback, trie, help }) + } + + /// Navigate to the next match. + pub fn goto_next_match(&mut self) -> DotViewerResult<()> { + let view = self.tabs.selected(); + view.matches.next(); + view.goto_match() + } + + /// Navigate to the previous match. + pub fn goto_prev_match(&mut self) -> DotViewerResult<()> { + let view = self.tabs.selected(); + view.matches.previous(); + view.goto_match() + } + + /// Navigate to the first. + pub fn goto_first(&mut self) -> DotViewerResult<()> { + if let Some(KeyCode::Char('g')) = self.lookback { + let view = self.tabs.selected(); + view.goto_first()?; + } + + Ok(()) + } + + /// Navigate to the last. + pub fn goto_last(&mut self) -> DotViewerResult<()> { + let view = self.tabs.selected(); + view.goto_last() + } + + /// Update search matches with trie. + pub fn update_search(&mut self) { + match &self.mode { + Mode::Search(smode) => { + let view = self.tabs.selected(); + let key = &self.input.key; + + match smode { + SearchMode::Fuzzy => view.update_fuzzy(key), + SearchMode::Regex => view.update_regex(key), + } + view.update_trie(); + + // ignore goto errors while updating search matches + let _ = view.goto_match(); + } + _ => unreachable!(), + } + } + + /// Autocomplete user input. + pub fn autocomplete_fuzzy(&mut self) { + let view = self.tabs.selected(); + + let key = &self.input.key; + if let Some(key) = view.autocomplete(key) { + view.update_fuzzy(&key); + view.update_trie(); + self.input.set(key); + } + } + + /// Autocomplete user input. + pub fn autocomplete_regex(&mut self) { + let view = self.tabs.selected(); + + let key = &self.input.key; + if let Some(key) = view.autocomplete(key) { + view.update_regex(&key); + view.update_trie(); + self.input.set(key); + } + } + + /// Autocomplete user input. + pub fn autocomplete_command(&mut self) { + let command = Command::parse(&self.input.key); + + if command == Command::NoMatch { + self.autocomplete_cmd() + } + } + + fn autocomplete_cmd(&mut self) { + let cmd = &self.input.key; + if let Some(cmd) = self.trie.trie_cmd.autocomplete(cmd) { + self.input.set(cmd); + } + } + + /// Parse and execute dot-viewer command + pub fn exec(&mut self) -> DotViewerResult { + let command = Command::parse(&self.input.key); + + match command { + Command::Neighbors(neighbors) => neighbors.depth.map_or( + Err(DotViewerError::CommandError("No argument supplied for neighbors".to_string())), + |depth| self.neighbors(depth).map(|_| Success::default()), + ), + Command::Export(export) => self.export(export.filename), + Command::Xdot(xdot) => self.xdot(xdot.filename), + Command::Filter => self.filter().map(|_| Success::default()), + Command::Help => { + self.set_popup_mode(PopupMode::Help); + Ok(Success::default()) + } + Command::Subgraph => { + self.set_popup_mode(PopupMode::Tree); + Ok(Success::default()) + } + Command::NoMatch => { + self.set_normal_mode(); + + let key = &self.input.key; + Err(DotViewerError::CommandError(format!("No such command {key}"))) + } + } + } + + /// Extract a subgraph which is a neighbor graph from the currently selected node, + /// with specified depth. + /// It opens a new tab with the neighbor graph view. + pub fn neighbors(&mut self, depth: usize) -> DotViewerResult<()> { + self.set_normal_mode(); + + let view_current = self.tabs.selected(); + let view_new = view_current.neighbors(depth)?; + self.tabs.open(view_new); + + Ok(()) + } + + /// Export the current view to dot. + pub fn export(&mut self, filename: Option) -> DotViewerResult { + self.set_normal_mode(); + + let viewer = self.tabs.selected(); + let graph = &viewer.graph; + + let default: String = viewer.title.chars().filter(|c| !c.is_whitespace()).collect(); + let filename = filename.unwrap_or(format!("{default}.dot")); + + write_graph(filename, graph) + } + + /// Launch `xdot.py`. + pub fn xdot(&mut self, filename: Option) -> DotViewerResult { + self.set_normal_mode(); + + let filename = filename.unwrap_or_else(|| "current.dot".to_string()); + let path = format!("./exports/{filename}"); + + if !std::path::Path::new("./exports/current.dot").exists() { + return Err(DotViewerError::XdotError); + } + + let xdot = std::process::Command::new("xdot") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .arg(&path) + .spawn(); + + xdot.map(|_| Success::XdotSuccess).map_err(|_| DotViewerError::XdotError) + } + + /// Apply filter on the current view, based on the current matches. + /// Opens a new tab with the filtered view. + pub fn filter(&mut self) -> DotViewerResult<()> { + self.set_normal_mode(); + + let view_current = self.tabs.selected(); + let view_new = view_current.filter()?; + self.tabs.open(view_new); + + Ok(()) + } + + /// Extract a subgraph from the current view. + /// When a subgraph id is selected in the subgraph tree, + /// it opens a new tab containing only the selected subgraph. + pub fn subgraph(&mut self) -> DotViewerResult<()> { + self.set_normal_mode(); + + let view_current = self.tabs.selected(); + let view_new = view_current.subgraph()?; + self.tabs.open(view_new); + + Ok(()) + } + + pub fn set_normal_mode(&mut self) { + self.mode = Mode::Normal; + } + + pub fn set_command_mode(&mut self) { + self.input.clear(); + + self.mode = Mode::Command; + } + + pub fn set_search_mode(&mut self, smode: SearchMode) { + self.input.clear(); + + self.mode = Mode::Search(smode); + + let view = self.tabs.selected(); + + view.matches = List::from_iter(Vec::new()); + view.prevs = List::from_iter(Vec::new()); + view.nexts = List::from_iter(Vec::new()); + } + + pub fn set_popup_mode(&mut self, pmode: PopupMode) { + self.mode = Mode::Popup(pmode); + } +} + +fn valid_filename(filename: &str) -> bool { + (!filename.contains('/')) && filename.ends_with(".dot") +} + +fn write_graph(filename: String, graph: &Graph) -> DotViewerResult { + if !valid_filename(&filename) { + return Err(DotViewerError::CommandError(format!("invalid dot filename: {filename}"))); + } + + let mut open_options = fs::OpenOptions::new(); + let open_options = open_options.write(true).truncate(true).create(true); + + fs::create_dir_all("./exports")?; + + let mut file_export = open_options.open(format!("./exports/{filename}"))?; + graph.to_dot(&mut file_export)?; + + let mut file_current = open_options.open("./exports/current.dot")?; + graph.to_dot(&mut file_current)?; + + Ok(Success::ExportSuccess(filename)) +} diff --git a/src/viewer/command.rs b/src/viewer/command.rs new file mode 100644 index 0000000..41ff34b --- /dev/null +++ b/src/viewer/command.rs @@ -0,0 +1,97 @@ +use crate::viewer::utils::Trie; +use clap::builder::{Arg, Command as ClapCommand}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) enum Command { + Neighbors(Neighbors), + Export(Export), + Xdot(Xdot), + Filter, + Help, + Subgraph, + NoMatch, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct Neighbors { + pub(crate) depth: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct Export { + pub(crate) filename: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct Xdot { + pub(crate) filename: Option, +} + +pub(crate) struct CommandTrie { + pub(crate) trie_cmd: Trie, + pub(crate) _trie_arg: Trie, +} + +fn subcommands() -> [ClapCommand; 6] { + [ + ClapCommand::new("neighbors") + .arg(Arg::new("depth").value_parser(clap::value_parser!(usize))), + ClapCommand::new("export").arg(Arg::new("filename")), + ClapCommand::new("xdot").arg(Arg::new("filename")), + ClapCommand::new("filter"), + ClapCommand::new("help"), + ClapCommand::new("subgraph"), + ] +} + +fn commands() -> ClapCommand { + ClapCommand::new("dot-viewer") + .multicall(true) + .disable_help_subcommand(true) + .subcommand_required(true) + .subcommands(subcommands()) +} + +impl Command { + pub fn parse(input: &str) -> Self { + let inputs: Vec<&str> = input.split_whitespace().collect(); + + match commands().try_get_matches_from(inputs) { + Ok(matches) => match matches.subcommand() { + Some(("neighbors", matches)) => { + let depth = matches.get_one::("depth").copied(); + let neigbors = Neighbors { depth }; + + Self::Neighbors(neigbors) + } + Some(("export", matches)) => { + let filename = matches.get_one::("filename").cloned(); + let export = Export { filename }; + + Self::Export(export) + } + Some(("xdot", matches)) => { + let filename = matches.get_one::("filename").cloned(); + let xdot = Xdot { filename }; + + Self::Xdot(xdot) + } + Some(("filter", _)) => Self::Filter, + Some(("help", _)) => Self::Help, + Some(("subgraph", _)) => Self::Subgraph, + _ => unreachable!(), + }, + Err(_) => Self::NoMatch, + } + } +} + +impl CommandTrie { + pub fn new() -> CommandTrie { + let trie_cmd = Trie::from_iter(subcommands().iter().map(|c| c.get_name().to_string())); + + let _trie_arg = Trie::from_iter([]); + + Self { trie_cmd, _trie_arg } + } +} diff --git a/src/viewer/error.rs b/src/viewer/error.rs new file mode 100644 index 0000000..979be0f --- /dev/null +++ b/src/viewer/error.rs @@ -0,0 +1,23 @@ +use dot_graph::DotGraphError; + +use crossterm::event::KeyCode; +use thiserror::Error; + +pub type DotViewerResult = Result; + +#[derive(Error, Debug)] +#[allow(clippy::enum_variant_names)] +pub enum DotViewerError { + #[error(transparent)] + DotGraphError(#[from] DotGraphError), + #[error("Err: viewer failed with, `{0}`")] + ViewerError(String), + #[error("Err: `{0}`")] + CommandError(String), + #[error("Err: no keybinding for {0:?}")] + KeyError(KeyCode), + #[error(transparent)] + IOError(#[from] std::io::Error), + #[error("Err: failed to launch xdot.py")] + XdotError, +} diff --git a/src/viewer/help.rs b/src/viewer/help.rs new file mode 100644 index 0000000..906f9ba --- /dev/null +++ b/src/viewer/help.rs @@ -0,0 +1,33 @@ +pub(super) const HEADER: &[&str] = &["When", "Key", "Command", "Actions"]; + +pub(super) const ROWS: &[&[&str]] = &[ + &["Quit", "q", "", "quit dot-viewer"], + &["Help", "", ":help", "help"], + &["", "", "", ""], + &["All", "esc", "", "go back to Normal mode"], + &["Normal", "/", "", "go to fuzzy search mode"], + &["Normal", "r", "", "go to regex search mode"], + &["Normal", ":", "", "go to command mode"], + &["", "", "", ""], + &["Normal", "c", "", "close the current tab (view)"], + &["", "h/l", "", "move focus between current, prevs, nexts list"], + &["", "j/k", "", "traverse in focused list"], + &["", "n/N", "", "go to next/previous match"], + &["", "tab/backtab", "", "move between tabs"], + &["Search", "tab", "", "autocomplete search keyword"], + &["", "enter", "", "apply search"], + &["Command", "", "filter", "apply filter on current matches"], + &["", "", "neighbors [depth]", "get up to [depth] neighbors of the current node"], + &["", "", "export [(opt) filename]", "export the current tab (view) to dot"], + &[ + "", + "", + "xdot [(opt) filename]", + "launch xdot, showing the most current exported file on default", + ], + &["", "", "subgraph", "go to subgraph Popup mode"], + &["", "tab", "", "autocomplete command"], + &["", "enter", "", "execute command"], + &["Subgraph Popup", "h/j/k/l", "", "traverse tree"], + &["", "enter", "", "change root to the selected subgraph"], +]; diff --git a/src/viewer/keybindings.rs b/src/viewer/keybindings.rs new file mode 100644 index 0000000..f57f73e --- /dev/null +++ b/src/viewer/keybindings.rs @@ -0,0 +1,292 @@ +use crate::viewer::{ + app::App, + error::{DotViewerError, DotViewerResult}, + modes::{Mode, PopupMode, SearchMode}, + success::Success, + view::{Focus, View}, +}; + +use crossterm::event::{KeyCode, KeyEvent}; +use log::{info, warn}; + +impl App { + pub fn key(&mut self, key: KeyEvent) { + info!("{:?}", key.code); + + self.result = match key.code { + KeyCode::Char(c) => self.char(c).map(|_| Success::default()), + KeyCode::Enter => self.enter(), + KeyCode::Backspace => self.backspace().map(|_| Success::default()), + KeyCode::Esc => self.esc().map(|_| Success::default()), + KeyCode::Tab => self.tab().map(|_| Success::default()), + KeyCode::BackTab => self.backtab().map(|_| Success::default()), + KeyCode::Right => self.right().map(|_| Success::default()), + KeyCode::Left => self.left().map(|_| Success::default()), + _ => Ok(Success::default()), + }; + + if let Err(err) = &self.result { + warn!("{err}"); + } + + self.lookback = Some(key.code); + } + + fn char(&mut self, c: char) -> DotViewerResult<()> { + match &self.mode { + Mode::Normal => self.char_normal(c)?, + Mode::Command => self.char_command(c)?, + Mode::Search(_) => self.char_search(c), + Mode::Popup(_) => self.char_popup(c)?, + }; + + Ok(()) + } + + fn char_normal(&mut self, c: char) -> DotViewerResult<()> { + match c { + 'q' => self.quit = true, + '/' => self.set_search_mode(SearchMode::Fuzzy), + 'r' => self.set_search_mode(SearchMode::Regex), + ':' => self.set_command_mode(), + 'c' => self.tabs.close()?, + 'h' => self.left()?, + 'j' => self.down()?, + 'k' => self.up()?, + 'l' => self.right()?, + 'n' => self.goto_next_match()?, + 'N' => self.goto_prev_match()?, + 'g' => self.goto_first()?, + 'G' => self.goto_last()?, + _ => Err(DotViewerError::KeyError(KeyCode::Char(c)))?, + }; + + Ok(()) + } + + fn char_command(&mut self, c: char) -> DotViewerResult<()> { + self.input.insert(c); + Ok(()) + } + + fn char_search(&mut self, c: char) { + self.input.insert(c); + self.update_search(); + } + + fn char_popup(&mut self, c: char) -> DotViewerResult<()> { + match &self.mode { + Mode::Popup(pmode) => match pmode { + PopupMode::Tree => self.char_tree(c), + PopupMode::Help => self.char_help(c), + }, + _ => unreachable!(), + } + } + + fn char_tree(&mut self, c: char) -> DotViewerResult<()> { + match c { + 'q' => { + self.quit = true; + Ok(()) + } + 'h' => self.left(), + 'j' => self.down(), + 'k' => self.up(), + 'l' => self.right(), + _ => Err(DotViewerError::KeyError(KeyCode::Char(c))), + } + } + + fn char_help(&mut self, c: char) -> DotViewerResult<()> { + match c { + 'q' => { + self.quit = true; + Ok(()) + } + 'j' => self.down(), + 'k' => self.up(), + _ => Err(DotViewerError::KeyError(KeyCode::Char(c))), + } + } + + fn enter(&mut self) -> DotViewerResult { + match &self.mode { + Mode::Normal => { + let view = self.tabs.selected(); + view.enter().map(|_| Success::default()) + } + Mode::Command => self.exec(), + Mode::Search(_) => { + self.set_normal_mode(); + Ok(Success::default()) + } + Mode::Popup(pmode) => match pmode { + PopupMode::Tree => self.subgraph().map(|_| Success::default()), + _ => Ok(Success::default()), + }, + } + } + + fn backspace(&mut self) -> DotViewerResult<()> { + match &self.mode { + Mode::Command => self.input.delete(), + Mode::Search(_) => { + self.input.delete(); + self.update_search(); + } + _ => Err(DotViewerError::KeyError(KeyCode::Backspace))?, + }; + + Ok(()) + } + + fn esc(&mut self) -> DotViewerResult<()> { + match &self.mode { + Mode::Normal => Err(DotViewerError::KeyError(KeyCode::Esc)), + _ => { + self.set_normal_mode(); + Ok(()) + } + } + } + + fn tab(&mut self) -> DotViewerResult<()> { + match &self.mode { + Mode::Normal => self.tabs.next(), + Mode::Command => self.autocomplete_command(), + Mode::Search(smode) => match smode { + SearchMode::Fuzzy => self.autocomplete_fuzzy(), + SearchMode::Regex => self.autocomplete_regex(), + }, + _ => Err(DotViewerError::KeyError(KeyCode::Tab))?, + }; + + Ok(()) + } + + fn backtab(&mut self) -> DotViewerResult<()> { + match &self.mode { + Mode::Normal => { + self.tabs.previous(); + Ok(()) + } + _ => Err(DotViewerError::KeyError(KeyCode::BackTab)), + } + } + + fn up(&mut self) -> DotViewerResult<()> { + let view = self.tabs.selected(); + + match &self.mode { + Mode::Normal => view.up()?, + Mode::Popup(pmode) => match pmode { + PopupMode::Tree => view.subtree.up(), + PopupMode::Help => self.help.previous(), + }, + _ => Err(DotViewerError::KeyError(KeyCode::Up))?, + }; + + Ok(()) + } + + fn down(&mut self) -> DotViewerResult<()> { + let view = self.tabs.selected(); + + match &self.mode { + Mode::Normal => view.down()?, + Mode::Popup(pmode) => match pmode { + PopupMode::Tree => view.subtree.down(), + PopupMode::Help => self.help.next(), + }, + _ => Err(DotViewerError::KeyError(KeyCode::Down))?, + }; + + Ok(()) + } + + fn right(&mut self) -> DotViewerResult<()> { + match &self.mode { + Mode::Normal => { + let view = self.tabs.selected(); + view.right() + } + Mode::Search(_) => self.input.front(), + Mode::Popup(PopupMode::Tree) => { + let view = self.tabs.selected(); + view.subtree.right() + } + _ => Err(DotViewerError::KeyError(KeyCode::Right))?, + }; + + Ok(()) + } + + fn left(&mut self) -> DotViewerResult<()> { + match &self.mode { + Mode::Normal => { + let view = self.tabs.selected(); + view.left() + } + Mode::Search(_) => self.input.back(), + Mode::Popup(PopupMode::Tree) => { + let view = self.tabs.selected(); + view.subtree.left() + } + _ => Err(DotViewerError::KeyError(KeyCode::Left))?, + }; + + Ok(()) + } +} + +impl View { + pub fn enter(&mut self) -> DotViewerResult<()> { + match &self.focus { + Focus::Prev | Focus::Next => self.goto_adjacent(), + Focus::Current => Ok(()), + } + } + + pub fn up(&mut self) -> DotViewerResult<()> { + match &self.focus { + Focus::Current => { + self.current.previous(); + self.update_adjacent()? + } + Focus::Prev => self.prevs.previous(), + Focus::Next => self.nexts.previous(), + } + + Ok(()) + } + + pub fn down(&mut self) -> DotViewerResult<()> { + match &self.focus { + Focus::Current => { + self.current.next(); + self.update_adjacent()? + } + Focus::Prev => self.prevs.next(), + Focus::Next => self.nexts.next(), + } + + Ok(()) + } + + pub fn right(&mut self) { + self.focus = match &self.focus { + Focus::Current => Focus::Prev, + Focus::Prev => Focus::Next, + Focus::Next => Focus::Current, + }; + } + + pub fn left(&mut self) { + self.focus = match &self.focus { + Focus::Current => Focus::Next, + Focus::Prev => Focus::Current, + Focus::Next => Focus::Prev, + }; + } +} diff --git a/src/viewer/mod.rs b/src/viewer/mod.rs new file mode 100644 index 0000000..cecc0a8 --- /dev/null +++ b/src/viewer/mod.rs @@ -0,0 +1,15 @@ +mod app; +mod command; +mod error; +mod help; +mod keybindings; +mod modes; +mod success; +mod utils; +mod view; + +pub(crate) use crate::viewer::{ + app::App, + modes::{Mode, PopupMode, SearchMode}, + view::{Focus, View}, +}; diff --git a/src/viewer/modes.rs b/src/viewer/modes.rs new file mode 100644 index 0000000..f24ed12 --- /dev/null +++ b/src/viewer/modes.rs @@ -0,0 +1,26 @@ +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +/// `Mode` represents the context that the application, `dot-viewer` is in. +pub(crate) enum Mode { + Normal, + Command, + Search(SearchMode), + Popup(PopupMode), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +/// In `PopupMode`, users can +/// - navigate the subgraphs, or +/// - see help message. +pub(crate) enum PopupMode { + Tree, + Help, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +/// In `SearchMode`, users can search for a node with, +/// - fuzzy search against node ids, or +/// - regex search against raw node representation in dot format. +pub(crate) enum SearchMode { + Fuzzy, + Regex, +} diff --git a/src/viewer/success.rs b/src/viewer/success.rs new file mode 100644 index 0000000..7fd7111 --- /dev/null +++ b/src/viewer/success.rs @@ -0,0 +1,25 @@ +use std::fmt; + +#[derive(Debug)] +#[allow(clippy::enum_variant_names)] +pub(crate) enum Success { + ExportSuccess(String), + XdotSuccess, + Silent, +} + +impl Default for Success { + fn default() -> Self { + Self::Silent + } +} + +impl fmt::Display for Success { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match &self { + Self::ExportSuccess(filename) => write!(f, "successfully exported to {filename}"), + Self::XdotSuccess => write!(f, "launched xdot"), + Self::Silent => Ok(()), + } + } +} diff --git a/src/viewer/utils/input.rs b/src/viewer/utils/input.rs new file mode 100644 index 0000000..adff65d --- /dev/null +++ b/src/viewer/utils/input.rs @@ -0,0 +1,49 @@ +#![allow(dead_code)] + +#[derive(Default)] +pub(crate) struct Input { + pub key: String, + pub cursor: usize, + history: Vec, +} + +impl Input { + pub fn new() -> Self { + Self::default() + } + + pub fn set(&mut self, key: String) { + self.key = key; + self.cursor = self.key.len(); + } + + pub fn front(&mut self) { + if self.cursor < self.key.len() { + self.cursor += 1; + } + } + + pub fn back(&mut self) { + if self.cursor > 0 { + self.cursor -= 1; + } + } + + pub fn insert(&mut self, c: char) { + self.key.insert(self.cursor, c); + self.cursor += 1; + } + + pub fn delete(&mut self) { + if self.cursor > 0 { + self.cursor -= 1; + self.key.remove(self.cursor); + } + } + + pub fn clear(&mut self) { + self.history.push(self.key.clone()); + self.key = String::from(""); + self.cursor = 0; + } +} diff --git a/src/viewer/utils/list.rs b/src/viewer/utils/list.rs new file mode 100644 index 0000000..c1757c2 --- /dev/null +++ b/src/viewer/utils/list.rs @@ -0,0 +1,73 @@ +#![allow(dead_code)] + +use tui::widgets::ListState; + +// https://github.com/fdehau/tui-rs/blob/master/examples/list.rs +pub(crate) struct List { + pub state: ListState, + pub items: Vec, +} + +impl std::iter::FromIterator for List { + fn from_iter>(iter: I) -> Self { + let state = ListState::default(); + + let items = Vec::from_iter(iter); + + let mut list = Self { state, items }; + + if !list.items.is_empty() { + list.state.select(Some(0)); + } + + list + } +} + +impl List { + pub fn next(&mut self) { + if !self.items.is_empty() { + let i = (self.state.selected()) + .map(|i| if i >= self.items.len() - 1 { 0 } else { i + 1 }) + .unwrap_or(0); + + self.state.select(Some(i)); + } + } + + pub fn previous(&mut self) { + if !self.items.is_empty() { + let i = (self.state.selected()) + .map(|i| if i == 0 { self.items.len() - 1 } else { i - 1 }) + .unwrap_or(0); + + self.state.select(Some(i)); + } + } + + pub fn first(&mut self) { + if !self.items.is_empty() { + self.state.select(Some(0)); + } + } + + pub fn last(&mut self) { + if !self.items.is_empty() { + self.state.select(Some(self.items.len() - 1)); + } + } + + pub fn select(&mut self, idx: usize) { + if idx < self.items.len() { + self.state.select(Some(idx)); + } + } + + pub fn selected(&self) -> Option { + self.state.selected().map(|i| self.items[i].clone()) + } + + pub fn find(&self, key: T) -> Option { + self.items.iter().position(|item| *item == key) + } +} diff --git a/src/viewer/utils/mod.rs b/src/viewer/utils/mod.rs new file mode 100644 index 0000000..fe07506 --- /dev/null +++ b/src/viewer/utils/mod.rs @@ -0,0 +1,13 @@ +mod input; +mod list; +mod table; +mod tabs; +mod tree; +mod trie; + +pub(crate) use input::Input; +pub(crate) use list::List; +pub(crate) use table::Table; +pub(crate) use tabs::Tabs; +pub(crate) use tree::Tree; +pub(crate) use trie::Trie; diff --git a/src/viewer/utils/table.rs b/src/viewer/utils/table.rs new file mode 100644 index 0000000..db84757 --- /dev/null +++ b/src/viewer/utils/table.rs @@ -0,0 +1,41 @@ +use rayon::prelude::*; +use tui::widgets::TableState; + +pub(crate) struct Table { + pub state: TableState, + pub header: Vec, + pub rows: Vec>, +} + +impl Table { + pub fn new(header: &[&str], rows: &[&[&str]]) -> Self { + let mut state = TableState::default(); + + if !rows.is_empty() { + state.select(Some(0)); + } + + let header: Vec = header.par_iter().map(|s| s.to_string()).collect(); + + let rows: Vec> = + rows.par_iter().map(|row| row.iter().map(|s| s.to_string()).collect()).collect(); + + Self { state, header, rows } + } + + pub fn next(&mut self) { + let i = (self.state.selected()) + .map(|i| if i >= self.rows.len() - 1 { 0 } else { i + 1 }) + .unwrap_or(0); + + self.state.select(Some(i)); + } + + pub fn previous(&mut self) { + let i = (self.state.selected()) + .map(|i| if i == 0 { self.rows.len() - 1 } else { i - 1 }) + .unwrap_or(0); + + self.state.select(Some(i)); + } +} diff --git a/src/viewer/utils/tabs.rs b/src/viewer/utils/tabs.rs new file mode 100644 index 0000000..d78ebfb --- /dev/null +++ b/src/viewer/utils/tabs.rs @@ -0,0 +1,62 @@ +#![allow(dead_code)] + +use crate::viewer::error::{DotViewerError, DotViewerResult}; + +// https://github.com/fdehau/tui-rs/blob/master/examples/tabs.rs +pub(crate) struct Tabs { + pub state: usize, + pub tabs: Vec, +} + +impl std::iter::FromIterator for Tabs { + fn from_iter>(iter: I) -> Self { + let state = 0; + let tabs = Vec::from_iter(iter); + + Self { state, tabs } + } +} + +impl Tabs { + pub fn next(&mut self) { + let state = self.state; + let len = self.tabs.len(); + + self.state = if state < len - 1 { state + 1 } else { 0 }; + } + + pub fn previous(&mut self) { + let state = self.state; + let len = self.tabs.len(); + + self.state = if state == 0 { len - 1 } else { state - 1 }; + } + + pub fn open(&mut self, tab: T) { + self.tabs.push(tab); + self.state = self.tabs.len() - 1; + } + + pub fn close(&mut self) -> DotViewerResult<()> { + if self.state == 0 { + return Err(DotViewerError::ViewerError("cannot close the first tab".to_string())); + } + + self.tabs.remove(self.state); + if self.state == self.tabs.len() { + self.state -= 1; + } + + Ok(()) + } + + pub fn select(&mut self, state: usize) { + if state < self.tabs.len() { + self.state = state; + } + } + + pub fn selected(&mut self) -> &mut T { + &mut self.tabs[self.state] + } +} diff --git a/src/viewer/utils/tree.rs b/src/viewer/utils/tree.rs new file mode 100644 index 0000000..c6d93e1 --- /dev/null +++ b/src/viewer/utils/tree.rs @@ -0,0 +1,112 @@ +#![allow(dead_code)] + +use dot_graph::Graph; + +use tui_tree_widget::{TreeItem, TreeState}; + +use rayon::prelude::*; + +pub(crate) struct Item { + id: String, + children: Vec, +} + +// https://github.com/EdJoPaTo/tui-rs-tree-widget/blob/main/examples/util/mod.rs +pub(crate) struct Tree { + pub state: TreeState, + pub tree: Vec>, + items: Vec, +} + +impl Tree { + pub fn from_graph(graph: &Graph) -> Self { + let root = graph.search_subgraph(graph.id()).unwrap().id(); + + let item = to_item(root, graph); + let tree = to_tree(&item, graph); + + let items = vec![item]; + let tree = vec![tree]; + + let mut state = TreeState::default(); + state.select_first(); + state.toggle_selected(); + + Self { state, items, tree } + } + + pub fn selected(&self) -> Option { + let mut idxs = self.state.selected(); + + if idxs.is_empty() { + return None; + } + + let idx = idxs.remove(0); + let mut item = &self.items[idx]; + for idx in idxs { + item = &item.children[idx]; + } + + Some(item.id.clone()) + } + + pub fn first(&mut self) { + self.state.select_first(); + } + + pub fn last(&mut self) { + self.state.select_last(&self.tree); + } + + pub fn down(&mut self) { + self.state.key_down(&self.tree); + } + + pub fn up(&mut self) { + self.state.key_up(&self.tree); + } + + pub fn left(&mut self) { + self.state.key_left(); + } + + pub fn right(&mut self) { + self.state.key_right(); + } + + pub fn toggle(&mut self) { + self.state.toggle_selected(); + } +} + +fn to_item(root: &String, graph: &Graph) -> Item { + let id = root.clone(); + + let children = graph.collect_subgraphs(root).expect("root should exist in the graph"); + let mut children: Vec = children + .par_iter() + .map(|&id| { + let child = graph.search_subgraph(id).unwrap(); + to_item(child.id(), graph) + }) + .collect(); + children.sort_by(|a, b| (a.id).cmp(&b.id)); + + Item { id, children } +} + +fn to_tree(root: &Item, graph: &Graph) -> TreeItem<'static> { + let id = &root.id; + let children = &root.children; + + let subgraph_cnt = children.len(); + let node_cnt = graph.collect_nodes(id).expect("root should exist in the graph").len(); + let edge_cnt = graph.collect_edges(id).expect("root should exist in the graph").len(); + + let id = format!("{} (s: {} n: {} e: {})", id, subgraph_cnt, node_cnt, edge_cnt); + let children: Vec> = + children.iter().map(|node| to_tree(node, graph)).collect(); + + TreeItem::new(id, children) +} diff --git a/src/viewer/utils/trie.rs b/src/viewer/utils/trie.rs new file mode 100644 index 0000000..8ecf16b --- /dev/null +++ b/src/viewer/utils/trie.rs @@ -0,0 +1,57 @@ +#![allow(dead_code)] + +use std::str; + +use trie_rs::TrieBuilder; + +pub(crate) struct Trie { + items: Vec, + trie: trie_rs::Trie, +} + +impl FromIterator for Trie { + fn from_iter>(iter: T) -> Self { + let mut builder = TrieBuilder::new(); + + let mut items = Vec::new(); + for id in iter { + items.push(id.clone()); + builder.push(id); + } + let trie = builder.build(); + + Trie { items, trie } + } +} + +impl Trie { + pub fn autocomplete(&self, key: &str) -> Option { + let predictions = if key.is_empty() { self.items.clone() } else { self.predict(key) }; + + longest_common_prefix(&predictions) + } + + fn predict(&self, key: &str) -> Vec { + let trie_search_result = self.trie.predictive_search(key); + (trie_search_result.into_iter()).map(|s| String::from_utf8(s).unwrap()).collect() + } +} + +// https://leetcode.com/problems/longest-common-prefix/solutions/1134124/faster-than-100-in-memory-and-runtime-by-rust/ +fn longest_common_prefix(strs: &[String]) -> Option { + if strs.is_empty() { + return None; + } + + let mut str_iters = strs.iter().map(|s| s.chars()).collect::>(); + + for (i, c) in strs[0].char_indices() { + for str_iter in &mut str_iters { + if str_iter.next().filter(|&x| x == c).is_none() { + return Some(strs[0][..i].to_string()); + } + } + } + + Some(strs[0].clone()) +} diff --git a/src/viewer/view.rs b/src/viewer/view.rs new file mode 100644 index 0000000..d3d577c --- /dev/null +++ b/src/viewer/view.rs @@ -0,0 +1,282 @@ +use crate::viewer::{ + error::{DotViewerError, DotViewerResult}, + utils::{List, Tree, Trie}, +}; + +use dot_graph::Graph; + +use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; +use rayon::prelude::*; +use regex::Regex; + +type Matcher = fn(&str, &str, &Graph) -> Option>; + +/// `View` holds a "view" of the graph that `dot-viewer` is dealing with. +/// +/// Named as an analogy to the database concept of "view", +/// it holds a smaller portion of the original graph. +pub(crate) struct View { + /// Title of the view + pub title: String, + + /// Graph that the view is representing (a portion of the original graph) + pub graph: Graph, + + /// Current focus + pub focus: Focus, + /// Topologically sorted list of all nodes in the view + pub current: List, + /// List of previous nodes of the currently selected node + pub prevs: List, + /// List of next nodes of the currently selected node + pub nexts: List, + + /// Keyword for match + pub key: String, + /// List of matching nodes given some input, with highlight index + pub matches: List<(usize, Vec)>, + + /// Trie for user input autocompletion + pub trie: Trie, + + /// Tree holding the subgraph tree of the view + pub subtree: Tree, +} + +#[derive(PartialEq)] +pub(crate) enum Focus { + Current, + Prev, + Next, +} + +impl View { + /// Constructs a new `View`, given a `title` and a `graph`, which is a portion of the original + /// graph. + pub fn new(title: String, graph: Graph) -> DotViewerResult { + let node_ids = graph.topsort()?; + let node_ids = node_ids.iter().map(|&id| id.clone()); + + let trie = Trie::from_iter(node_ids.clone()); + + let focus = Focus::Current; + let current = List::from_iter(node_ids); + let prevs = List::from_iter(Vec::new()); + let nexts = List::from_iter(Vec::new()); + + let key = String::new(); + let matches = List::from_iter(Vec::new()); + + let subtree = Tree::from_graph(&graph); + + let mut view = + Self { title, graph, focus, current, prevs, nexts, key, matches, trie, subtree }; + + view.update_adjacent().expect("there is always a selected current node on initialization"); + + Ok(view) + } + + /// Navigate to the first node in focused list. + pub fn goto_first(&mut self) -> DotViewerResult<()> { + match &self.focus { + Focus::Current => { + self.current.first(); + self.update_adjacent()? + } + Focus::Prev => self.prevs.first(), + Focus::Next => self.nexts.first(), + } + + Ok(()) + } + + /// Navigate to the last node in focused list. + pub fn goto_last(&mut self) -> DotViewerResult<()> { + match &self.focus { + Focus::Current => { + self.current.last(); + self.update_adjacent()? + } + Focus::Prev => self.prevs.last(), + Focus::Next => self.nexts.last(), + } + + Ok(()) + } + + /// Navigate to the selected adjacent node. + pub fn goto_adjacent(&mut self) -> DotViewerResult<()> { + let err = Err(DotViewerError::ViewerError("no node selected".to_string())); + + match &self.focus { + Focus::Prev => self.prevs.selected().map_or(err, |id| self.goto(&id)), + Focus::Next => self.nexts.selected().map_or(err, |id| self.goto(&id)), + _ => err, + } + } + + /// Navigate to the matched node. + pub fn goto_match(&mut self) -> DotViewerResult<()> { + self.matched_id() + .map_or(Err(DotViewerError::ViewerError("no node selected".to_string())), |id| { + self.goto(&id) + }) + } + + /// Navigate to the currently selected node with `id`. + /// The current node list will be focused on the selected node. + pub fn goto(&mut self, id: &str) -> DotViewerResult<()> { + let idx = (self.current) + .find(id.to_string()) + .ok_or(DotViewerError::ViewerError(format!("no such node {id:?}")))?; + + self.current.select(idx); + self.update_adjacent()?; + + Ok(()) + } + + /// Apply prefix filter on the view given prefix `key`. + /// Returns `Ok` with a new `View` if the prefix yields a valid subgraph. + pub fn filter(&mut self) -> DotViewerResult { + let node_ids: Vec<&String> = + (self.matches.items.iter()).map(|(idx, _)| &self.current.items[*idx]).collect(); + let graph = self.graph.filter(&node_ids); + + if graph.is_empty() { + let key = &self.key; + return Err(DotViewerError::ViewerError(format!("no match for keyword {key}"))); + } + + Self::new(format!("{} - {}", self.title, self.key), graph) + } + + /// Extract a subgraph from the view. + /// Returns `Ok` with a new `View` if the selected subgraph id is valid. + pub fn subgraph(&mut self) -> DotViewerResult { + let key = (self.subtree) + .selected() + .ok_or(DotViewerError::ViewerError("no subgraph selected".to_string()))?; + + let subgraph = + self.graph.subgraph(&key).map_err(|e| DotViewerError::ViewerError(e.to_string()))?; + + if subgraph.is_empty() { + return Err(DotViewerError::ViewerError("empty graph".to_string())); + } + + let title = &self.title; + Self::new(format!("{title} - {key}"), subgraph) + } + + /// Get neighbors graph from the selected id in the view. + /// Returns `Ok` with a new `View` if the depth is valid. + pub fn neighbors(&mut self, depth: usize) -> DotViewerResult { + let id = self.current_id(); + let graph = self.graph.neighbors(&id, depth)?; + + if graph.is_empty() { + return Err(DotViewerError::ViewerError("cannot define a neighbors graph".to_string())); + } + + let title = &self.title; + Self::new(format!("{title} - neighbors-{id}-{depth}"), graph) + } + + /// Autocomplete a given keyword, coming from `tab` keybinding. + pub fn autocomplete(&mut self, key: &str) -> Option { + self.trie.autocomplete(key) + } + + /// Update prevs and nexts lists based on the selected current node. + pub fn update_adjacent(&mut self) -> DotViewerResult<()> { + let id = self.current_id(); + + let mut prevs = Vec::from_iter(self.graph.froms(&id)?); + prevs.sort_unstable(); + let prevs = prevs.iter().map(|n| n.to_string()); + self.prevs = List::from_iter(prevs); + + let mut nexts = Vec::from_iter(self.graph.tos(&id)?); + nexts.sort_unstable(); + let nexts = nexts.iter().map(|n| n.to_string()); + self.nexts = List::from_iter(nexts); + + Ok(()) + } + + /// Update matches based on the given matching function `match` with input `key`. + fn update_matches(&mut self, matcher: Matcher, key: &str) { + let matches: Vec<(usize, Vec)> = (self.current.items.par_iter()) + .enumerate() + .filter_map(|(idx, id)| matcher(id, key, &self.graph).map(|highlight| (idx, highlight))) + .collect(); + + self.key = key.to_string(); + self.matches = List::from_iter(matches); + } + + /// Update matches in fuzzy search mode. + /// Fuzzy matcher matches input against node ids. + pub fn update_fuzzy(&mut self, key: &str) { + self.update_matches(match_fuzzy, key); + } + + /// Update matches in regex search mode. + /// Regex matcher matches input against node represented in raw dot format string. + pub fn update_regex(&mut self, key: &str) { + self.update_matches(match_regex, key); + } + + /// Update trie based on the current matches. + pub fn update_trie(&mut self) { + let nodes = self.matches.items.iter().map(|(idx, _)| self.current.items[*idx].clone()); + self.trie = Trie::from_iter(nodes); + } + + pub fn current_id(&self) -> String { + self.current.selected().expect("there is always a current id selected in a view") + } + + pub fn matched_id(&self) -> Option { + self.matches.selected().map(|(idx, _)| self.current.items[idx].clone()) + } + + pub fn progress_current(&self) -> String { + let idx = self.current.state.selected().unwrap(); + let len = self.current.items.len(); + let percentage = (idx as f32 / len as f32) * 100_f32; + + format!("[{} / {} ({:.3}%)]", idx + 1, len, percentage) + } + + pub fn progress_matches(&self) -> String { + let idx = self.matches.state.selected().unwrap(); + let len = self.matches.items.len(); + let percentage = (idx as f32 / len as f32) * 100_f32; + + format!("[{} / {} ({:.3}%)]", idx + 1, len, percentage) + } +} + +fn match_fuzzy(id: &str, key: &str, _graph: &Graph) -> Option> { + let matcher = SkimMatcherV2::default(); + + matcher.fuzzy_indices(id, key).map(|(_, idxs)| idxs) +} + +fn match_regex(id: &str, key: &str, graph: &Graph) -> Option> { + if let Ok(matcher) = Regex::new(key) { + let node = graph.search_node(&id.to_string()).unwrap(); + + let mut buffer = Vec::new(); + node.to_dot(0, &mut buffer).expect("to_dot should succeed"); + let raw = std::str::from_utf8(&buffer).unwrap(); + + let highlight: Vec = (0..id.len()).collect(); + matcher.is_match(raw).then_some(highlight) + } else { + None + } +}