diff --git a/src/app.rs b/src/app.rs index c891e60..fdc7a9b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,6 +4,7 @@ use std::{ collections::HashMap, fs::File, path::{Path, PathBuf}, + time::Instant, }; use crate::Opt; @@ -16,21 +17,57 @@ pub enum TabId { const TABS: [TabId; 2] = [TabId::Main, TabId::Script]; -#[derive(PartialEq)] +#[derive(PartialEq, Clone)] pub enum Action { - Skip(String), - Move(String, String), - MkDir(String), + Skip(PathBuf), + Move(PathBuf, PathBuf), + Rename(String), + MkDir(PathBuf), +} + +impl Action { + pub fn is_poppable(&self) -> bool { + !matches!(self, Action::MkDir(_)) + } + + pub fn queue_step(&self) -> usize { + match self { + Action::Skip(_) | Action::Move(_, _) => 1, + _ => 0, + } + } } pub struct App { pub tab: usize, pub script_offset: (u16, u16), - pub images: Vec, + pub images: Vec, pub current: usize, - pub key_mapping: HashMap, + pub key_mapping: HashMap, pub actions: Vec, pub output: String, + pub enable_input: bool, + pub input: Vec, + pub input_idx: usize, + pub last_save: Option, +} + +impl Default for App { + fn default() -> Self { + App { + tab: 0, + script_offset: (0, 0), + current: 0, + images: vec![], + key_mapping: HashMap::new(), + actions: vec![], + output: "".to_string(), + enable_input: false, + input: vec![], + input_idx: 0, + last_save: None, + } + } } impl App { @@ -39,17 +76,15 @@ impl App { let (key_mapping, actions) = App::parse_key_mapping(opt.bind)?; Ok(App { - tab: 0, - script_offset: (0, 0), - current: 0, images, key_mapping, actions, output: opt.output, + ..App::default() }) } - pub fn current_image(&self) -> Option { + pub fn current_image(&self) -> Option { if self.current == self.images.len() { return None; } @@ -58,17 +93,23 @@ impl App { } pub fn pop_action(&mut self) { - if self.current > 0 { - self.actions.pop(); - self.current -= 1; + let last_action = self.actions.last().cloned(); + + if let Some(last_action) = last_action { + if last_action.is_poppable() { + self.actions.pop(); + } + self.current -= last_action.queue_step(); } } pub fn push_action(&mut self, action: Action) { - if self.current < self.images.len() { - self.actions.push(action); - self.current += 1; + if self.current == self.images.len() { + return; } + + self.current += action.queue_step(); + self.actions.push(action); } pub fn current_tab(&self) -> TabId { @@ -106,15 +147,28 @@ impl App { self.script_offset = (y, x + 1); } - pub fn write(&self) -> Result<()> { + pub fn rename_current_image(&mut self) { + if let Some(current_image) = self.current_image() { + if let Some(name) = current_image.file_name() { + let name: Vec = name.to_str().unwrap().chars().collect(); + self.input_idx = name.len(); + self.input = name; + self.enable_input = true; + } + } + } + + pub fn write(&mut self) -> Result<()> { let mut lines: Vec = vec!["#!/bin/sh".to_string()]; for action in self.actions.iter() { match action { - Action::MkDir(folder) => lines.push(format!("mkdir -p {}", folder)), - Action::Move(image_path, folder) => { - lines.push(format!("mv {} {}", image_path, folder)) - } + Action::MkDir(folder) => lines.push(format!("mkdir -p \"{}\"", folder.display())), + Action::Move(image_path, folder) => lines.push(format!( + "mv \"{}\" \"{}\"", + image_path.display(), + folder.display() + )), _ => {} } } @@ -123,18 +177,18 @@ impl App { let mut file = File::create(&self.output)?; file.write_all(script.as_bytes())?; + self.last_save = Some(Instant::now()); Ok(()) } pub fn parse_key_mapping( args: Vec<(char, PathBuf)>, - ) -> Result<(HashMap, Vec)> { + ) -> Result<(HashMap, Vec)> { let mut key_mapping = HashMap::new(); let mut actions = vec![]; - for (key, path) in args.into_iter() { - let path = path.as_path(); - let path_string = path.display().to_string(); + for (key, path_buf) in args.into_iter() { + let path = path_buf.as_path(); if path.exists() && !path.is_dir() { return Err(anyhow!( @@ -144,23 +198,23 @@ impl App { } if !path.exists() { - actions.push(Action::MkDir(path_string.clone())); + actions.push(Action::MkDir(path_buf.clone())); } - key_mapping.insert(key, path_string); + key_mapping.insert(key, path_buf); } Ok((key_mapping, actions)) } - pub fn parse_images(args: Vec) -> Result> { - let mut images: Vec = vec![]; + pub fn parse_images(args: Vec) -> Result> { + let mut images: Vec = vec![]; - for input in args.iter() { + for input in args.into_iter() { let path = input.as_path(); if path.is_file() && App::is_image(&path) { - images.push(path.display().to_string()); + images.push(input.clone()); } if path.is_dir() { @@ -168,12 +222,11 @@ impl App { if let Ok(entry) = entry { let path = entry.path(); - if !App::is_image(&path) { + if !App::is_image(path.as_path()) { continue; } - let path_str = path.display().to_string(); - images.push(path_str); + images.push(path); } } } diff --git a/src/event.rs b/src/event.rs index 1fe324f..130e775 100644 --- a/src/event.rs +++ b/src/event.rs @@ -12,12 +12,6 @@ pub struct EventsListener { rx: Receiver, } -impl Default for EventsListener { - fn default() -> Self { - EventsListener::new(Duration::from_millis(500)) - } -} - impl EventsListener { pub fn new(tick_rate: Duration) -> Self { let (tx, rx) = unbounded::(); diff --git a/src/image_display.rs b/src/image_display.rs index 8a76747..8cae0a7 100644 --- a/src/image_display.rs +++ b/src/image_display.rs @@ -1,5 +1,8 @@ use anyhow::{anyhow, Result}; -use std::{env, path::Path}; +use std::{ + env, + path::{Path, PathBuf}, +}; use subprocess::{Popen, PopenConfig, Redirection}; use tui::layout::Rect; @@ -39,7 +42,7 @@ impl ImageDisplay { } } - pub fn render_image(&mut self, image_path: String, block: Rect, terminal: Rect) -> Result<()> { + pub fn render_image(&mut self, image_path: PathBuf, block: Rect, terminal: Rect) -> Result<()> { let input = self.w3m_input(image_path, block, terminal)?; let mut process = Popen::create( &[&self.path], @@ -54,7 +57,7 @@ impl ImageDisplay { Ok(()) } - fn w3m_input(&mut self, image_path: String, block: Rect, terminal: Rect) -> Result { + fn w3m_input(&mut self, image_path: PathBuf, block: Rect, terminal: Rect) -> Result { let (fontw, fonth) = self.font_dimensions(terminal)?; let start_x = (block.x as u32 + 1) * fontw; @@ -79,14 +82,18 @@ impl ImageDisplay { let input = format!( "0;1;{};{};{};{};;;;;{}\n4;\n3;\n", - start_x, start_y, width, height, image_path + start_x, + start_y, + width, + height, + image_path.display() ); Ok(input) } - fn image_dimensions(&mut self, image_path: &str) -> Result<(u32, u32)> { - let input = format!("5;{}\n", image_path); + fn image_dimensions(&mut self, image_path: &PathBuf) -> Result<(u32, u32)> { + let input = format!("5;{}\n", image_path.display()); let mut process = Popen::create( &[&self.path], PopenConfig { diff --git a/src/input.rs b/src/input.rs index 52e889f..de99f49 100644 --- a/src/input.rs +++ b/src/input.rs @@ -23,8 +23,11 @@ fn handle_app_key(key: char, app: &mut App) { } fn handle_mapping_key(key: char, app: &mut App) { - if let Some(path) = app.key_mapping.get(&key).cloned() { + if let Some(mut path) = app.key_mapping.get_mut(&key).cloned() { if let Some(image_path) = app.current_image() { + if let Some(Action::Rename(name)) = app.actions.last() { + path.push(name); + } app.push_action(Action::Move(image_path, path)); } } @@ -39,3 +42,74 @@ pub fn handle_key_script(key: Key, app: &mut App) { _ => {} } } + +pub fn handle_key_input(key: Key, app: &mut App) { + match key { + Key::Ctrl('k') => { + app.input.drain(app.input_idx..app.input.len()); + } + Key::Ctrl('u') => { + app.input.drain(..app.input_idx); + app.input_idx = 0; + } + Key::Ctrl('l') => { + app.input = vec![]; + app.input_idx = 0; + } + Key::Ctrl('w') => { + if app.input_idx == 0 { + return; + } + let word_end = match app.input[..app.input_idx].iter().rposition(|&x| x != ' ') { + Some(index) => index + 1, + None => 0, + }; + let word_start = match app.input[..word_end].iter().rposition(|&x| x == ' ') { + Some(index) => index + 1, + None => 0, + }; + app.input.drain(word_start..app.input_idx); + app.input_idx = word_start; + } + Key::End | Key::Ctrl('e') => { + app.input_idx = app.input.len(); + } + Key::Home | Key::Ctrl('a') => { + app.input_idx = 0; + } + Key::Left | Key::Ctrl('b') => { + if app.input_idx > 0 { + app.input_idx -= 1; + } + } + Key::Right | Key::Ctrl('f') => { + if app.input_idx < app.input.len() { + app.input_idx += 1; + } + } + Key::Esc => { + app.enable_input = false; + } + Key::Char('\n') => { + let input_str: String = app.input.iter().collect(); + app.actions.push(Action::Rename(input_str)); + app.enable_input = false; + } + Key::Backspace | Key::Ctrl('h') => { + if !app.input.is_empty() && app.input_idx > 0 { + app.input.remove(app.input_idx - 1); + app.input_idx -= 1; + } + } + Key::Delete | Key::Ctrl('d') => { + if !app.input.is_empty() && app.input_idx < app.input.len() { + app.input.remove(app.input_idx); + } + } + Key::Char(c) => { + app.input.insert(app.input_idx, c); + app.input_idx += 1; + } + _ => {} + } +} diff --git a/src/main.rs b/src/main.rs index 06c1f3f..f8f7ecb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,15 +5,15 @@ mod input; mod render; use anyhow::{anyhow, Result}; -use std::{io, path::PathBuf}; +use std::{io, path::PathBuf, time::Duration}; use structopt::StructOpt; -use termion::{event::Key, raw::IntoRawMode, screen::AlternateScreen}; +use termion::{cursor::Goto, event::Key, raw::IntoRawMode, screen::AlternateScreen}; use tui::{backend::TermionBackend, Terminal}; use crate::app::{App, TabId}; use crate::event::{Event, EventsListener}; use crate::image_display::ImageDisplay; -use crate::input::{handle_key_main, handle_key_script}; +use crate::input::{handle_key_input, handle_key_main, handle_key_script}; use crate::render::{render_layout, render_main, render_script}; fn parse_key_val(s: &str) -> Result<(char, PathBuf)> { @@ -52,19 +52,21 @@ pub struct Opt { default_value = "sort.sh" )] output: String, + + #[structopt(short, long, help = "App tick rate (ms)", default_value = "1000")] + tick_rate: u64, } fn main() -> Result<()> { let opt = Opt::from_args(); + let events_listener = EventsListener::new(Duration::from_millis(opt.tick_rate)); let mut app = App::new(opt)?; let stdout = io::stdout().into_raw_mode()?; let stdout = AlternateScreen::from(stdout); let backend = TermionBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - terminal.hide_cursor()?; - let events_listener = EventsListener::default(); let mut image_display = ImageDisplay::new()?; loop { @@ -79,15 +81,36 @@ fn main() -> Result<()> { } })?; + if app.enable_input { + terminal.show_cursor()?; + let size = terminal.size()?; + print!("{}", Goto(app.input_idx as u16 + 2, size.height - 1)) + } else { + terminal.hide_cursor()?; + } + match events_listener.next()? { Event::Tick => continue, - Event::Input(Key::Ctrl('c')) => break, - Event::Input(Key::Ctrl('w')) => app.write()?, - Event::Input(Key::BackTab) => app.switch_tab(), - Event::Input(key) => match app.current_tab() { - TabId::Main => handle_key_main(key, &mut app), - TabId::Script => handle_key_script(key, &mut app), - }, + Event::Input(key) => { + if key == Key::Ctrl('c') { + break; + } + + if app.enable_input { + handle_key_input(key, &mut app); + } else { + // App controls + match key { + Key::Ctrl('w') => app.write()?, + Key::Ctrl('r') => app.rename_current_image(), + Key::Char('\t') => app.switch_tab(), + _ => match app.current_tab() { + TabId::Main => handle_key_main(key, &mut app), + TabId::Script => handle_key_script(key, &mut app), + }, + } + } + } } } diff --git a/src/render.rs b/src/render.rs index 3590ee6..f0a046e 100644 --- a/src/render.rs +++ b/src/render.rs @@ -1,8 +1,7 @@ use anyhow::Result; -use std::io; -use termion::{raw::RawTerminal, screen::AlternateScreen}; +use std::time::Duration; use tui::{ - backend::TermionBackend, + backend::Backend, layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Style}, terminal::Frame, @@ -13,10 +12,10 @@ use tui::{ use crate::app::{Action, App}; use crate::image_display::ImageDisplay; -pub fn render_layout( - f: &mut Frame>>>, - app: &App, -) -> Rect { +pub fn render_layout(f: &mut Frame, app: &App) -> Rect +where + B: Backend, +{ let window = f.size(); let layout = Layout::default() .direction(Direction::Vertical) @@ -32,36 +31,48 @@ pub fn render_layout( let tabs = Tabs::new(titles) .block(Block::default().title("image-sorter").borders(Borders::ALL)) .select(app.tab) - .style(Style::default().fg(Color::White)) .highlight_style(Style::default().fg(Color::Red)); f.render_widget(tabs, layout[0]); layout[1] } -pub fn render_main( - f: &mut Frame>>>, +pub fn render_main( + f: &mut Frame, app: &App, image_display: &mut ImageDisplay, window: Rect, -) -> Result<()> { +) -> Result<()> +where + B: Backend, +{ let image_title = match app.current_image() { None => "No more images left to sort".to_string(), - Some(image_path) => image_path, + Some(image_path) => { + let image_path = image_path.display().to_string(); + if let Some(Action::Rename(name)) = app.actions.last() { + format!("{} - Renamed to {}", image_path, name) + } else { + image_path + } + } }; let image_block = Block::default().borders(Borders::ALL).title(image_title); - let status_block = Block::default().borders(Borders::ALL).title("Status"); - let key_mapping_block = Block::default().borders(Borders::ALL).title("Key mapping"); - let controls_block = Block::default().borders(Borders::ALL).title("Controls"); let window_layout = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Min(10), Constraint::Length(30)].as_ref()) .split(window); + let main_layout_constraints = if app.enable_input { + vec![Constraint::Min(10), Constraint::Length(3)] + } else { + vec![Constraint::Min(10)] + }; + let main_layout = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Min(10)].as_ref()) + .constraints(main_layout_constraints.as_ref()) .split(window_layout[0]); let sidebar_layout = Layout::default() @@ -69,21 +80,16 @@ pub fn render_main( .constraints( [ Constraint::Length(3), - Constraint::Min(3), - Constraint::Length(9), + Constraint::Min(5), + Constraint::Length(10), ] .as_ref(), ) .split(window_layout[1]); - let status_container = status_block.inner(sidebar_layout[0]); - render_status(f, app, status_container); - - let key_mapping_container = key_mapping_block.inner(sidebar_layout[1]); - render_key_mapping(f, app, key_mapping_container); - - let controls_container = controls_block.inner(sidebar_layout[2]); - render_controls(f, controls_container); + render_status(f, app, sidebar_layout[0]); + render_key_mapping(f, app, sidebar_layout[1]); + render_controls(f, sidebar_layout[2]); if let Some(image_path) = app.current_image() { let terminal_size = f.size(); @@ -92,50 +98,76 @@ pub fn render_main( } f.render_widget(image_block, main_layout[0]); - f.render_widget(status_block, sidebar_layout[0]); - f.render_widget(key_mapping_block, sidebar_layout[1]); - f.render_widget(controls_block, sidebar_layout[2]); + if app.enable_input { + render_rename_input(f, app, main_layout[1]); + } Ok(()) } -fn render_status( - f: &mut Frame>>>, - app: &App, - window: Rect, -) { - let status = Text::from(format!("Sorted: {}/{}", app.current, app.images.len())); - let paragraph = Paragraph::new(status).alignment(Alignment::Center); +fn render_rename_input(f: &mut Frame, app: &App, window: Rect) +where + B: Backend, +{ + let input_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)) + .title("Rename"); + let text: String = app.input.iter().collect(); + let text = Text::from(text); + let paragraph = Paragraph::new(text).block(input_block); + f.render_widget(paragraph, window); } -fn render_key_mapping( - f: &mut Frame>>>, - app: &App, - window: Rect, -) { - let keys = app - .key_mapping - .iter() - .map(|(key, path)| Row::Data(vec![key.to_string(), path.clone()].into_iter())); +const STATUS_DURATION: Duration = Duration::from_secs(2); + +fn render_status(f: &mut Frame, app: &App, window: Rect) +where + B: Backend, +{ + let status_block = Block::default().borders(Borders::ALL).title("Status"); + let mut status = format!("Sorted: {}/{}", app.current, app.images.len()); + if let Some(last_save) = app.last_save { + if last_save.elapsed() < STATUS_DURATION { + status = "Script saved!".to_string(); + } + } + let paragraph = Paragraph::new(Text::from(status)) + .alignment(Alignment::Center) + .block(status_block); + f.render_widget(paragraph, window); +} + +fn render_key_mapping(f: &mut Frame, app: &App, window: Rect) +where + B: Backend, +{ + let key_mapping_block = Block::default().borders(Borders::ALL).title("Key mapping"); + let keys = app.key_mapping.iter().map(|(key, path)| { + Row::Data(vec![key.to_string(), path.display().to_string()].into_iter()) + }); let key_mapping = Table::new(["Key", "Path"].iter(), keys) .widths([Constraint::Length(3), Constraint::Length(25)].as_ref()) .header_gap(0) - .header_style(Style::default().fg(Color::Red)); + .header_style(Style::default().fg(Color::Red)) + .block(key_mapping_block); f.render_widget(key_mapping, window); } -fn render_controls( - f: &mut Frame>>>, - window: Rect, -) { +fn render_controls(f: &mut Frame, window: Rect) +where + B: Backend, +{ + let controls_block = Block::default().borders(Borders::ALL).title("Controls"); let controls = Table::new( ["Key", "Action"].iter(), vec![ Row::Data(["Ctrl-C", "Exit"].iter()), - Row::Data(["Shift-Tab", "Switch tabs"].iter()), + Row::Data(["Tab", "Switch tabs"].iter()), Row::Data(["", ""].iter()), + Row::Data(["Ctrl-R", "Rename image"].iter()), Row::Data(["Ctrl-S", "Skip image"].iter()), Row::Data(["Ctrl-Z", "Undo action"].iter()), Row::Data(["Ctrl-W", "Save script"].iter()), @@ -144,16 +176,16 @@ fn render_controls( ) .widths([Constraint::Length(10), Constraint::Length(20)].as_ref()) .header_gap(0) - .header_style(Style::default().fg(Color::Red)); + .header_style(Style::default().fg(Color::Red)) + .block(controls_block); f.render_widget(controls, window); } -pub fn render_script( - f: &mut Frame>>>, - app: &App, - window: Rect, -) -> Result<()> { +pub fn render_script(f: &mut Frame, app: &App, window: Rect) -> Result<()> +where + B: Backend, +{ let comment_style = Style::default().fg(Color::Yellow); let mut lines = vec![ Span::styled("#!/bin/sh", comment_style), @@ -172,23 +204,28 @@ pub fn render_script( for action in app.actions.iter() { match action { - Action::Skip(image) => { - lines.push(Span::styled(format!("# Skipped {}", image), comment_style)) + Action::Skip(image) => lines.push(Span::styled( + format!("# Skipped {}", image.display()), + comment_style, + )), + Action::MkDir(path) => { + lines.push(Span::from(format!("mkdir -p \"{}\"", path.display()))) } - Action::Move(image, path) => lines.push(Span::from(format!("mv {} {}", image, path))), - Action::MkDir(path) => lines.push(Span::from(format!("mkdir -p {}", path))), + Action::Move(image, path) => lines.push(Span::from(format!( + "mv \"{}\" \"{}\"", + image.display(), + path.display() + ))), + _ => {} } } let lines: Vec = lines.into_iter().map(Spans::from).collect(); - let script_block = Block::default().borders(Borders::ALL); - let paragraph = Paragraph::new(lines) .block(script_block) .scroll(app.script_offset); f.render_widget(paragraph, window); - Ok(()) }