use rich_rs::Syntax;
use textual::prelude::*;
const CSS: &str = r#"
#tree-view {
display: none;
scrollbar-gutter: stable;
overflow: auto;
width: auto;
height: 100%;
dock: left;
}
Screen.-show-tree #tree-view {
display: block;
max-width: 50%;
}
#code-view {
overflow: auto scroll;
min-width: 100%;
hatch: right $panel;
}
#code {
width: auto;
padding: 0 1;
background: $surface;
}
"#;
struct CodeBrowserApp {
start_path: String,
}
impl CodeBrowserApp {
fn new(start_path: impl Into<String>) -> Self {
Self {
start_path: start_path.into(),
}
}
fn load_path(app: &mut App, path: &str) {
match Syntax::from_path(path) {
Ok(syntax) => {
let highlighted = syntax.highlight();
let _ = app.with_query_one_mut_as::<Static, _>("#code", |s| {
s.update_rich(highlighted);
});
let _ = app.with_query_one_mut_as::<VerticalScroll, _>("#code-view", |s| {
s.scroll_home();
});
app.set_sub_title(path);
}
Err(e) => {
let error_msg = format!("[b red]Error reading file:[/b red]\n{path}\n\n{e}");
let _ = app.with_query_one_mut_as::<Static, _>("#code", |s| {
s.update(&error_msg);
});
app.set_sub_title("ERROR");
}
}
}
}
impl TextualApp for CodeBrowserApp {
fn configure(&mut self, app: &mut App) -> textual::Result<()> {
app.load_stylesheet(CSS);
Ok(())
}
fn bindings(&self) -> Vec<BindingDecl> {
vec![
BindingDecl::new(
"f",
"app.toggle_class('Screen', '-show-tree')",
"Toggle Files",
),
BindingDecl::new("q", "app.quit", "Quit"),
]
}
fn compose(&mut self) -> AppRoot {
let tree = Node::new(DirectoryTree::new(&self.start_path)).id("tree-view");
let code = Static::new("").id("code");
let code_view = Node::new(VerticalScroll::new().with_child(code)).id("code-view");
AppRoot::new()
.with_child(Header::new())
.with_child(Container::new().with_child(tree).with_child(code_view))
.with_child(Footer::new())
}
fn on_mount_with_app(&mut self, app: &mut App, _ctx: &mut EventCtx) {
app.set_title("Code Browser");
let _ = app.action_focus("tree-view");
let _ = app.query_mut("Screen").map(|q| q.add_class("-show-tree"));
}
fn on_message_with_app(&mut self, app: &mut App, message: &MessageEvent, _ctx: &mut EventCtx) {
if let Message::DirectoryTreeFileSelected(ev) = &message.message {
Self::load_path(app, &ev.path);
}
}
}
fn main() -> textual::Result<()> {
let start_path = std::env::args().nth(1).unwrap_or_else(|| "./".to_string());
run_sync(CodeBrowserApp::new(start_path))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn code_browser_app_composes_without_panic() {
let mut app = CodeBrowserApp::new("./");
let _root = app.compose();
}
#[test]
fn bindings_include_toggle_files_and_quit() {
let app = CodeBrowserApp::new("./");
let bindings = app.bindings();
let actions: Vec<&str> = bindings.iter().map(|b| b.action.as_str()).collect();
assert!(
actions
.iter()
.any(|a| a.contains("toggle_class") && a.contains("-show-tree")),
"expected toggle_class binding for '-show-tree': {:?}",
actions,
);
assert!(
actions.iter().any(|a| *a == "app.quit"),
"expected app.quit binding: {:?}",
actions,
);
}
#[test]
fn bindings_f_key_triggers_toggle_files() {
let app = CodeBrowserApp::new("./");
let bindings = app.bindings();
let toggle_binding = bindings
.iter()
.find(|b| b.action.contains("toggle_class") && b.action.contains("-show-tree"));
assert!(toggle_binding.is_some(), "toggle binding not found");
let keys = &toggle_binding.unwrap().key;
assert!(
keys.split(',').any(|k| k.trim() == "f"),
"expected 'f' key for toggle: {keys:?}",
);
}
#[test]
fn bindings_q_key_triggers_quit() {
let app = CodeBrowserApp::new("./");
let bindings = app.bindings();
let quit_binding = bindings.iter().find(|b| b.action == "app.quit");
assert!(quit_binding.is_some(), "quit binding not found");
let keys = &quit_binding.unwrap().key;
assert!(
keys.split(',').any(|k| k.trim() == "q"),
"expected 'q' key for quit: {keys:?}",
);
}
#[test]
fn code_browser_uses_vertical_scroll() {
let mut app = CodeBrowserApp::new("./");
let _root = app.compose();
}
#[test]
fn code_browser_error_display_includes_path() {
let path = "/nonexistent/file.rs";
let e = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let error_msg = format!("[b red]Error reading file:[/b red]\n{path}\n\n{e}");
assert!(error_msg.contains(path));
assert!(error_msg.contains("file not found"));
}
}