//! A pretty bloated example! //! //! - Quit and help is in the other menu. //! - Menu shows and hides with ESC //! - Switch tabs with mouse or F1 F2... //! - Search actions by typing while the menu is open //! - Browse folders open files //! - Close folders and files //! - Switch already open files use std::fs; use std::io; use std::path::{Path, PathBuf}; use termit::prelude::*; #[async_std::main] async fn main() -> io::Result<()> { env_logger::init(); let mut app = AppState { is_terminating: false, show_menu: true, menu_width: 7, menu_page_width: 40, menu: vec![], active_menu: 0, files: vec![], folders: vec![], current_folder: None, current_folder_entries: vec![], current_file: None, text: String::new(), }; app.menu.push(MenuPage::new("H")); let menu_file = MenuPage::new("F"); app.active_menu = app.menu.len(); app.menu.push(menu_file); app.menu.push(MenuPage::new("C")); let mut termit = Terminal::try_system_default()? .into_termit::() .enter_raw_mode()? .capture_mouse(true)? .use_alternate_screen(true)? .enable_line_wrap(false)? .show_cursor(false)? .use_alternate_screen(true)?; let mut ui = make_ui(); while !app.is_terminating { termit.step(&mut app, &mut ui).await?; } Ok(()) } enum FsObjectKind { Folder, File, Link(Option>), Pipe, } struct FsObject { pub path: PathBuf, pub kind: FsObjectKind, } impl FsObject { pub fn file(path: impl Into) -> Self { Self { path: path.into(), kind: FsObjectKind::File, } } pub fn folder(path: impl Into) -> Self { Self { path: path.into(), kind: FsObjectKind::Folder, } } pub fn pipe(path: impl Into) -> Self { Self { path: path.into(), kind: FsObjectKind::Pipe, } } pub fn link(path: impl Into, target: impl Into>) -> Self { Self { path: path.into(), kind: FsObjectKind::Link(target.into().map(Box::new)), } } } #[derive(Default)] struct AppState { pub is_terminating: bool, pub menu_width: u16, pub show_menu: bool, pub menu_page_width: u16, pub menu: Vec, pub active_menu: usize, pub folders: Vec, pub files: Vec, pub current_folder: Option, pub current_folder_entries: Vec, pub current_file: Option, text: String, } impl AppState { pub fn open_folder(&mut self, path: impl AsRef) { let name = path.as_ref().to_owned(); self.current_folder = Some(name.clone()); self.current_folder_entries = fs::read_dir(name) .expect("read dir") .map(|entry| entry.expect("read dir entry")) .map(|entry| { if entry.path().is_dir() { FsObject::folder(entry.path()) } else if entry.path().is_file() { FsObject::file(entry.path()) } else if entry.path().is_symlink() { let mut link = entry.path(); loop { match link.read_link().ok() { Some(target) => { if target.is_symlink() { link = target; continue; } else if target.is_dir() { break FsObject::link(entry.path(), FsObject::folder(target)); } else if target.is_file() { break FsObject::link(entry.path(), FsObject::file(target)); } else { break FsObject::link(entry.path(), FsObject::pipe(target)); } } None => break FsObject::link(entry.path(), None), } } } else { FsObject::pipe(entry.path()) } }) .collect(); self.current_folder_entries .sort_by_key(|entry| entry.path.clone()); self.menu[self.active_menu].active_action_index = 0; self.menu[self.active_menu].active_action_description = "close this folder".to_owned(); } pub fn close_folder(&mut self) { if let Some(path) = self.current_folder.take() { self.folders.retain(|item| item != &path); } self.current_folder_entries.clear(); } pub fn open_file(&mut self, path: impl AsRef) { let name = path.as_ref().to_owned(); self.current_file = Some(name.clone()); if !self.files.contains(&name) { self.files.push(name); } self.text = fs::read_to_string(path).expect("read file"); } pub fn close_file(&mut self) { if let Some(path) = self.current_file.take() { if let Some((pos, _)) = self.files.iter().enumerate().find(|(_, f)| f.eq(&&path)) { self.files.remove(pos); if pos < self.files.len() { self.open_file(self.files[pos].clone()); } else if let Some(path) = self.files.last().cloned() { self.open_file(path); } else { self.text.clear(); } } } } pub fn current_menu_page_mut(&mut self) -> &mut MenuPage { &mut self.menu[self.active_menu] } } fn update_actions(model: &mut AppState) { match model.current_menu_page_mut().name.as_str() { "F" => { update_file_actions(model); } "H" => { update_main_actions(model); } _ => {} } let page = model.current_menu_page_mut(); if page.active_action_index >= page.actions.len() || page.actions[page.active_action_index].description != page.active_action_description { page.active_action_index = page .actions .iter() .enumerate() .find(|(_, a)| a.description == page.active_action_description) .map(|(i, _)| i) .unwrap_or_default(); if !page.actions.is_empty() { page.active_action_description = page.actions[page.active_action_index].description.clone(); } }; } fn update_main_actions(model: &mut AppState) { let style = Style::default(); let search = &model.menu[model.active_menu].command; let mut actions = vec![]; actions.push(Action::new("quit", style.front(Color::red(false)), |m| { m.is_terminating = true })); actions.push(Action::new( "help", style.front(Color::yellow(false)), |m| m.text = "Hey, this is a termit editor showcase!".to_owned(), )); let low_search = search.to_lowercase(); let mut actions: Vec = actions .into_iter() .filter(|a| a.description.to_lowercase().contains(&low_search)) .collect(); if !search.is_empty() { actions.push(Action::new( "cancel", style.front(Color::red(false)), |_| {}, )); } model.current_menu_page_mut().actions = actions; } fn update_file_actions(model: &mut AppState) { let plain_style = Style::DEFAULT.front(Color::grey(false)); let dir_style = plain_style.front(Color::blue(false)); let file_style = plain_style.front(Color::cyan(false)); let bad_style = plain_style.front(Color::magenta(false)); let cancel_style = plain_style.front(Color::red(false)); let search = &model.menu[model.active_menu].command; let mut actions = vec![]; if model.current_file.is_some() { actions.push(Action::new( "save this file".to_string(), plain_style, move |_| {}, )) } if model.current_file.is_some() { actions.push(Action::new( "close this file".to_string(), plain_style, move |m| m.close_file(), )) } for path in model.files.iter().cloned() { let name = path .file_name() .unwrap_or(path.as_os_str()) .to_string_lossy() .to_string(); actions.push(Action::new(name, file_style, move |m| m.open_file(&path))) } for path in model.folders.iter().cloned() { let name = path .file_name() .unwrap_or(path.as_os_str()) .to_string_lossy() .to_string(); actions.push(Action::new(name, dir_style, move |m| m.open_folder(&path))) } if let Some(ref path) = model.current_folder { actions.push(Action::new( "close this folder".to_string(), plain_style, move |m| m.close_folder(), )); if let Some(path) = path .canonicalize() .expect("canonical path") .parent() .map(|p| p.to_owned()) { actions.push(Action::new("..".to_string(), plain_style, move |m| { m.open_folder(path.clone()) })) } } for entry in model.current_folder_entries.iter() { let name = entry .path .file_name() .unwrap_or(entry.path.as_os_str()) .to_string_lossy() .to_string(); let folder_opener = |actions: &mut Vec| { let path = entry.path.to_owned(); actions.push(Action::new(name.as_str(), dir_style, move |m| { m.open_folder(path.clone()) })) }; let file_opener = |actions: &mut Vec| { let path = entry.path.to_owned(); actions.push(Action::new(name.as_str(), file_style, move |m| { m.open_file(path.clone()) })) }; let bad_opener = |actions: &mut Vec| { actions.push(Action::new(name.as_str(), bad_style, move |_| {})) }; match &entry.kind { FsObjectKind::Folder => folder_opener(&mut actions), FsObjectKind::File => file_opener(&mut actions), FsObjectKind::Pipe => bad_opener(&mut actions), FsObjectKind::Link(target) => match target { None => bad_opener(&mut actions), Some(target) => match target.kind { FsObjectKind::Folder => folder_opener(&mut actions), FsObjectKind::File => file_opener(&mut actions), FsObjectKind::Link(_) => bad_opener(&mut actions), FsObjectKind::Pipe => bad_opener(&mut actions), }, }, } } actions.push(Action::new("browse current folder", plain_style, |m| { m.open_folder(".") })); actions.push(Action::new("browse home folder", plain_style, |m| { m.open_folder("/home/jo") })); actions.push(Action::new("browse root folder", plain_style, |m| { m.open_folder("/") })); let low_search = search.to_lowercase(); let mut actions: Vec = actions .into_iter() .filter(|a| a.description.to_lowercase().contains(&low_search)) .collect(); if !search.is_empty() { actions.push(Action::new("cancel", cancel_style, |_| {})); } model.current_menu_page_mut().actions = actions; } struct MenuPage { name: String, actions: Vec, active_action_index: usize, active_action_description: String, command: String, } impl MenuPage { pub fn new(name: impl ToString) -> Self { Self { name: name.to_string(), actions: vec![], active_action_description: String::new(), active_action_index: 0, command: String::new(), } } } struct Action { description: String, action: Box, style: Style, } impl Action { pub fn new( description: impl ToString, style: Style, action: impl Fn(&mut AppState) + 'static, ) -> Self { Self { description: description.to_string(), action: Box::new(action), style, } } fn run(&self, model: &mut AppState) { (self.action)(model) } } #[derive(Default)] struct Menu; impl AnchorPlacementEnabled for Menu {} impl Widget for Menu { fn update( &mut self, model: &mut AppState, input: &Event, screen: &mut Screen, painter: &Painter, ) -> Window { if match input { Event::Key(key) => key.keycode == KeyCode::Esc, Event::Mouse(mouse) => match mouse.kind { MouseEventKind::Up(_) => point(mouse.column, mouse.row).is_in(painter.scope()), _ => false, }, _ => false, } { model.show_menu = !model.show_menu; } else { match input { Event::Key(key) => match key.keycode { KeyCode::F(n) => { model.show_menu = true; model.active_menu = (n as usize).clamp(1, model.menu.len()) - 1; } _ => {} }, _ => {} } } " ESC " .front(Color::grey(false)) .back(Color::rgb(10, 10, 10)) .update_asserted(model, input, screen, painter) } } #[derive(Default)] struct CommandLine; impl AnchorPlacementEnabled for CommandLine {} impl Widget for CommandLine { fn update( &mut self, model: &mut AppState, input: &Event, screen: &mut Screen, painter: &Painter, ) -> Window { if model.show_menu { let action_count = model.current_menu_page_mut().actions.len(); match input { Event::Key(key) => match key.keycode { KeyCode::Enter if action_count != 0 => { let page = model.current_menu_page_mut(); if let Some(a) = page.actions.get_mut(page.active_action_index) { let action = std::mem::replace( a, Action::new("dummy", Style::default(), |_| {}), ); action.run(model); let page = model.current_menu_page_mut(); page.command.clear(); page.actions[page.active_action_index] = action; } } KeyCode::Down if action_count != 0 => { let page = model.current_menu_page_mut(); page.active_action_index = (page.active_action_index.saturating_add(1)) % action_count; page.active_action_description = page.actions[page.active_action_index].description.clone() } KeyCode::Up if action_count != 0 => { let page = model.current_menu_page_mut(); page.active_action_index = (page .active_action_index .saturating_add(action_count) .saturating_sub(1)) % action_count; page.active_action_description = page.actions[page.active_action_index].description.clone() } _ => {} }, _ => {} } } let len = model.menu[model.active_menu].command.len(); let fg = Color::blue(false); let bg = Color::rgb( len.clamp(0, 250) as u8, (len.clamp(250, 500) - 250) as u8, (len.clamp(500, 750) - 500) as u8, ); let painter = painter.front(fg).back(bg); let mut prompt = " ".repeat((painter.scope().width as usize).saturating_sub(1)); prompt.insert(0, '>'); painter.paint(&prompt, screen, 0, false); let painter = painter.with_scope( painter .scope() .relative_crop(&window(point(2, 0), point(100, 1))), ); let mut textbox = TextBox::new(|cmd| Some(cmd)); textbox.active = model.show_menu; let scope = textbox.update_asserted( &mut model.current_menu_page_mut().command, input, screen, &painter, ); update_actions(model); scope } } fn make_ui() -> impl Widget { Canvas::default() .front(Color::grey(false)) .back(Color::black()) .contain( Stack::south_east_boxes(vec![]) .add_box( Menu.left(0) .top(0) .height(1) .replace_with(|w, m| w.width(m.menu_width)), ) .add_box( Canvas::new(Tabs::default().bind_with( |w, m: &AppState| *w = make_tabs(m), |w, m: &mut AppState| { if let Some(tab) = w.active_tab { m.active_menu = tab; m.show_menu = true; } else { m.show_menu = false; } }, )) .back(Color::rgb(10, 10, 10)) .left(0) .top(0) .load_with(|w, m| { w.placement = w.placement.width(m.menu_width).height(if m.show_menu { u16::MAX } else { 0 }) }), ) .add_box( Canvas::new(CommandLine::default().top(1).left(2).right(2).height(1)) .back(Color::rgb(20, 20, 20)) .left(0) .top(0) .load_with(|w, m| { w.placement = w .placement .width(if m.show_menu { m.menu_page_width } else { 0 }) .height(if m.show_menu { 3 } else { 0 }) }), ) .add_box( Canvas::default() .back(Color::rgb(20, 20, 20)) .contain(Stack::default().load_with(|w, m: &AppState| { let mut stack = Stack::south_east(vec![]); let page = &m.menu[m.active_menu]; let max_width = m.menu_page_width - 4; for (i, action) in page.actions.iter().enumerate() { let mut text = action.description.clone(); // for _ in m.menu_page_width as usize..text.chars().count() + 4 { // text.pop(); // } text += &" ".repeat( (max_width as usize).saturating_sub(action.description.len()), ); let mut bg = Color::rgb(20, 20, 20); if i == page.active_action_index { bg = Color::rgb(10, 10, 10); // text.pop(); // text.pop(); // text += " ★"; } stack = stack .add(text.apply(action.style.back(bg)).top(0).left(2).right(2)); } *w = stack })) .left(0) .top(0) .load_with(|w, m| { w.placement = w .placement .width(if m.show_menu { m.menu_page_width } else { 0 }) .height(if m.show_menu { u16::MAX } else { 0 }) }), ) .add_box( " file " .back(Color::rgb(10, 10, 10)) .left(0) .top(0) .load_with(|w, m: &AppState| { w.placement = w.placement.width(m.menu_width).height(if m.show_menu { 1 } else { 0 }) }), ) .add_box( (1..100) .fold(String::with_capacity(9 * 50), |mut s, l| { s += &format!("{:>6} \r\n", l); s }) .front(Color::grey(false)) .back(Color::rgb(10, 10, 10)) .left(0) .top(0) .replace_with(|w, m: &AppState| w.width(m.menu_width)), ) .add_box( Canvas::new(String::default()) .front(Color::blue(false)) .load_with(|w, m: &AppState| { *w.content_mut() = m .current_file .as_ref() .map(|path| path.to_string_lossy().to_string()) .unwrap_or_default() }) .left(0) .top(0) .height(1), ) .add_box( Canvas::new(String::new().bind_property(|w| w, |m: &mut AppState| &mut m.text)) .front(Color::grey(true)), ), ) } fn make_tabs(app: &AppState) -> Tabs<'static, AppState, A> { let mut tabs = Tabs::default(); tabs.active_tab = match app.show_menu { true => Some(app.active_menu), false => None, }; for (i, panel) in app.menu.iter().enumerate() { tabs = tabs.add( Canvas::new(panel.name.to_owned().width(1).height(1)).load_with( move |w, m: &AppState| { if m.active_menu == i { *w.style_mut() = Style::DEFAULT .front(Color::white()) .back(Color::rgb(20, 20, 20)); } else { *w.style_mut() = Style::default(); } }, ), ); } tabs } /// A somewhat immature tab widget implementation /// /// Each tab will take equal vertical space. Clicking on a tab activates it. /// /// Only vertical tabs for now. pub struct Tabs<'a, M, A: AppEvent> { tabs: Grid + 'a>>, pub active_tab: Option, } impl<'a, M, A: AppEvent> Default for Tabs<'a, M, A> { fn default() -> Self { Self { tabs: Default::default(), active_tab: Default::default(), } } } impl<'a, M, A: AppEvent> Tabs<'a, M, A> { pub fn add(mut self, tab: impl Widget + 'a) -> Self { let row = self.tabs.rows(); self.tabs = self.tabs.add_box((0, row), (1, 1), tab); self } } impl AnchorPlacementEnabled for Tabs<'_, M, A> {} impl Widget for Tabs<'_, M, A> { fn update( &mut self, model: &mut M, input: &Event, screen: &mut Screen, painter: &Painter, ) -> Window { match input { Event::Mouse(mouse) => match mouse.kind { MouseEventKind::Up(MouseButton::Left) => { if let Some(tab) = self .tabs .get_cell_for_point((mouse.column, mouse.row).into(), painter.scope()) { if self.active_tab == Some(tab.1) { // toggle off self.active_tab = None } else { self.active_tab = Some(tab.1); } } } _ => {} }, _ => {} } self.tabs.update_asserted(model, input, screen, painter) } }