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::<NoAppEvent>()
.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<Box<FsObject>>),
Pipe,
}
struct FsObject {
pub path: PathBuf,
pub kind: FsObjectKind,
}
impl FsObject {
pub fn file(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
kind: FsObjectKind::File,
}
}
pub fn folder(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
kind: FsObjectKind::Folder,
}
}
pub fn pipe(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
kind: FsObjectKind::Pipe,
}
}
pub fn link(path: impl Into<PathBuf>, target: impl Into<Option<FsObject>>) -> 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<MenuPage>,
pub active_menu: usize,
pub folders: Vec<PathBuf>,
pub files: Vec<PathBuf>,
pub current_folder: Option<PathBuf>,
pub current_folder_entries: Vec<FsObject>,
pub current_file: Option<PathBuf>,
text: String,
}
impl AppState {
pub fn open_folder(&mut self, path: impl AsRef<Path>) {
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<Path>) {
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<Action> = 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<Action>| {
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<Action>| {
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<Action>| {
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<Action> = 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<Action>,
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<dyn Fn(&mut AppState)>,
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<A: AppEvent> Widget<AppState, A> for Menu {
fn update(
&mut self,
model: &mut AppState,
input: &Event<A>,
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<A: AppEvent> Widget<AppState, A> for CommandLine {
fn update(
&mut self,
model: &mut AppState,
input: &Event<A>,
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<AppState, NoAppEvent> {
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();
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);
}
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<A: AppEvent>(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
}
pub struct Tabs<'a, M, A: AppEvent> {
tabs: Grid<Box<dyn Widget<M, A> + 'a>>,
pub active_tab: Option<usize>,
}
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<M, A> + 'a) -> Self {
let row = self.tabs.rows();
self.tabs = self.tabs.add_box((0, row), (1, 1), tab);
self
}
}
impl<M, A: AppEvent> AnchorPlacementEnabled for Tabs<'_, M, A> {}
impl<M, A: AppEvent> Widget<M, A> for Tabs<'_, M, A> {
fn update(
&mut self,
model: &mut M,
input: &Event<A>,
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) {
self.active_tab = None
} else {
self.active_tab = Some(tab.1);
}
}
}
_ => {}
},
_ => {}
}
self.tabs.update_asserted(model, input, screen, painter)
}
}