You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
xplr/src/ui.rs

418 lines
14 KiB
Rust

use crate::app;
use crate::app::HelpMenuLine;
use crate::app::{Node, SymlinkNode};
use handlebars::Handlebars;
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::collections::HashMap;
use tui::backend::Backend;
use tui::layout::Rect;
use tui::layout::{Constraint as TuiConstraint, Direction, Layout};
use tui::style::{Color, Style};
use tui::text::{Span, Spans};
use tui::widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table};
use tui::Frame;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SymlinkNodeUiMetadata {
pub absolute_path: String,
pub extension: String,
pub is_dir: bool,
pub is_file: bool,
pub is_readonly: bool,
pub mime_essence: String,
}
impl From<SymlinkNode> for SymlinkNodeUiMetadata {
fn from(node: SymlinkNode) -> Self {
Self {
absolute_path: node.absolute_path.clone(),
extension: node.extension.clone(),
is_dir: node.is_dir,
is_file: node.is_file,
is_readonly: node.is_readonly,
mime_essence: node.mime_essence,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct NodeUiMetadata {
// From Node
pub parent: String,
pub relative_path: String,
pub absolute_path: String,
pub extension: String,
pub is_symlink: bool,
pub is_broken: bool,
pub is_dir: bool,
pub is_file: bool,
pub is_readonly: bool,
pub mime_essence: String,
pub symlink: Option<SymlinkNodeUiMetadata>,
// Extra
pub index: usize,
pub relative_index: usize,
pub is_before_focus: bool,
pub is_after_focus: bool,
pub tree: String,
pub prefix: String,
pub suffix: String,
pub is_selected: bool,
pub is_focused: bool,
pub total: usize,
pub meta: HashMap<String, String>,
}
impl NodeUiMetadata {
fn new(
node: &Node,
index: usize,
relative_index: usize,
is_before_focus: bool,
is_after_focus: bool,
tree: String,
prefix: String,
suffix: String,
is_selected: bool,
is_focused: bool,
total: usize,
meta: HashMap<String, String>,
) -> Self {
Self {
parent: node.parent.clone(),
relative_path: node.relative_path.clone(),
absolute_path: node.absolute_path.clone(),
extension: node.extension.clone(),
is_symlink: node.is_symlink,
is_broken: node.is_broken,
is_dir: node.is_dir,
is_file: node.is_file,
is_readonly: node.is_readonly,
mime_essence: node.mime_essence.clone(),
symlink: node.symlink.to_owned().map(|s| s.into()),
index,
relative_index,
is_before_focus,
is_after_focus,
tree,
prefix,
suffix,
is_selected,
is_focused,
total,
meta,
}
}
}
fn draw_table<B: Backend>(f: &mut Frame<B>, rect: Rect, app: &app::App, hb: &Handlebars) {
let config = app.config().to_owned();
let header_height = config.general.table.header.height.unwrap_or(1);
let height: usize = (rect.height.max(header_height + 2) - (header_height + 2)).into();
let rows = app
.directory_buffer()
.map(|dir| {
dir.nodes
.iter()
.enumerate()
.skip(height * (dir.focus / height))
.take(height)
.map(|(index, node)| {
let is_focused = dir.focus == index;
// TODO : Optimize
let is_selected = app.selection().contains(&node);
let ui = if is_focused {
&config.general.focus_ui
} else if is_selected {
&config.general.selection_ui
} else {
&config.general.default_ui
};
let is_first = index == 0;
let is_last = index == dir.total.max(1) - 1;
let tree = config
.general
.table
.tree
.clone()
.map(|t| {
if is_last {
t.2.format
} else if is_first {
t.0.format
} else {
t.1.format
}
})
.unwrap_or_default();
let node_type = config
.node_types
.special
.get(&node.relative_path)
.or_else(|| config.node_types.extension.get(&node.extension))
.or_else(|| config.node_types.mime_essence.get(&node.mime_essence))
.unwrap_or_else(|| {
if node.is_symlink {
&config.node_types.symlink
} else if node.is_dir {
&config.node_types.directory
} else {
&config.node_types.file
}
});
let (relative_index, is_before_focus, is_after_focus) =
match dir.focus.cmp(&index) {
Ordering::Greater => (dir.focus - index, true, false),
Ordering::Less => (index - dir.focus, false, true),
Ordering::Equal => (0, false, false),
};
let meta = NodeUiMetadata::new(
&node,
index,
relative_index,
is_before_focus,
is_after_focus,
tree.unwrap_or_default(),
ui.prefix.to_owned().unwrap_or_default(),
ui.suffix.to_owned().unwrap_or_default(),
is_selected,
is_focused,
dir.total,
node_type.meta.clone(),
);
let cols = hb
.render(app::TEMPLATE_TABLE_ROW, &meta)
.ok()
.unwrap_or_else(|| app::UNSUPPORTED_STR.into())
.split('\t')
.map(|x| Cell::from(x.to_string()))
.collect::<Vec<Cell>>();
let style = if is_focused {
config.general.focus_ui.style
} else if is_selected {
config.general.selection_ui.style
} else {
config
.node_types
.special
.get(&node.relative_path)
.or_else(|| config.node_types.extension.get(&node.extension))
.or_else(|| config.node_types.mime_essence.get(&node.mime_essence))
.unwrap_or_else(|| {
if node.is_symlink {
&config.node_types.symlink
} else if node.is_dir {
&config.node_types.directory
} else {
&config.node_types.file
}
})
.style
};
Row::new(cols).style(style.into())
})
.collect::<Vec<Row>>()
})
.unwrap_or_default();
let table_constraints: Vec<TuiConstraint> = config
.general
.table
.col_widths
.clone()
.unwrap_or_default()
.into_iter()
.map(|c| c.into())
.collect();
let table = Table::new(rows)
.widths(&table_constraints)
.style(config.general.table.style.into())
.highlight_style(config.general.focus_ui.style.into())
.column_spacing(config.general.table.col_spacing.unwrap_or_default())
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" {} ", app.pwd())),
);
let table = table.clone().header(
Row::new(
config
.general
.table
.header
.cols
.unwrap_or_default()
.iter()
.map(|c| Cell::from(c.format.to_owned().unwrap_or_default()))
.collect::<Vec<Cell>>(),
)
.height(header_height)
.style(config.general.table.header.style.into()),
);
f.render_widget(table, rect);
}
fn draw_selection<B: Backend>(f: &mut Frame<B>, rect: Rect, app: &app::App, _: &Handlebars) {
let selection: Vec<ListItem> = app
.selection()
.iter()
.rev()
.take((rect.height.max(2) - 2).into())
.rev()
.map(|n| n.absolute_path.clone())
.map(ListItem::new)
.collect();
let selection_count = selection.len();
// Selected items
let selection_list = List::new(selection).block(
Block::default()
.borders(Borders::ALL)
3 years ago
.title(format!(" Selection ({}) ", selection_count)),
);
3 years ago
f.render_widget(selection_list, rect);
}
fn draw_help_menu<B: Backend>(f: &mut Frame<B>, rect: Rect, app: &app::App, _: &Handlebars) {
let help_menu_rows = app
.mode()
.help_menu()
.into_iter()
.map(|l| match l {
HelpMenuLine::Paragraph(p) => Row::new([Cell::from(p)].to_vec()),
HelpMenuLine::KeyMap(k, h) => {
let remaps = app
.mode()
.key_bindings
.remaps
.iter()
.filter(|(_, t)| t == &&k)
.map(|(f, _)| f.clone())
.collect::<Vec<String>>()
.join("|");
Row::new([Cell::from(k), Cell::from(remaps), Cell::from(h)].to_vec())
}
})
.collect::<Vec<Row>>();
3 years ago
let help_menu = Table::new(help_menu_rows)
.block(
Block::default()
.borders(Borders::ALL)
3 years ago
.title(format!(" Help [{}] ", &app.mode().name)),
)
.widths(&[
TuiConstraint::Percentage(20),
TuiConstraint::Percentage(20),
TuiConstraint::Percentage(60),
]);
f.render_widget(help_menu, rect);
}
3 years ago
fn draw_input_buffer<B: Backend>(f: &mut Frame<B>, rect: Rect, app: &app::App, _: &Handlebars) {
let input_buf = Paragraph::new(Spans::from(vec![
Span::styled(
app.config()
.general
.prompt
.format
.clone()
.unwrap_or_default(),
app.config().general.prompt.style.into(),
),
Span::raw(app.input_buffer().unwrap_or_else(|| "".into())),
Span::styled(
app.config()
.general
.cursor
.format
.clone()
.unwrap_or_default(),
app.config().general.cursor.style.into(),
),
]))
.block(Block::default().borders(Borders::ALL).title(" Input "));
f.render_widget(input_buf, rect);
}
fn draw_logs<B: Backend>(f: &mut Frame<B>, rect: Rect, app: &app::App, _: &Handlebars) {
let logs = app
.logs()
.iter()
.rev()
.take(1)
.rev()
.map(|l| {
let time = l.created_at.format("%r");
let log = format!("{} | {}", &time, l.message);
match &l.level {
app::LogLevel::Info => ListItem::new(log).style(Style::default().fg(Color::Gray)),
app::LogLevel::Success => {
ListItem::new(log).style(Style::default().fg(Color::Green))
}
app::LogLevel::Error => ListItem::new(log).style(Style::default().fg(Color::Red)),
}
})
.collect::<Vec<ListItem>>();
3 years ago
let logs_list = List::new(logs).block(Block::default().borders(Borders::ALL).title(" Logs "));
f.render_widget(logs_list, rect);
}
pub fn draw<B: Backend>(f: &mut Frame<B>, app: &app::App, hb: &Handlebars) {
let rect = f.size();
3 years ago
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([TuiConstraint::Percentage(70), TuiConstraint::Percentage(30)].as_ref())
3 years ago
.split(rect);
let left_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
TuiConstraint::Length(rect.height - 3),
TuiConstraint::Length(3),
]
.as_ref(),
)
.split(chunks[0]);
draw_table(f, left_chunks[0], app, hb);
if app.input_buffer().is_some() {
draw_input_buffer(f, left_chunks[1], app, hb);
} else {
draw_logs(f, left_chunks[1], app, hb);
};
3 years ago
let right_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([TuiConstraint::Percentage(50), TuiConstraint::Percentage(50)].as_ref())
3 years ago
.split(chunks[1]);
3 years ago
draw_selection(f, right_chunks[0], app, hb);
3 years ago
draw_help_menu(f, right_chunks[1], app, hb);
}