├── src ├── components │ ├── input │ │ ├── input.rs │ │ ├── text.rs │ │ ├── mod.rs │ │ └── color.rs │ ├── mod.rs │ ├── cell.rs │ ├── brush.rs │ ├── clicks.rs │ ├── history.rs │ ├── palette.rs │ ├── charpicker.rs │ ├── save_load.rs │ ├── layers.rs │ └── tools.rs ├── lib.rs ├── ui │ ├── screen_too_small.rs │ ├── debug_mode.rs │ ├── canvas.rs │ ├── popup_help.rs │ ├── sidebar │ │ ├── toolbox.rs │ │ ├── mod.rs │ │ ├── colorpalette.rs │ │ ├── charpicker.rs │ │ ├── brushinfo.rs │ │ └── layermanager.rs │ ├── popup_exit_confirm.rs │ ├── mod.rs │ ├── popup_rename.rs │ ├── popup_save.rs │ ├── popup_export.rs │ └── popup_colorpicker.rs ├── tui.rs ├── main.rs ├── app.rs └── handler.rs ├── README.md ├── Cargo.toml ├── .gitignore └── LICENSE /src/components/input/input.rs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Terminart 2 | 3 | Draw in your terminal! 4 | -------------------------------------------------------------------------------- /src/components/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod brush; 2 | pub mod cell; 3 | pub mod charpicker; 4 | pub mod clicks; 5 | pub mod history; 6 | pub mod input; 7 | pub mod layers; 8 | pub mod palette; 9 | pub mod save_load; 10 | pub mod tools; 11 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | /// Application. 2 | pub mod app; 3 | 4 | /// Widget renderer. 5 | pub mod ui; 6 | 7 | /// Terminal user interface. 8 | pub mod tui; 9 | 10 | /// Event management. 11 | pub mod handler; 12 | 13 | /// Utility objects 14 | pub mod components; 15 | -------------------------------------------------------------------------------- /src/ui/screen_too_small.rs: -------------------------------------------------------------------------------- 1 | use ratatui::style::{Color, Stylize}; 2 | use ratatui::widgets::Paragraph; 3 | use ratatui::Frame; 4 | 5 | use super::centered_box; 6 | 7 | pub fn show(f: &mut Frame) { 8 | let area = f.area(); 9 | let message = "Terminal must be 30x70!"; 10 | let (w, h) = (message.len() as _, message.lines().count() as _); 11 | 12 | let center = centered_box(w, h, area); 13 | 14 | f.render_widget(Paragraph::new(message).fg(Color::Red), center); 15 | } 16 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "terminart" 3 | version = "0.9.1" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | alea = "0.2.2" 8 | anstyle = "1.0.8" 9 | anstyle-parse = "0.2.5" 10 | better-panic = "0.3.0" 11 | ciborium = "0.2.2" 12 | clap = { version = "4.5.16", features = ["cargo", "derive"] } 13 | clap-stdin = "0.5.1" 14 | cli-clipboard = "0.4.0" 15 | crossterm = "0.28.1" 16 | hashbrown = { version = "0.14.5", features = ["serde"] } 17 | ratatui = { version = "0.28.0", features = ["serde"] } 18 | regex = "1.10.6" 19 | serde = { version = "1.0.208", features = ["derive"] } 20 | -------------------------------------------------------------------------------- /src/components/cell.rs: -------------------------------------------------------------------------------- 1 | use ratatui::style::{Color, Style}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] 5 | pub struct Cell { 6 | pub fg: Color, 7 | pub bg: Color, 8 | pub char: char, 9 | } 10 | 11 | impl Cell { 12 | #[rustfmt::skip] 13 | pub fn char(&self) -> String { self.char.into() } 14 | 15 | pub const fn style(&self) -> Style { 16 | Style::new().fg(self.fg).bg(self.bg) 17 | } 18 | } 19 | 20 | /// For use with undo history, for removing a cell use app.erase 21 | impl Default for Cell { 22 | fn default() -> Self { 23 | Self { 24 | fg: Color::Reset, 25 | bg: Color::Reset, 26 | char: ' ', 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 8 | Cargo.lock 9 | 10 | # These are backup files generated by rustfmt 11 | **/*.rs.bk 12 | 13 | # MSVC Windows builds of rustc generate these, which store debugging information 14 | *.pdb 15 | 16 | # RustRover 17 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 18 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 19 | # and can be added to the global gitignore or merged into this file. For a more nuclear 20 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 21 | #.idea/ 22 | 23 | test* 24 | -------------------------------------------------------------------------------- /src/ui/debug_mode.rs: -------------------------------------------------------------------------------- 1 | use ratatui::layout::{Constraint, Direction, Layout}; 2 | use ratatui::{widgets::Paragraph, Frame}; 3 | 4 | use crate::app::App; 5 | 6 | pub fn show(app: &mut App, f: &mut Frame) { 7 | let terminal_area = f.area(); 8 | 9 | let horiz_layout = Layout::new( 10 | Direction::Horizontal, 11 | [ 12 | Constraint::Percentage(25), 13 | Constraint::Percentage(25), 14 | Constraint::Percentage(25), 15 | Constraint::Percentage(25), 16 | ], 17 | ) 18 | .split(terminal_area); 19 | 20 | let undo_history = app 21 | .history 22 | .past 23 | .iter() 24 | .map(|i| format!("{:?}", i)) 25 | .collect::>() 26 | .join("\n"); 27 | 28 | f.render_widget( 29 | Paragraph::new(format!("HISTORY\n\n{}", undo_history)), 30 | horiz_layout[0], 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 wyspr 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/brush.rs: -------------------------------------------------------------------------------- 1 | use ratatui::style::{Color, Style}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use super::{cell::Cell, tools::Tools}; 5 | 6 | const BRUSH_MIN: u16 = 1; 7 | const BRUSH_MAX: u16 = 21; 8 | 9 | #[derive(Clone, Copy, Debug, Serialize, Deserialize)] 10 | pub struct Brush { 11 | pub fg: Color, 12 | pub bg: Color, 13 | 14 | pub size: u16, 15 | pub char: char, 16 | pub tool: Tools, 17 | } 18 | 19 | impl Default for Brush { 20 | fn default() -> Self { 21 | Self { 22 | size: 1, 23 | fg: Color::Black, 24 | bg: Color::White, 25 | char: '░', 26 | tool: Tools::default(), 27 | } 28 | } 29 | } 30 | 31 | impl Brush { 32 | pub const fn style(&self) -> Style { 33 | Style::new().fg(self.fg).bg(self.bg) 34 | } 35 | 36 | #[rustfmt::skip] pub fn char(&self) -> String { self.char.to_string() } 37 | 38 | pub const fn as_cell(&self) -> Cell { 39 | Cell { 40 | fg: self.fg, 41 | bg: self.bg, 42 | char: self.char, 43 | } 44 | } 45 | 46 | pub fn down(&mut self, val: u16) { 47 | self.size = self.size.saturating_sub(val).max(BRUSH_MIN); 48 | } 49 | 50 | pub fn up(&mut self, val: u16) { 51 | self.size = (self.size + val).min(BRUSH_MAX); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/ui/canvas.rs: -------------------------------------------------------------------------------- 1 | use ratatui::layout::Rect; 2 | use ratatui::style::{Color, Style}; 3 | use ratatui::text::Span; 4 | use ratatui::widgets::{canvas::Canvas, Block, BorderType, Borders}; 5 | use ratatui::Frame; 6 | 7 | use crate::{app::App, components::clicks::ClickAction, ui::DARK_TEXT}; 8 | 9 | pub fn render(app: &mut App, f: &mut Frame, area: Rect) { 10 | let block = Block::new() 11 | .borders(Borders::all()) 12 | .border_type(BorderType::Rounded) 13 | .title(" Canvas ") 14 | .title_style(Style::new().bg(Color::Green).fg(DARK_TEXT)); 15 | 16 | let block_inner = block.inner(area); 17 | app.input_capture 18 | .click_mode_normal(&block_inner, ClickAction::Draw); 19 | 20 | let width = block_inner.width as f64; 21 | let height = block_inner.height as f64; 22 | 23 | let render = app.layers.render(); 24 | 25 | let canvas = Canvas::default() 26 | .x_bounds([0.0, width]) 27 | .y_bounds([0.0, height]) 28 | .paint(|c| { 29 | for (x, y, cell) in render 30 | .iter() 31 | .map(|(&(x, y), cell)| (x as f64, y as f64, cell)) 32 | { 33 | c.print(x, height - y, Span::styled(cell.char(), cell.style())); 34 | } 35 | }); 36 | 37 | f.render_widget(block, area); 38 | f.render_widget(canvas, block_inner); 39 | } 40 | -------------------------------------------------------------------------------- /src/components/clicks.rs: -------------------------------------------------------------------------------- 1 | use ratatui::style::Color; 2 | 3 | use super::{input::color::TextFocus, tools::Tools}; 4 | 5 | #[repr(u8)] 6 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 7 | pub enum ClickAction { 8 | Draw, 9 | Prev(Increment), 10 | Next(Increment), 11 | Set(SetValue), 12 | Layer(LayerAction), 13 | Rename(PopupBoxAction), 14 | Export(PopupBoxAction), 15 | Save(PopupBoxAction), 16 | Exit(PopupBoxAction), 17 | PickColor(PickAction), 18 | } 19 | 20 | #[repr(u8)] 21 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 22 | pub enum Increment { 23 | CharPicker, 24 | BrushSize, 25 | } 26 | 27 | #[repr(u8)] 28 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 29 | pub enum SetValue { 30 | Tool(Tools), 31 | Color(Color), 32 | Reset(ResetValue), 33 | Char(char), // 🦎🔥 34 | } 35 | 36 | #[repr(u8)] 37 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 38 | pub enum ResetValue { 39 | FG, 40 | BG, 41 | } 42 | 43 | #[repr(u8)] 44 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 45 | pub enum LayerAction { 46 | Add, 47 | Remove, 48 | Rename, 49 | Select(u8), 50 | MoveUp, 51 | MoveDown, 52 | ToggleVis(u8), 53 | } 54 | 55 | #[repr(u8)] 56 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 57 | pub enum PopupBoxAction { 58 | Accept, 59 | Nothing, 60 | Deny, 61 | } 62 | 63 | #[repr(u8)] 64 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 65 | pub enum PickAction { 66 | AcceptFG, 67 | AcceptBG, 68 | ReplacePColor(Color, usize), 69 | Plus(TextFocus), 70 | Minus(TextFocus), 71 | ChangeFocus(TextFocus), 72 | Update(TextFocus, u8), 73 | New, 74 | Exit, 75 | Nothing, 76 | } 77 | -------------------------------------------------------------------------------- /src/components/input/text.rs: -------------------------------------------------------------------------------- 1 | use crate::components::save_load::FileSaveError; 2 | 3 | #[derive(Default, Debug)] 4 | pub struct TextArea { 5 | pub buffer: String, 6 | pub pos: usize, 7 | pub error: Option, 8 | } 9 | 10 | impl TextArea { 11 | pub fn get(&self) -> Option { 12 | if self.buffer.is_empty() { 13 | return None; 14 | } 15 | Some(self.buffer.clone()) 16 | } 17 | 18 | pub fn input(&mut self, ch: char, max_len: usize) { 19 | if self.pos >= max_len { 20 | return; 21 | } 22 | self.buffer.insert(self.pos, ch); 23 | self.pos += 1; 24 | } 25 | 26 | pub fn backspace(&mut self) { 27 | self.buffer = self 28 | .buffer 29 | .chars() 30 | .enumerate() 31 | .filter(|&(i, _)| i != self.pos - 1) 32 | .map(|(_, c)| c) 33 | .collect(); 34 | 35 | self.left(); 36 | } 37 | 38 | pub fn delete(&mut self) { 39 | self.buffer = self 40 | .buffer 41 | .chars() 42 | .enumerate() 43 | .filter(|&(i, _)| i != self.pos) 44 | .map(|(_, c)| c) 45 | .collect(); 46 | } 47 | 48 | pub fn home(&mut self) { 49 | self.pos = 0; 50 | } 51 | 52 | pub fn end(&mut self) { 53 | self.pos = self.buffer.len(); 54 | } 55 | 56 | pub fn left(&mut self) { 57 | self.pos = self.pos.saturating_sub(1); 58 | } 59 | 60 | pub fn right(&mut self) { 61 | self.pos = (self.pos + 1).min(self.buffer.len()); 62 | } 63 | 64 | pub fn clear(&mut self) { 65 | self.buffer = "".into(); 66 | self.pos = 0; 67 | self.error = None; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/ui/popup_help.rs: -------------------------------------------------------------------------------- 1 | use ratatui::layout::Alignment; 2 | use ratatui::style::{Color, Style, Stylize}; 3 | use ratatui::text::Line; 4 | use ratatui::widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph}; 5 | 6 | use super::{centered_box, YELLOW}; 7 | 8 | const HELP_TEXT: &str = " 9 | Q, Esc - Quit 10 | L-Button - (Canvas) Draw with current brush 11 | M-Button - (Canvas) Paste into canvas at mouse cursor 12 | L-Button - (Palette) Set foreground color 13 | R-Button - (Palette) Set background color 14 | M-Button - (Palette) Unset selected color (transparent) 15 | s, S - Brush size 16 | f, F - Cycle brush fg 17 | b, B - Cycle brush bg 18 | u, U - Undo / Redo 19 | y - Copy canvas to clipboard with ANSI codes 20 | Y - Copy canvas to clipboard as plain text 21 | p, P - Input first character from clipboard as brush 22 | Ctrl + S - Save Canvas (flat ANSI) 23 | Ctrl + E - Export Canvas (layers, palette, and brush) 24 | R - Reset (Will delete layers) 25 | ? - Toggle Help 26 | "; 27 | 28 | pub fn show(f: &mut ratatui::Frame) { 29 | let help_width = 6 + HELP_TEXT.lines().skip(1).fold(0, |a, b| a.max(b.len())) as u16; 30 | let help_height = 4 + HELP_TEXT.lines().skip(1).count() as u16; 31 | 32 | let help_area = centered_box(help_width, help_height, f.area()); 33 | 34 | let help_box = Block::default() 35 | .title(" HELP ") 36 | .title_style(Style::new().bold().fg(YELLOW)) 37 | .title_alignment(Alignment::Center) 38 | .padding(Padding::new(2, 2, 1, 1)) 39 | .borders(Borders::all()) 40 | .border_type(BorderType::Rounded) 41 | .border_style(Style::new().fg(Color::Yellow)); 42 | 43 | let help_box_size = help_box.inner(help_area); 44 | 45 | f.render_widget(Clear, help_area); 46 | f.render_widget(help_box, help_area); 47 | 48 | let lines: Vec<_> = HELP_TEXT.lines().skip(1).map(Line::from).collect(); 49 | 50 | f.render_widget(Paragraph::new(lines).fg(YELLOW), help_box_size); 51 | } 52 | -------------------------------------------------------------------------------- /src/components/history.rs: -------------------------------------------------------------------------------- 1 | use super::layers::{Layer, LayerData}; 2 | 3 | #[derive(Debug, Clone, PartialEq, Eq)] 4 | pub enum HistoryAction { 5 | LayerAdded(u32), 6 | LayerRemoved(Layer, usize), 7 | LayerRenamed(u32, String), 8 | LayerUp(u32), 9 | LayerDown(u32), 10 | Draw(u32, LayerData), 11 | } 12 | 13 | #[derive(Debug, Default)] 14 | pub struct History { 15 | pub past: Vec, 16 | pub future: Vec, 17 | pub partial_draw: Option, 18 | } 19 | 20 | impl History { 21 | pub fn draw(&mut self, id: u32, data: LayerData) { 22 | if !data.is_empty() { 23 | self.past.push(HistoryAction::Draw(id, data)); 24 | } 25 | } 26 | 27 | pub fn add_layer(&mut self, id: u32) { 28 | self.past.push(HistoryAction::LayerAdded(id)); 29 | } 30 | 31 | pub fn remove_layer(&mut self, layer: Layer, index: usize) { 32 | self.past.push(HistoryAction::LayerRemoved(layer, index)); 33 | } 34 | 35 | pub fn rename_layer(&mut self, id: u32, old_name: String) { 36 | self.past.push(HistoryAction::LayerRenamed(id, old_name)); 37 | } 38 | 39 | pub fn layer_up(&mut self, id: u32) { 40 | self.past.push(HistoryAction::LayerUp(id)); 41 | } 42 | 43 | pub fn layer_down(&mut self, id: u32) { 44 | self.past.push(HistoryAction::LayerDown(id)); 45 | } 46 | 47 | pub fn forget_redo(&mut self) { 48 | self.future.clear(); 49 | } 50 | 51 | pub fn add_partial_draw(&mut self, mut old_data: LayerData) { 52 | if let Some(partial) = self.partial_draw.take() { 53 | old_data.extend(partial); 54 | } 55 | 56 | self.partial_draw = Some(old_data); 57 | } 58 | 59 | pub fn finish_partial_draw(&mut self, layer_id: u32) { 60 | if let Some(partial_draw) = self.partial_draw.take() { 61 | self.draw(layer_id, partial_draw); 62 | } 63 | } 64 | 65 | pub fn click_to_partial_draw(&mut self) { 66 | if let Some(action) = self.past.pop() { 67 | match action { 68 | HistoryAction::Draw(_, data) => self.add_partial_draw(data), 69 | other => self.past.push(other), 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/components/palette.rs: -------------------------------------------------------------------------------- 1 | use ratatui::style::Color; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Clone, Debug, Serialize, Deserialize)] 5 | pub struct Palette { 6 | pub colors: Vec, 7 | 8 | fg: usize, 9 | bg: usize, 10 | } 11 | 12 | impl Default for Palette { 13 | fn default() -> Self { 14 | let colors = vec![ 15 | Color::Black, // 0 16 | Color::Red, // 1 17 | Color::Yellow, // 2 18 | Color::Green, // 3 19 | Color::Blue, // 4 20 | Color::Magenta, // 5 21 | Color::Cyan, // 6 22 | Color::Gray, // 7 23 | Color::DarkGray, // 8 24 | Color::LightRed, // 9 25 | Color::LightYellow, // 10 26 | Color::LightGreen, // 11 27 | Color::LightBlue, // 12 28 | Color::LightMagenta, // 13 29 | Color::LightCyan, // 14 30 | Color::White, // 15 31 | ]; 32 | Self { 33 | colors, 34 | fg: 0, 35 | bg: 15, 36 | } 37 | } 38 | } 39 | 40 | impl Palette { 41 | pub fn colors(&self) -> Vec { 42 | self.colors.to_vec() 43 | } 44 | 45 | fn next(&self, index: usize) -> usize { 46 | (index + 1) % self.colors.len() 47 | } 48 | 49 | fn prev(&self, index: usize) -> usize { 50 | index.checked_sub(1).unwrap_or(self.colors.len() - 1) 51 | } 52 | 53 | pub fn replace(&mut self, index: usize, color: Color) { 54 | if index > 15 { 55 | return; 56 | } 57 | 58 | self.colors[index] = color; 59 | } 60 | 61 | pub fn fg_next(&mut self) -> Color { 62 | self.fg = self.next(self.fg); 63 | self.colors[self.fg] 64 | } 65 | 66 | pub fn fg_prev(&mut self) -> Color { 67 | self.fg = self.prev(self.fg); 68 | self.colors[self.fg] 69 | } 70 | 71 | pub fn bg_next(&mut self) -> Color { 72 | self.bg = self.next(self.bg); 73 | self.colors[self.bg] 74 | } 75 | 76 | pub fn bg_prev(&mut self) -> Color { 77 | self.bg = self.prev(self.bg); 78 | self.colors[self.bg] 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/ui/sidebar/toolbox.rs: -------------------------------------------------------------------------------- 1 | use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; 2 | use ratatui::style::{Style, Stylize}; 3 | use ratatui::text::{Line, Span}; 4 | use ratatui::widgets::block::{Position, Title}; 5 | use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; 6 | use ratatui::Frame; 7 | 8 | use crate::app::App; 9 | use crate::components::clicks::{ClickAction::Set, SetValue::Tool}; 10 | use crate::components::tools::Tools; 11 | 12 | use super::{Button, LIGHT_TEXT, TOOL_BORDER}; 13 | 14 | pub fn render(app: &mut App, f: &mut Frame, area: Rect) { 15 | let block_area = outer_block(f, area); 16 | let rows = Layout::new(Direction::Vertical, [Constraint::Min(1); 2]).split(block_area); 17 | render_buttons(app, f, rows[0]); 18 | render_info(app, f, rows[1]); 19 | } 20 | 21 | fn outer_block(f: &mut Frame, area: Rect) -> Rect { 22 | let block = Block::new() 23 | .title("Tool Selector ".bold()) 24 | .title( 25 | Title::from(" ╶".bold()) 26 | .position(Position::Bottom) 27 | .alignment(Alignment::Left), 28 | ) 29 | .borders(Borders::TOP | Borders::RIGHT | Borders::BOTTOM) 30 | .border_type(BorderType::Rounded) 31 | .border_style(Style::new().fg(TOOL_BORDER)); 32 | 33 | let block_inner = block.inner(area); 34 | 35 | f.render_widget(block, area); 36 | 37 | block_inner 38 | } 39 | 40 | fn render_buttons(app: &mut App, f: &mut Frame, area: Rect) { 41 | let current_tool = app.brush.tool; 42 | let tools = Tools::all(); 43 | let tool_amount = tools.len(); 44 | 45 | let row = Layout::new(Direction::Horizontal, vec![Constraint::Min(3); tool_amount]).split(area); 46 | 47 | tools.iter().zip(row.iter()).for_each(|(&t, &area)| { 48 | let c = t.char(); 49 | 50 | let btn = if current_tool == t { 51 | Button::selected(&c) 52 | } else { 53 | Button::normal(&c) 54 | }; 55 | 56 | let button = Paragraph::new(Line::from(btn)); 57 | 58 | app.input_capture.click_mode_normal(&area, Set(Tool(t))); 59 | f.render_widget(button, area); 60 | }); 61 | } 62 | 63 | fn render_info(app: &App, f: &mut Frame, area: Rect) { 64 | let info = Paragraph::new(Line::from(vec![ 65 | Span::from("Current tool: "), 66 | Span::from(app.brush.tool.to_string()).bold(), 67 | ])) 68 | .fg(LIGHT_TEXT) 69 | .alignment(Alignment::Center); 70 | 71 | f.render_widget(info, area); 72 | } 73 | -------------------------------------------------------------------------------- /src/components/charpicker.rs: -------------------------------------------------------------------------------- 1 | #[derive(Clone, Debug)] 2 | pub struct CharPicker { 3 | pub chars: hashbrown::HashMap<(u16, u16), char>, 4 | pub page: u16, 5 | } 6 | 7 | impl Default for CharPicker { 8 | fn default() -> Self { 9 | let mut map = hashbrown::HashMap::default(); 10 | 11 | for (y, tens) in (2..=7).enumerate() { 12 | let tens = tens << 4; // Shift to the right 13 | for (x, ones) in (0..=0xF).enumerate() { 14 | let hex = tens + ones; 15 | // skip over DEL escape code 16 | if hex == 0x7f { 17 | continue; 18 | } 19 | 20 | if let Some(ch) = std::char::from_u32(hex) { 21 | map.insert((x as u16, y as u16), ch); 22 | } 23 | } 24 | } 25 | 26 | // Lowest byte for box drawing chars 27 | let lines_base = 0x2500; 28 | 29 | for (y, tens) in (0..=9).rev().enumerate() { 30 | let tens = tens << 4; // Shift to the right 31 | for (x, ones) in (0..=0xF).enumerate() { 32 | let hex = lines_base + tens + ones; 33 | if let Some(ch) = std::char::from_u32(hex) { 34 | map.insert((x as u16, 6 + y as u16), ch); 35 | } 36 | } 37 | } 38 | 39 | Self { 40 | chars: map, 41 | page: 3, 42 | } 43 | } 44 | } 45 | 46 | impl CharPicker { 47 | pub const fn max_pages(&self) -> u16 { 48 | let page_count = 8; 49 | page_count - 1 50 | } 51 | 52 | fn get_page(&self, number: u16) -> Vec { 53 | // A page is 2 "rows" from the 16x16 hashmap 54 | let row = number * 2; 55 | 56 | let mut out = Vec::with_capacity(32); 57 | 58 | for sub in 0..=1 { 59 | for x in 0..=0xF { 60 | if let Some(&ch) = self.chars.get(&(x, row + sub)) { 61 | out.push(ch); 62 | } 63 | } 64 | } 65 | 66 | if out.is_empty() { 67 | self.get_page(number - 1) 68 | } else { 69 | out 70 | } 71 | } 72 | 73 | pub fn page(&self) -> Vec { 74 | self.get_page(self.page) 75 | } 76 | 77 | pub fn next(&mut self) { 78 | let next = self.page + 1; 79 | self.page = if next > self.max_pages() { 0 } else { next } 80 | } 81 | 82 | pub fn prev(&mut self) { 83 | self.page = self.page.checked_sub(1).unwrap_or_else(|| self.max_pages()); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/ui/popup_exit_confirm.rs: -------------------------------------------------------------------------------- 1 | use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; 2 | use ratatui::style::{Color, Style, Stylize}; 3 | use ratatui::text::{Line, Span}; 4 | use ratatui::widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph}; 5 | use ratatui::Frame; 6 | 7 | use crate::app::App; 8 | use crate::components::clicks::{ClickAction::Exit, PopupBoxAction::*}; 9 | 10 | use super::centered_box; 11 | use super::DARK_TEXT; 12 | 13 | pub fn show(app: &mut App, f: &mut Frame) { 14 | let area = f.area(); 15 | let box_height = 7; 16 | let box_width = 19; 17 | 18 | let block_area = centered_box(box_width, box_height, area); 19 | 20 | app.input_capture 21 | .click_mode_popup(&block_area, Exit(Nothing)); 22 | 23 | let block = Block::new() 24 | .title(" Exit ") 25 | .title_alignment(Alignment::Center) 26 | .title_style(Style::new().reversed().bold()) 27 | .borders(Borders::all()) 28 | .padding(Padding::new(1, 1, 1, 1)) 29 | .border_type(BorderType::Rounded); 30 | 31 | let block_inner = block.inner(block_area); 32 | 33 | f.render_widget(Clear, block_area); 34 | f.render_widget(block, block_area); 35 | 36 | let rows = Layout::new(Direction::Vertical, [Constraint::Length(1); 3]).split(block_inner); 37 | 38 | f.render_widget( 39 | Paragraph::new("Are you sure?") 40 | .alignment(Alignment::Center) 41 | .bold(), 42 | rows[0], 43 | ); 44 | 45 | buttons(app, f, rows[2]); 46 | } 47 | 48 | fn buttons(app: &mut App, f: &mut Frame, area: Rect) { 49 | let buttons_layout = Layout::new( 50 | Direction::Horizontal, 51 | [ 52 | Constraint::Length(2), 53 | Constraint::Length(5), 54 | Constraint::Length(2), 55 | Constraint::Length(4), 56 | Constraint::Length(2), 57 | ], 58 | ) 59 | .split(area); 60 | 61 | let exit_area = buttons_layout[1]; 62 | let stay_area = buttons_layout[3]; 63 | 64 | let exit_button = Paragraph::new(Line::from(vec![ 65 | Span::from(" "), 66 | Span::from("Y").underlined(), 67 | Span::from("es "), 68 | ])) 69 | .alignment(Alignment::Center) 70 | .bold() 71 | .bg(Color::Red) 72 | .fg(DARK_TEXT); 73 | 74 | let stay_button = Paragraph::new(Line::from(vec![ 75 | Span::from(" "), 76 | Span::from("N").underlined(), 77 | Span::from("o "), 78 | ])) 79 | .alignment(Alignment::Center) 80 | .bold() 81 | .bg(Color::Blue) 82 | .fg(Color::White); 83 | 84 | app.input_capture.click_mode_popup(&exit_area, Exit(Accept)); 85 | f.render_widget(exit_button, exit_area); 86 | 87 | app.input_capture.click_mode_popup(&stay_area, Exit(Deny)); 88 | f.render_widget(stay_button, stay_area); 89 | } 90 | -------------------------------------------------------------------------------- /src/ui/sidebar/mod.rs: -------------------------------------------------------------------------------- 1 | mod brushinfo; 2 | mod charpicker; 3 | mod colorpalette; 4 | mod layermanager; 5 | mod toolbox; 6 | 7 | use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; 8 | use ratatui::style::{Color, Style, Stylize}; 9 | use ratatui::text::{Line, Span}; 10 | use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; 11 | use ratatui::Frame; 12 | 13 | use crate::app::App; 14 | 15 | use super::{ 16 | ACCENT_BUTTON_COLOR, BG, BG_LAYER_MANAGER, BLACK, BUTTON_COLOR, DARK_TEXT, DIM_TEXT, 17 | LAYER_SELECTED, LAYER_UNSELECTED, LIGHT_TEXT, SEL_BUTTON_COLOR, TOOL_BORDER, WHITE, YELLOW, 18 | }; 19 | 20 | pub fn render(app: &mut App, f: &mut Frame, area: Rect) { 21 | let bar_block = Block::new() 22 | .style(Style::new().bg(BG)) 23 | .borders(Borders::all()) 24 | .border_type(BorderType::QuadrantInside) 25 | .border_style(Style::new().fg(BG).bg(Color::Reset)) 26 | .title(" Toolbox ".fg(DARK_TEXT).bg(YELLOW)) 27 | .title_alignment(Alignment::Center); 28 | 29 | let bar_inner = bar_block.inner(area); 30 | 31 | f.render_widget(bar_block, area); 32 | 33 | let bar_layout = Layout::new( 34 | Direction::Vertical, 35 | [ 36 | Constraint::Max(3), // 0: Brush info 37 | Constraint::Max(4), // 1: Tools 38 | Constraint::Max(10), // 2: Char picker 39 | Constraint::Max(6), // 3: Palette 40 | Constraint::Min(0), // 4: Layers 41 | Constraint::Max(1), // 5: Help text 42 | ], 43 | ) 44 | .split(bar_inner); 45 | 46 | brushinfo::render(app, f, bar_layout[0]); 47 | toolbox::render(app, f, bar_layout[1]); 48 | charpicker::render(app, f, bar_layout[2]); 49 | colorpalette::render(app, f, bar_layout[3]); 50 | layermanager::render(app, f, bar_layout[4]); 51 | 52 | f.render_widget( 53 | Paragraph::new(Line::from(vec![ 54 | Span::raw("Help: "), 55 | Span::raw("? ").bold(), 56 | ])) 57 | .fg(LIGHT_TEXT) 58 | .alignment(Alignment::Right), 59 | bar_layout[5], 60 | ) 61 | } 62 | 63 | pub struct Button; 64 | 65 | impl Button { 66 | pub fn custom(label: &str, bg: Color, fg: Color) -> Vec { 67 | vec![ 68 | Span::raw("▐").fg(bg), 69 | Span::raw(label).bg(bg).fg(fg), 70 | Span::raw("▌").fg(bg), 71 | ] 72 | } 73 | 74 | pub fn blank(color: Color) -> Vec> { 75 | vec![Span::raw("▐█▌").fg(color)] 76 | } 77 | 78 | pub fn normal(label: &str) -> Vec { 79 | Self::custom(label, BUTTON_COLOR, DARK_TEXT) 80 | } 81 | 82 | pub fn selected(label: &str) -> Vec { 83 | Self::custom(label, SEL_BUTTON_COLOR, DARK_TEXT) 84 | } 85 | 86 | pub fn accent(label: &str) -> Vec { 87 | Self::custom(label, ACCENT_BUTTON_COLOR, DARK_TEXT) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/ui/sidebar/colorpalette.rs: -------------------------------------------------------------------------------- 1 | use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; 2 | use ratatui::style::{Style, Stylize}; 3 | use ratatui::text::{Line, Span}; 4 | use ratatui::widgets::{block::Title, Block, BorderType, Borders, Padding, Paragraph}; 5 | use ratatui::Frame; 6 | 7 | use crate::app::App; 8 | use crate::components::clicks::ClickAction::{PickColor, Set}; 9 | use crate::components::clicks::PickAction::New; 10 | use crate::components::clicks::SetValue::Color; 11 | 12 | use super::{Button, BG_LAYER_MANAGER, BLACK, TOOL_BORDER, WHITE}; 13 | 14 | pub fn render(app: &mut App, f: &mut Frame, area: Rect) { 15 | let block = block(app, f, area); 16 | render_buttons(app, f, block); 17 | } 18 | 19 | fn block(app: &mut App, f: &mut Frame, area: Rect) -> Rect { 20 | let block = Block::new() 21 | .title(Title::from(" Palette ".bold()).alignment(Alignment::Center)) 22 | .title(Title::from(Button::accent("+")).alignment(Alignment::Right)) 23 | .padding(Padding::horizontal(1)) 24 | .borders(Borders::all()) 25 | .border_type(BorderType::Rounded) 26 | .border_style(Style::new().fg(TOOL_BORDER)); 27 | 28 | let add_color_button = Rect { 29 | height: 1, 30 | width: 3, 31 | x: area.width - 3, 32 | ..area 33 | }; 34 | app.input_capture 35 | .click_mode_normal(&add_color_button, PickColor(New)); 36 | 37 | let block_inner = block.inner(area); 38 | f.render_widget(block, area); 39 | 40 | block_inner 41 | } 42 | 43 | fn render_buttons(app: &mut App, f: &mut Frame, area: Rect) { 44 | let rows = Layout::new(Direction::Vertical, [Constraint::Min(2); 2]).split(area); 45 | let row = Layout::new(Direction::Horizontal, [Constraint::Min(3); 8]); 46 | let row1 = row.split(rows[0]); 47 | let row2 = row.split(rows[1]); 48 | 49 | let row_iter = row1.iter().chain(row2.iter()); 50 | 51 | app.palette 52 | .colors() 53 | .iter() 54 | .zip(row_iter) 55 | .for_each(|(&color, &area)| { 56 | let style = match color { 57 | c if c == app.brush.bg && c == app.brush.fg => Style::new().bg(BG_LAYER_MANAGER), 58 | c if c == app.brush.fg => Style::new().bg(WHITE), 59 | c if c == app.brush.bg => Style::new().bg(BLACK), 60 | _ => Style::new(), 61 | } 62 | .fg(color); 63 | 64 | let top_line = Line::from(vec![ 65 | Span::raw("▗").fg(color), 66 | Span::raw("▄").style(style), 67 | Span::raw("▖").fg(color), 68 | ]); // ▗▄▖ 69 | 70 | let bot_line = Line::from(Span::raw("▝▀▘").fg(color)); 71 | 72 | let color_pg = Paragraph::new(vec![top_line, bot_line]); 73 | 74 | app.input_capture 75 | .click_mode_normal(&area, Set(Color(color))); 76 | f.render_widget(color_pg, area); 77 | }); 78 | } 79 | -------------------------------------------------------------------------------- /src/tui.rs: -------------------------------------------------------------------------------- 1 | use better_panic::Settings; 2 | use crossterm::event::{ 3 | DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, 4 | }; 5 | use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; 6 | use ratatui::backend::Backend; 7 | use ratatui::Terminal; 8 | use std::io; 9 | 10 | use crate::app::{App, AppResult}; 11 | use crate::handler::EventHandler; 12 | use crate::ui; 13 | 14 | /// Representation of a terminal user interface. 15 | /// 16 | /// It is responsible for setting up the terminal, 17 | /// initializing the interface and handling the draw events. 18 | #[derive(Debug)] 19 | pub struct Tui { 20 | /// Interface to the Terminal. 21 | terminal: Terminal, 22 | /// Terminal event handler. 23 | pub events: EventHandler, 24 | } 25 | 26 | impl Tui { 27 | /// Constructs a new instance of [`Tui`]. 28 | pub fn new(terminal: Terminal, events: EventHandler) -> AppResult { 29 | let mut this = Self { terminal, events }; 30 | this.init()?; 31 | Ok(this) 32 | } 33 | 34 | /// Initializes the terminal interface. 35 | /// 36 | /// It enables the raw mode and sets terminal properties. 37 | pub fn init(&mut self) -> AppResult<()> { 38 | terminal::enable_raw_mode()?; 39 | crossterm::execute!( 40 | io::stderr(), 41 | EnterAlternateScreen, 42 | EnableMouseCapture, 43 | EnableBracketedPaste 44 | )?; 45 | 46 | // Define a custom panic hook to reset the terminal properties. 47 | // This way, you won't have your terminal messed up if an unexpected error happens. 48 | std::panic::set_hook(Box::new(|panic_info| { 49 | #[allow(clippy::expect_used)] 50 | Self::reset().expect("failed to reset the terminal"); 51 | Settings::auto() 52 | .most_recent_first(false) 53 | .lineno_suffix(true) 54 | .create_panic_handler()(panic_info); 55 | })); 56 | 57 | self.terminal.hide_cursor()?; 58 | self.terminal.clear()?; 59 | Ok(()) 60 | } 61 | 62 | /// [`Draw`] the terminal interface by [`rendering`] the widgets. 63 | /// 64 | /// [`Draw`]: ratatui::Terminal::draw 65 | /// [`rendering`]: crate::ui:render 66 | pub fn render(&mut self, app: &mut App) -> AppResult<()> { 67 | self.terminal.draw(|frame| ui::render(app, frame))?; 68 | Ok(()) 69 | } 70 | 71 | /// Resets the terminal interface. 72 | /// 73 | /// This function is also used for the panic hook to revert 74 | /// the terminal properties if unexpected errors occur. 75 | fn reset() -> AppResult<()> { 76 | terminal::disable_raw_mode()?; 77 | crossterm::execute!( 78 | io::stderr(), 79 | LeaveAlternateScreen, 80 | DisableMouseCapture, 81 | DisableBracketedPaste 82 | )?; 83 | Ok(()) 84 | } 85 | 86 | /// Exits the terminal interface. 87 | /// 88 | /// It disables the raw mode and reverts back the terminal properties. 89 | pub fn exit(&mut self) -> AppResult<()> { 90 | Self::reset()?; 91 | self.terminal.show_cursor()?; 92 | Ok(()) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/ui/mod.rs: -------------------------------------------------------------------------------- 1 | use ratatui::layout::{Constraint, Direction, Layout, Rect}; 2 | use ratatui::style::Color::{self, Rgb as rgb}; // make the struct work with rgb highlighter 3 | use ratatui::Frame; 4 | 5 | use crate::app::App; 6 | use crate::components::input::InputMode; 7 | 8 | mod canvas; 9 | mod popup_colorpicker; 10 | mod popup_exit_confirm; 11 | mod popup_export; 12 | mod popup_help; 13 | mod popup_rename; 14 | mod popup_save; 15 | mod screen_too_small; 16 | mod sidebar; 17 | 18 | #[cfg(debug_assertions)] 19 | mod debug_mode; 20 | 21 | pub const TOOLBOX_WIDTH: u16 = 30; 22 | pub const _COLOR_STEPS: u8 = 32; 23 | pub const COLOR_STEPS: u8 = _COLOR_STEPS + 1; 24 | pub const COLOR_STEP_AMT: u8 = u8::MAX / COLOR_STEPS + 1; 25 | 26 | pub const WHITE: Color = rgb(220, 220, 220); 27 | pub const LIGHT_TEXT: Color = WHITE; 28 | pub const DIM_TEXT: Color = rgb(170, 170, 170); 29 | pub const BLACK: Color = rgb(10, 10, 10); 30 | pub const BG: Color = rgb(100, 100, 100); 31 | pub const BG_LAYER_MANAGER: Color = rgb(70, 70, 70); 32 | pub const LAYER_SELECTED: Color = rgb(120, 120, 120); 33 | pub const LAYER_UNSELECTED: Color = rgb(90, 90, 90); 34 | pub const TOOL_BORDER: Color = rgb(240, 240, 240); 35 | pub const BUTTON_COLOR: Color = rgb(85, 165, 165); 36 | pub const SEL_BUTTON_COLOR: Color = rgb(120, 190, 210); 37 | pub const ACCENT_BUTTON_COLOR: Color = rgb(172, 188, 255); 38 | pub const DARK_TEXT: Color = rgb(35, 42, 46); 39 | pub const RED: Color = rgb(140, 14, 14); 40 | pub const YELLOW: Color = rgb(223, 160, 0); 41 | 42 | pub fn render(app: &mut App, f: &mut Frame) { 43 | let terminal_area = f.area(); 44 | 45 | #[cfg(debug_assertions)] 46 | if app.input_capture.mode == InputMode::Debug { 47 | debug_mode::show(app, f); 48 | return; 49 | } 50 | 51 | if terminal_area.width < 70 || terminal_area.height < 30 { 52 | app.input_capture.change_mode(InputMode::TooSmall); 53 | screen_too_small::show(f); 54 | return; 55 | } 56 | 57 | if app.input_capture.mode == InputMode::TooSmall { 58 | app.input_capture.change_mode(InputMode::Normal); 59 | } 60 | 61 | let main_layout = Layout::new( 62 | Direction::Horizontal, 63 | [Constraint::Length(TOOLBOX_WIDTH), Constraint::Min(0)], 64 | ) 65 | .split(terminal_area); 66 | 67 | sidebar::render(app, f, main_layout[0]); 68 | canvas::render(app, f, main_layout[1]); 69 | 70 | match app.input_capture.mode { 71 | InputMode::Rename => popup_rename::show(app, f), 72 | InputMode::Color => popup_colorpicker::show(app, f), 73 | InputMode::Help => popup_help::show(f), 74 | InputMode::Export => popup_export::show(app, f), 75 | InputMode::Save => popup_save::show(app, f), 76 | InputMode::Exit => popup_exit_confirm::show(app, f), 77 | _ => {} 78 | }; 79 | } 80 | 81 | pub fn centered_box(width: u16, height: u16, area: Rect) -> Rect { 82 | let vert_center = Layout::new( 83 | Direction::Vertical, 84 | [ 85 | Constraint::Length((area.height - height) / 2), 86 | Constraint::Length(height), 87 | Constraint::Length((area.height - height) / 2), 88 | ], 89 | ) 90 | .split(area)[1]; 91 | 92 | Layout::new( 93 | Direction::Horizontal, 94 | [ 95 | Constraint::Length((area.width - width) / 2), 96 | Constraint::Length(width), 97 | Constraint::Length((area.width - width) / 2), 98 | ], 99 | ) 100 | .split(vert_center)[1] 101 | } 102 | -------------------------------------------------------------------------------- /src/components/input/mod.rs: -------------------------------------------------------------------------------- 1 | use ratatui::layout::Rect; 2 | 3 | pub mod color; 4 | pub mod text; 5 | 6 | use super::clicks::ClickAction; 7 | 8 | pub type ClickLayer = hashbrown::HashMap<(u16, u16), ClickAction>; 9 | 10 | #[derive(Clone, Copy, Default, Debug, PartialEq, Eq)] 11 | pub enum InputMode { 12 | #[default] 13 | Normal, 14 | Rename, 15 | Color, 16 | Help, 17 | Export, 18 | Save, 19 | Exit, 20 | TooSmall, 21 | #[cfg(debug_assertions)] 22 | Debug, 23 | } 24 | 25 | #[derive(Clone, Copy, Default, Debug, PartialEq, Eq)] 26 | pub enum MouseMode { 27 | #[default] 28 | Normal, 29 | Click, 30 | Drag, 31 | } 32 | 33 | #[derive(Default, Debug)] 34 | pub struct InputCapture { 35 | pub mode: InputMode, 36 | pub normal_input: ClickLayer, 37 | pub popup_layer: ClickLayer, 38 | pub text_area: text::TextArea, 39 | pub color_picker: color::ColorPicker, 40 | pub last_file_name: Option, 41 | pub mouse_mode: MouseMode, 42 | } 43 | 44 | impl InputCapture { 45 | pub fn clear(&mut self) { 46 | self.normal_input.clear(); 47 | self.popup_layer.clear(); 48 | self.text_area.clear(); 49 | } 50 | 51 | pub fn get(&self, x: u16, y: u16) -> Option<&ClickAction> { 52 | match self.mode { 53 | InputMode::Normal | InputMode::Help => &self.normal_input, 54 | _ => &self.popup_layer, 55 | } 56 | .get(&(x, y)) 57 | } 58 | 59 | pub fn click_mode_normal(&mut self, area: &Rect, action: ClickAction) { 60 | let (left, top) = (area.x, area.y); 61 | let right = left + area.width; 62 | let bottom = top + area.height; 63 | 64 | for y in top..bottom { 65 | for x in left..right { 66 | self.normal_input.insert((x, y), action); 67 | } 68 | } 69 | } 70 | 71 | pub fn click_mode_popup(&mut self, area: &Rect, action: ClickAction) { 72 | let (left, top) = (area.x, area.y); 73 | let right = left + area.width; 74 | let bottom = top + area.height; 75 | 76 | for y in top..bottom { 77 | for x in left..right { 78 | self.popup_layer.insert((x, y), action); 79 | } 80 | } 81 | } 82 | 83 | pub fn toggle_help(&mut self) { 84 | if self.mode == InputMode::Help { 85 | self.mode = InputMode::Normal; 86 | } else { 87 | self.mode = InputMode::Help; 88 | } 89 | } 90 | 91 | pub fn change_mode(&mut self, new_mode: InputMode) { 92 | if self.mode == new_mode { 93 | return; 94 | } 95 | 96 | if !matches!(new_mode, InputMode::Normal | InputMode::Help) { 97 | self.text_area.clear(); 98 | self.color_picker.reset(); 99 | self.popup_layer.clear(); 100 | } 101 | 102 | self.mode = new_mode; 103 | } 104 | 105 | pub fn exit(&mut self) { 106 | self.text_area.clear(); 107 | self.color_picker.reset(); 108 | self.popup_layer.clear(); 109 | self.change_mode(InputMode::Normal) 110 | } 111 | 112 | pub fn accept(&mut self) -> Option { 113 | if self.text_area.buffer.is_empty() { 114 | return None; 115 | } 116 | let out: String = self.text_area.buffer.chars().take(20).collect(); 117 | self.exit(); 118 | Some(out) 119 | } 120 | 121 | #[cfg(debug_assertions)] 122 | pub fn toggle_debug(&mut self) { 123 | if self.mode == InputMode::Debug { 124 | self.mode = InputMode::Normal; 125 | } else { 126 | self.mode = InputMode::Debug; 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/ui/sidebar/charpicker.rs: -------------------------------------------------------------------------------- 1 | use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; 2 | use ratatui::style::{Style, Stylize}; 3 | use ratatui::text::{Line, Span}; 4 | use ratatui::widgets::{block::Title, Block, BorderType, Borders, Padding, Paragraph}; 5 | use ratatui::Frame; 6 | 7 | use crate::app::App; 8 | use crate::components::clicks::ClickAction::{Next, Prev, Set}; 9 | use crate::components::clicks::Increment::CharPicker; 10 | use crate::components::clicks::SetValue::Char; 11 | 12 | use super::{Button, TOOL_BORDER}; 13 | 14 | pub fn render(app: &mut App, f: &mut Frame, area: Rect) { 15 | let outer_block = outer_block(app, f, area); 16 | let inner_block = inner_block(app, f, outer_block); 17 | render_buttons(app, f, inner_block); 18 | } 19 | 20 | fn outer_block(app: &mut App, f: &mut Frame, area: Rect) -> Rect { 21 | let block = Block::new() 22 | .title(Title::from(" Character Select ".bold()).alignment(Alignment::Center)) 23 | .title(Title::from(Button::accent("<")).alignment(Alignment::Left)) 24 | .title(Title::from(Button::accent(">")).alignment(Alignment::Right)) 25 | .padding(Padding::horizontal(1)) 26 | .borders(Borders::TOP) 27 | .border_style(Style::new().fg(TOOL_BORDER)); 28 | 29 | let page_prev_button = Rect { 30 | height: 1, 31 | width: 3, 32 | ..area 33 | }; 34 | 35 | let page_next_button = Rect { 36 | x: area.width - 3, 37 | ..page_prev_button 38 | }; 39 | app.input_capture 40 | .click_mode_normal(&page_prev_button, Prev(CharPicker)); 41 | app.input_capture 42 | .click_mode_normal(&page_next_button, Next(CharPicker)); 43 | 44 | let outer_block = block.inner(area); 45 | f.render_widget(block, area); 46 | 47 | outer_block 48 | } 49 | 50 | fn inner_block(app: &App, f: &mut Frame, area: Rect) -> Rect { 51 | let char_block = Block::new() 52 | .title(Title::from(vec![ 53 | Span::from((app.char_picker.page + 1).to_string()), 54 | Span::from("/"), 55 | Span::from((app.char_picker.max_pages() + 1).to_string()), 56 | ])) 57 | .borders(Borders::all()) 58 | .border_type(BorderType::Double) 59 | .border_style(Style::new().fg(TOOL_BORDER)); 60 | 61 | let inner_block = char_block.inner(area); 62 | f.render_widget(char_block, area); 63 | 64 | inner_block 65 | } 66 | 67 | fn render_buttons(app: &mut App, f: &mut Frame, area: Rect) { 68 | let rows = Layout::new( 69 | Direction::Vertical, 70 | [ 71 | Constraint::Min(2), 72 | Constraint::Min(2), 73 | Constraint::Min(2), 74 | Constraint::Min(1), 75 | ], 76 | ) 77 | .split(area); 78 | let row = Layout::new(Direction::Horizontal, [Constraint::Min(3); 8]); 79 | 80 | let row1 = row.split(rows[0]); 81 | let row2 = row.split(rows[1]); 82 | let row3 = row.split(rows[2]); 83 | let row4 = row.split(rows[3]); 84 | 85 | let row_iter = row1 86 | .iter() 87 | .chain(row2.iter()) 88 | .chain(row3.iter()) 89 | .chain(row4.iter()); 90 | 91 | app.char_picker 92 | .page() 93 | .iter() 94 | .zip(row_iter) 95 | .for_each(|(&c, &area)| { 96 | // replace space with a nicer character 97 | let c_str = if c == ' ' { 98 | "␣".to_string() 99 | } else { 100 | c.to_string() 101 | }; 102 | 103 | let btn = if app.brush.char == c { 104 | Button::selected(&c_str) 105 | } else { 106 | Button::normal(&c_str) 107 | }; 108 | 109 | let button = Paragraph::new(Line::from(btn)); 110 | 111 | app.input_capture.click_mode_normal(&area, Set(Char(c))); 112 | f.render_widget(button, area); 113 | }); 114 | } 115 | -------------------------------------------------------------------------------- /src/ui/popup_rename.rs: -------------------------------------------------------------------------------- 1 | use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; 2 | use ratatui::style::{Color, Style, Stylize}; 3 | use ratatui::text::{Line, Span}; 4 | use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph}; 5 | use ratatui::Frame; 6 | 7 | use crate::app::App; 8 | use crate::components::clicks::{ClickAction::Rename, PopupBoxAction}; 9 | use crate::components::save_load::FileSaveError; 10 | 11 | use super::{centered_box, DARK_TEXT, WHITE}; 12 | 13 | pub fn show(app: &mut App, f: &mut Frame) { 14 | let area = f.area(); 15 | let has_message = app.input_capture.text_area.error == Some(FileSaveError::NoName); 16 | let box_height = if has_message { 9 } else { 7 }; 17 | let box_width = 38; 18 | 19 | let block_area = centered_box(box_width, box_height, area); 20 | 21 | app.input_capture 22 | .click_mode_popup(&block_area, Rename(PopupBoxAction::Nothing)); 23 | 24 | let block = Block::new() 25 | .title(format!( 26 | " Rename layer: {} ", 27 | app.layers.get_active_layer().name 28 | )) 29 | .title_alignment(Alignment::Center) 30 | .title_style(Style::new().reversed().bold()) 31 | .borders(Borders::all()) 32 | .border_type(BorderType::Rounded); 33 | 34 | let block_inner = block.inner(block_area); 35 | 36 | f.render_widget(Clear, block_area); 37 | f.render_widget(block, block_area); 38 | 39 | let rows = Layout::new( 40 | Direction::Vertical, 41 | vec![Constraint::Min(1); box_height as usize - 2], 42 | ) 43 | .split(block_inner); 44 | 45 | text(app, f, rows[1]); 46 | 47 | if has_message { 48 | message(f, rows[3]); 49 | buttons(app, f, rows[5]); 50 | } else { 51 | buttons(app, f, rows[3]); 52 | } 53 | } 54 | 55 | fn text(app: &App, f: &mut Frame, area: Rect) { 56 | let text_block_area = Layout::new( 57 | Direction::Horizontal, 58 | [ 59 | Constraint::Length((area.width - 22) / 2), 60 | Constraint::Length(22), 61 | Constraint::Length((area.width - 22) / 2), 62 | ], 63 | ) 64 | .split(area)[1]; 65 | 66 | let text_block_bg = Block::new().bg(Color::DarkGray).fg(Color::White); 67 | 68 | let mut text_block_inner = text_block_bg.inner(text_block_area); 69 | text_block_inner.x += 1; 70 | text_block_inner.width -= 1; 71 | 72 | let display_text = Paragraph::new(app.input_capture.text_area.buffer.as_str()); 73 | 74 | let cursor_area = Rect { 75 | x: text_block_inner.x + app.input_capture.text_area.pos as u16, 76 | width: 1, 77 | height: 1, 78 | ..text_block_inner 79 | }; 80 | 81 | let cursor_block = Block::new().reversed(); 82 | 83 | f.render_widget(cursor_block, cursor_area); 84 | f.render_widget(text_block_bg, text_block_area); 85 | f.render_widget(display_text, text_block_inner); 86 | } 87 | 88 | fn message(f: &mut Frame, area: Rect) { 89 | f.render_widget( 90 | Paragraph::new(Line::from( 91 | Span::from(" No layer name provided. ") 92 | .bg(Color::Red) 93 | .fg(WHITE), 94 | )) 95 | .alignment(Alignment::Center), 96 | area, 97 | ) 98 | } 99 | 100 | fn buttons(app: &mut App, f: &mut Frame, area: Rect) { 101 | let buttons_layout = Layout::new( 102 | Direction::Horizontal, 103 | [ 104 | Constraint::Length(7), 105 | Constraint::Length(8), 106 | Constraint::Length(6), 107 | Constraint::Length(8), 108 | Constraint::Length(6), 109 | ], 110 | ) 111 | .split(area); 112 | 113 | let exit_area = buttons_layout[1]; 114 | let accept_area = buttons_layout[3]; 115 | 116 | let exit_button = Paragraph::new(" Cancel ") 117 | .alignment(Alignment::Center) 118 | .bold() 119 | .bg(Color::Red) 120 | .fg(DARK_TEXT); 121 | let accept_button = Paragraph::new(" Accept ") 122 | .alignment(Alignment::Center) 123 | .bold() 124 | .bg(Color::Blue) 125 | .fg(Color::White); 126 | 127 | app.input_capture 128 | .click_mode_popup(&exit_area, Rename(PopupBoxAction::Deny)); 129 | f.render_widget(exit_button, exit_area); 130 | 131 | app.input_capture 132 | .click_mode_popup(&accept_area, Rename(PopupBoxAction::Accept)); 133 | f.render_widget(accept_button, accept_area); 134 | } 135 | -------------------------------------------------------------------------------- /src/ui/sidebar/brushinfo.rs: -------------------------------------------------------------------------------- 1 | use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; 2 | use ratatui::style::{Style, Stylize}; 3 | use ratatui::text::{Line, Span}; 4 | use ratatui::widgets::{Block, Borders, Paragraph}; 5 | use ratatui::Frame; 6 | 7 | use crate::app::App; 8 | use crate::components::clicks::ClickAction::{Next, Prev, Set}; 9 | use crate::components::clicks::Increment::BrushSize; 10 | use crate::components::clicks::ResetValue::{BG, FG}; 11 | use crate::components::clicks::SetValue::Reset; 12 | 13 | use super::{Button, DARK_TEXT, LIGHT_TEXT, TOOL_BORDER}; 14 | 15 | pub fn render(app: &mut App, f: &mut Frame, area: Rect) { 16 | let block_area = block(f, area); 17 | 18 | let brush_layout = Layout::new( 19 | Direction::Horizontal, 20 | [ 21 | Constraint::Length(9), 22 | Constraint::Length(6), 23 | Constraint::Min(0), 24 | ], 25 | ) 26 | .split(block_area); 27 | 28 | render_size_info(app, f, brush_layout[0]); 29 | render_colors(app, f, brush_layout[1]); 30 | render_char_info(app, f, brush_layout[2]); 31 | } 32 | 33 | fn block(f: &mut Frame, area: Rect) -> Rect { 34 | let brush_block = Block::new() 35 | .title("Brush info ".bold()) 36 | .borders(Borders::TOP) 37 | .border_style(Style::new().fg(TOOL_BORDER)); 38 | 39 | let inner_block = brush_block.inner(area); 40 | 41 | f.render_widget(brush_block, area); 42 | 43 | inner_block 44 | } 45 | 46 | fn render_size_info(app: &mut App, f: &mut Frame, area: Rect) { 47 | let brush = app.brush; 48 | 49 | let size_layout = Layout::new(Direction::Vertical, [Constraint::Min(1); 2]).split(area); 50 | 51 | let size_info = Paragraph::new(Line::from(vec![ 52 | Span::from("S").underlined(), 53 | Span::from("ize: "), 54 | Span::from(brush.size.to_string()), 55 | ])) 56 | .fg(LIGHT_TEXT); 57 | 58 | f.render_widget(size_info, size_layout[0]); 59 | 60 | let size_button_layout = Layout::new( 61 | Direction::Horizontal, 62 | [ 63 | Constraint::Length(3), 64 | Constraint::Length(1), 65 | Constraint::Length(3), 66 | ], 67 | ) 68 | .split(size_layout[1]); 69 | 70 | let size_down_area = size_button_layout[0]; 71 | let size_up_area = size_button_layout[2]; 72 | 73 | let size_down_button = Paragraph::new(Line::from(Button::normal("-"))); 74 | let size_up_button = Paragraph::new(Line::from(Button::normal("+"))); 75 | 76 | app.input_capture 77 | .click_mode_normal(&size_down_area, Prev(BrushSize)); 78 | f.render_widget(size_down_button, size_down_area); 79 | 80 | app.input_capture 81 | .click_mode_normal(&size_up_area, Next(BrushSize)); 82 | f.render_widget(size_up_button, size_up_area); 83 | } 84 | 85 | fn render_colors(app: &mut App, f: &mut Frame, area: Rect) { 86 | let current_colors = Paragraph::new(vec![ 87 | Line::from(vec![ 88 | Span::raw("▮").fg(DARK_TEXT), 89 | Span::raw("F").fg(LIGHT_TEXT).underlined(), 90 | Span::raw("G:").fg(LIGHT_TEXT), 91 | Span::from("██").fg(app.brush.fg), 92 | ]), 93 | Line::from(vec![ 94 | Span::raw("▮").fg(LIGHT_TEXT), 95 | Span::raw("B").fg(LIGHT_TEXT).underlined(), 96 | Span::raw("G:").fg(LIGHT_TEXT), 97 | Span::raw("██").fg(app.brush.bg), 98 | ]), 99 | ]) 100 | .alignment(Alignment::Center); 101 | 102 | let fg_area = Rect { 103 | height: 1, 104 | width: 3, 105 | x: area.x + 3, 106 | ..area 107 | }; 108 | let bg_area = Rect { 109 | y: area.y + 1, 110 | ..fg_area 111 | }; 112 | 113 | app.input_capture 114 | .click_mode_normal(&fg_area, Set(Reset(FG))); 115 | 116 | app.input_capture 117 | .click_mode_normal(&bg_area, Set(Reset(BG))); 118 | 119 | f.render_widget(current_colors, area); 120 | } 121 | 122 | fn render_char_info(app: &App, f: &mut Frame, area: Rect) { 123 | let brush = app.brush; 124 | let current_char = Paragraph::new(vec![ 125 | Line::from(vec![Span::from("Character: "), Span::from(brush.char())]).fg(LIGHT_TEXT), 126 | Line::from(vec![ 127 | Span::from("Preview: ").fg(LIGHT_TEXT), 128 | Span::styled(brush.char(), brush.style()), 129 | ]), 130 | ]) 131 | .alignment(Alignment::Right); 132 | 133 | f.render_widget(current_char, area); 134 | } 135 | -------------------------------------------------------------------------------- /src/ui/popup_save.rs: -------------------------------------------------------------------------------- 1 | use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; 2 | use ratatui::style::{Color, Style, Stylize}; 3 | use ratatui::text::{Line, Span}; 4 | use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph}; 5 | use ratatui::Frame; 6 | 7 | use crate::app::App; 8 | use crate::components::clicks::ClickAction::Save; 9 | use crate::components::clicks::PopupBoxAction::*; 10 | use crate::components::save_load::FileSaveError; 11 | 12 | use super::{centered_box, DARK_TEXT, WHITE}; 13 | 14 | pub fn show(app: &mut App, f: &mut Frame) { 15 | let area = f.area(); 16 | let has_message = app.input_capture.text_area.error.is_some(); 17 | let box_height = if has_message { 9 } else { 7 }; 18 | let box_width = 40; 19 | 20 | let block_area = centered_box(box_width, box_height, area); 21 | 22 | app.input_capture 23 | .click_mode_popup(&block_area, Save(Nothing)); 24 | 25 | let block = Block::new() 26 | .title(" Save ") 27 | .title_alignment(Alignment::Center) 28 | .title_style(Style::new().reversed().bold()) 29 | .borders(Borders::all()) 30 | .border_type(BorderType::Rounded); 31 | 32 | let block_inner = block.inner(block_area); 33 | 34 | f.render_widget(Clear, block_area); 35 | f.render_widget(block, block_area); 36 | 37 | let rows = Layout::new( 38 | Direction::Vertical, 39 | vec![Constraint::Min(1); box_height as usize - 2], 40 | ) 41 | .split(block_inner); 42 | 43 | text(app, f, rows[1]); 44 | 45 | if has_message { 46 | message(app, f, rows[3]); 47 | buttons(app, f, rows[5]); 48 | } else { 49 | buttons(app, f, rows[3]); 50 | } 51 | } 52 | 53 | fn message(app: &mut App, f: &mut Frame, area: Rect) { 54 | let Some(message_type) = app.input_capture.text_area.error else { 55 | return; 56 | }; 57 | 58 | let display_message = match message_type { 59 | FileSaveError::NoName => " No file name provided. ", 60 | FileSaveError::NameConflict => " File exists, save again to overwrite. ", 61 | FileSaveError::NoCanvas => " The canvas has no data ", 62 | FileSaveError::CantCreate => " Can't create file ", 63 | FileSaveError::Other => " Saving failed ", 64 | }; 65 | 66 | f.render_widget( 67 | Paragraph::new(Line::from( 68 | Span::from(display_message).bg(Color::Red).fg(WHITE), 69 | )) 70 | .alignment(Alignment::Center), 71 | area, 72 | ) 73 | } 74 | 75 | fn text(app: &App, f: &mut Frame, area: Rect) { 76 | let line_layout = Layout::new( 77 | Direction::Horizontal, 78 | [ 79 | Constraint::Ratio(1, 8), 80 | Constraint::Ratio(3, 4), 81 | Constraint::Ratio(1, 8), 82 | ], 83 | ) 84 | .split(area); 85 | let text_block_area = line_layout[1]; 86 | 87 | let text_block = Block::new().bg(Color::DarkGray).fg(Color::White); 88 | 89 | let text_block_inner = text_block.inner(text_block_area); 90 | 91 | let display_text = Paragraph::new(app.input_capture.text_area.buffer.as_str()); 92 | 93 | let cursor_area = Rect { 94 | x: text_block_inner.x + app.input_capture.text_area.pos as u16, 95 | width: 1, 96 | height: 1, 97 | ..text_block_inner 98 | }; 99 | 100 | let cursor_block = Block::new().reversed(); 101 | 102 | f.render_widget(cursor_block, cursor_area); 103 | f.render_widget(text_block, text_block_area); 104 | f.render_widget(display_text, text_block_inner); 105 | } 106 | 107 | fn buttons(app: &mut App, f: &mut Frame, area: Rect) { 108 | let text_block_area = Layout::new( 109 | Direction::Horizontal, 110 | [ 111 | Constraint::Ratio(1, 8), 112 | Constraint::Ratio(3, 4), 113 | Constraint::Ratio(1, 8), 114 | ], 115 | ) 116 | .split(area)[1]; 117 | let buttons_layout = Layout::new( 118 | Direction::Horizontal, 119 | [ 120 | Constraint::Length(8), 121 | Constraint::Length(text_block_area.width - 14), 122 | Constraint::Length(6), 123 | ], 124 | ) 125 | .split(text_block_area); 126 | 127 | let exit_area = buttons_layout[0]; 128 | let accept_area = buttons_layout[2]; 129 | 130 | let exit_button = Paragraph::new(" Cancel ") 131 | .alignment(Alignment::Center) 132 | .bold() 133 | .bg(Color::Red) 134 | .fg(DARK_TEXT); 135 | let accept_button = Paragraph::new(" Save ") 136 | .alignment(Alignment::Center) 137 | .bold() 138 | .bg(Color::Blue) 139 | .fg(Color::White); 140 | 141 | app.input_capture.click_mode_popup(&exit_area, Save(Deny)); 142 | f.render_widget(exit_button, exit_area); 143 | 144 | app.input_capture 145 | .click_mode_popup(&accept_area, Save(Accept)); 146 | f.render_widget(accept_button, accept_area); 147 | } 148 | -------------------------------------------------------------------------------- /src/ui/sidebar/layermanager.rs: -------------------------------------------------------------------------------- 1 | use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; 2 | use ratatui::style::{Style, Stylize}; 3 | use ratatui::text::Line; 4 | use ratatui::widgets::{block::Title, Block, BorderType, Borders, Padding, Paragraph}; 5 | use ratatui::Frame; 6 | 7 | use crate::app::App; 8 | use crate::components::clicks::ClickAction::Layer; 9 | use crate::components::clicks::LayerAction::{self, *}; 10 | 11 | use super::{ 12 | Button, BG, BG_LAYER_MANAGER, DIM_TEXT, LAYER_SELECTED, LAYER_UNSELECTED, LIGHT_TEXT, 13 | TOOL_BORDER, 14 | }; 15 | 16 | pub fn render(app: &mut App, f: &mut Frame, area: Rect) { 17 | let block = outer_block(app, f, area); 18 | let layout = Layout::new( 19 | Direction::Vertical, 20 | [Constraint::Max(1), Constraint::Min(0)], 21 | ) 22 | .split(block); 23 | 24 | render_buttons(app, f, layout[0]); 25 | render_layers(app, f, layout[1]); 26 | } 27 | 28 | fn outer_block(app: &mut App, f: &mut Frame, area: Rect) -> Rect { 29 | let block = Block::new() 30 | .title(Title::from(" Layers ".bold()).alignment(Alignment::Center)) 31 | .title(Title::from(Button::accent("+")).alignment(Alignment::Right)) 32 | .padding(Padding::horizontal(1)) 33 | .borders(Borders::TOP | Borders::BOTTOM) 34 | .border_style(Style::new().fg(TOOL_BORDER)); 35 | 36 | let add_layer_button = Rect { 37 | height: 1, 38 | width: 3, 39 | x: area.width - 3, 40 | ..area 41 | }; 42 | app.input_capture 43 | .click_mode_normal(&add_layer_button, Layer(Add)); 44 | 45 | let outer_block = block.inner(area); 46 | f.render_widget(block, area); 47 | 48 | outer_block 49 | } 50 | 51 | fn render_buttons(app: &mut App, f: &mut Frame, area: Rect) { 52 | let row = Layout::new( 53 | Direction::Horizontal, 54 | [ 55 | Constraint::Min(8), 56 | Constraint::Min(8), 57 | Constraint::Min(4), 58 | Constraint::Min(6), 59 | ], 60 | ) 61 | .split(area); 62 | 63 | delete_button(app, f, row[0]); 64 | rename_button(app, f, row[1]); 65 | up_button(app, f, row[2]); 66 | down_button(app, f, row[3]); 67 | } 68 | 69 | fn render_layers(app: &mut App, f: &mut Frame, area: Rect) { 70 | let layers_count = app.layers.layers.len(); 71 | 72 | let mut constraints = vec![Constraint::Max(1); layers_count]; 73 | constraints.push(Constraint::Min(0)); 74 | 75 | let block = Block::new() 76 | .borders(Borders::TOP) 77 | .border_type(BorderType::QuadrantOutside) 78 | .border_style(Style::new().fg(BG).bg(BG_LAYER_MANAGER)); 79 | let block_inner = block.inner(area); 80 | 81 | f.render_widget(block, area); 82 | 83 | let rows = Layout::new(Direction::Vertical, constraints).split(block_inner); 84 | 85 | f.render_widget(Block::new().bg(BG_LAYER_MANAGER), rows[layers_count]); 86 | 87 | for (i, (name, show)) in app.layers.get_display_info() { 88 | let index = layers_count - (i + 1); 89 | let is_active_layer = index == app.layers.active; 90 | 91 | // Selected layer background 92 | if is_active_layer { 93 | f.render_widget(Block::new().bg(LAYER_SELECTED).fg(LIGHT_TEXT), rows[i]); 94 | } else { 95 | f.render_widget(Block::new().bg(LAYER_UNSELECTED).fg(DIM_TEXT), rows[i]); 96 | app.input_capture 97 | .click_mode_normal(&rows[i], Layer(Select(index as u8))); 98 | } 99 | 100 | let row = Layout::new( 101 | Direction::Horizontal, 102 | [Constraint::Min(0), Constraint::Max(6)], 103 | ) 104 | .split(rows[i]); 105 | 106 | // Layer 107 | f.render_widget(Paragraph::new(name), row[0]); 108 | 109 | // Show/hide click register 110 | app.input_capture 111 | .click_mode_normal(&row[1], Layer(ToggleVis(index as u8))); 112 | 113 | let btn = if show { 114 | Button::normal("Hide") 115 | } else { 116 | Button::selected("Show") 117 | }; 118 | f.render_widget(Paragraph::new(Line::from(btn)), row[1]); 119 | } 120 | } 121 | 122 | fn delete_button(app: &mut App, f: &mut Frame, area: Rect) { 123 | base_button(app, f, area, Remove, "Delete") 124 | } 125 | 126 | fn rename_button(app: &mut App, f: &mut Frame, area: Rect) { 127 | base_button(app, f, area, Rename, "Rename") 128 | } 129 | 130 | fn up_button(app: &mut App, f: &mut Frame, area: Rect) { 131 | base_button(app, f, area, MoveUp, "Up") 132 | } 133 | 134 | fn down_button(app: &mut App, f: &mut Frame, area: Rect) { 135 | base_button(app, f, area, MoveDown, "Down") 136 | } 137 | 138 | fn base_button(app: &mut App, f: &mut Frame, area: Rect, action: LayerAction, label: &str) { 139 | app.input_capture.click_mode_normal(&area, Layer(action)); 140 | f.render_widget( 141 | Paragraph::new(Line::from(Button::normal(label))).bold(), 142 | area, 143 | ); 144 | } 145 | -------------------------------------------------------------------------------- /src/ui/popup_export.rs: -------------------------------------------------------------------------------- 1 | use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; 2 | use ratatui::style::{Color, Style, Stylize}; 3 | use ratatui::text::{Line, Span}; 4 | use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph}; 5 | use ratatui::Frame; 6 | 7 | use crate::app::App; 8 | use crate::components::clicks::ClickAction::Export; 9 | use crate::components::clicks::PopupBoxAction::*; 10 | use crate::components::save_load::FileSaveError; 11 | 12 | use super::{centered_box, DARK_TEXT, WHITE}; 13 | 14 | pub fn show(app: &mut App, f: &mut Frame) { 15 | let area = f.area(); 16 | let has_message = app.input_capture.text_area.error.is_some(); 17 | let box_height = if has_message { 9 } else { 7 }; 18 | let box_width = 40; 19 | 20 | let block_area = centered_box(box_width, box_height, area); 21 | 22 | app.input_capture 23 | .click_mode_popup(&block_area, Export(Nothing)); 24 | 25 | let block = Block::new() 26 | .title(" Export ") 27 | .title_alignment(Alignment::Center) 28 | .title_style(Style::new().reversed().bold()) 29 | .borders(Borders::all()) 30 | .border_type(BorderType::Rounded); 31 | 32 | let block_inner = block.inner(block_area); 33 | 34 | f.render_widget(Clear, block_area); 35 | f.render_widget(block, block_area); 36 | 37 | let rows = Layout::new( 38 | Direction::Vertical, 39 | vec![Constraint::Min(1); box_height as usize - 2], 40 | ) 41 | .split(block_inner); 42 | 43 | text(app, f, rows[1]); 44 | 45 | if has_message { 46 | message(app, f, rows[3]); 47 | buttons(app, f, rows[5]); 48 | } else { 49 | buttons(app, f, rows[3]); 50 | } 51 | } 52 | 53 | fn message(app: &mut App, f: &mut Frame, area: Rect) { 54 | let Some(message_type) = app.input_capture.text_area.error else { 55 | return; 56 | }; 57 | 58 | let display_message = match message_type { 59 | FileSaveError::NoName => " No file name provided. ", 60 | FileSaveError::NameConflict => " File exists, save again to overwrite. ", 61 | FileSaveError::NoCanvas => " The canvas has no data ", 62 | FileSaveError::CantCreate => " Can't create file ", 63 | FileSaveError::Other => " Saving failed ", 64 | }; 65 | 66 | f.render_widget( 67 | Paragraph::new(Line::from( 68 | Span::from(display_message).bg(Color::Red).fg(WHITE), 69 | )) 70 | .alignment(Alignment::Center), 71 | area, 72 | ) 73 | } 74 | 75 | fn text(app: &App, f: &mut Frame, area: Rect) { 76 | let line_layout = Layout::new( 77 | Direction::Horizontal, 78 | [ 79 | Constraint::Ratio(1, 8), 80 | Constraint::Ratio(3, 4), 81 | Constraint::Ratio(1, 8), 82 | ], 83 | ) 84 | .split(area); 85 | let text_block_area = line_layout[1]; 86 | 87 | let text_block = Block::new().bg(Color::DarkGray).fg(Color::White); 88 | 89 | let text_block_inner = text_block.inner(text_block_area); 90 | 91 | let display_text = Paragraph::new(app.input_capture.text_area.buffer.as_str()); 92 | 93 | let cursor_area = Rect { 94 | x: text_block_inner.x + app.input_capture.text_area.pos as u16, 95 | width: 1, 96 | height: 1, 97 | ..text_block_inner 98 | }; 99 | 100 | let cursor_block = Block::new().reversed(); 101 | 102 | f.render_widget(cursor_block, cursor_area); 103 | f.render_widget(Paragraph::new(".tart"), line_layout[2]); 104 | f.render_widget(text_block, text_block_area); 105 | f.render_widget(display_text, text_block_inner); 106 | } 107 | 108 | fn buttons(app: &mut App, f: &mut Frame, area: Rect) { 109 | let text_block_area = Layout::new( 110 | Direction::Horizontal, 111 | [ 112 | Constraint::Ratio(1, 8), 113 | Constraint::Ratio(3, 4), 114 | Constraint::Ratio(1, 8), 115 | ], 116 | ) 117 | .split(area)[1]; 118 | let buttons_layout = Layout::new( 119 | Direction::Horizontal, 120 | [ 121 | Constraint::Length(8), 122 | Constraint::Length(text_block_area.width - 14), 123 | Constraint::Length(6), 124 | ], 125 | ) 126 | .split(text_block_area); 127 | 128 | let exit_area = buttons_layout[0]; 129 | let accept_area = buttons_layout[2]; 130 | 131 | let exit_button = Paragraph::new(" Cancel ") 132 | .alignment(Alignment::Center) 133 | .bold() 134 | .bg(Color::Red) 135 | .fg(DARK_TEXT); 136 | let accept_button = Paragraph::new(" Save ") 137 | .alignment(Alignment::Center) 138 | .bold() 139 | .bg(Color::Blue) 140 | .fg(Color::White); 141 | 142 | app.input_capture.click_mode_popup(&exit_area, Export(Deny)); 143 | f.render_widget(exit_button, exit_area); 144 | 145 | app.input_capture 146 | .click_mode_popup(&accept_area, Export(Accept)); 147 | f.render_widget(accept_button, accept_area); 148 | } 149 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use clap::{command, Parser}; 2 | use clap_stdin::FileOrStdin; 3 | use ratatui::style::Color; 4 | use regex::Regex; 5 | use terminart::app::{App, AppResult}; 6 | use terminart::components::save_load::{AnsiData, SaveData}; 7 | use terminart::handler::{handle_key_events, handle_mouse_events}; 8 | use terminart::handler::{Event, EventHandler}; 9 | use terminart::tui::Tui; 10 | 11 | use ratatui::backend::CrosstermBackend; 12 | use ratatui::Terminal; 13 | 14 | use std::fs::File; 15 | use std::io; 16 | use std::path::Path; 17 | 18 | #[derive(Parser)] 19 | #[command(version, about, long_about = None)] 20 | struct Cli { 21 | /// ANSI/text/.tart file to edit, can be read from stdin 22 | input: Option, 23 | 24 | #[arg(short, long)] 25 | #[arg(value_parser = color_parser)] 26 | /// Colors to use in palette 27 | /// 28 | /// Formats: "rgb(1,2,3)" / "r,g,b" / #ffffff / #fff 29 | color: Option>, 30 | } 31 | 32 | fn main() -> AppResult<()> { 33 | // Create the application. 34 | let mut app = App::new(); 35 | 36 | // Setup command line interface 37 | let cli = Cli::parse(); 38 | 39 | // Load canvas from user input 40 | if let Some(input) = cli.input { 41 | if input.is_file() { 42 | let file_str = input.filename(); 43 | 44 | let Ok(file) = File::open(file_str) else { 45 | println!("File does not exist: {:?}", file_str); 46 | app.quit(); 47 | return Ok(()); 48 | }; 49 | 50 | if file_str.ends_with(".tart") { 51 | let data: SaveData = ciborium::from_reader(file)?; 52 | 53 | app.brush = data.brush; 54 | app.palette = data.palette; 55 | app.layers.id_list = data.layers.iter().map(|l| l.id).collect(); 56 | app.layers.layers = data.layers; 57 | app.input_capture.last_file_name = 58 | file_str.strip_suffix(".tart").map(|s| s.to_string()); 59 | } else { 60 | app.layers.layers[0].data = AnsiData::open_file(file); 61 | app.layers.layers[0].name = "Imported Layer".into(); 62 | app.input_capture.last_file_name = Path::new(file_str) 63 | .file_stem() 64 | .map(|s| s.to_string_lossy().into()); 65 | } 66 | } else { 67 | let Ok(ansi) = input.contents() else { 68 | println!("Input not readable."); 69 | app.quit(); 70 | return Ok(()); 71 | }; 72 | app.layers.layers[0].data = AnsiData::read_str(ansi); 73 | app.layers.layers[0].name = "Imported Layer".into(); 74 | } 75 | } 76 | 77 | // Importing user colors 78 | if let Some(color_vec) = cli.color { 79 | app.palette 80 | .colors 81 | .iter_mut() 82 | .take(color_vec.len()) 83 | .zip(color_vec) 84 | .for_each(|(og_color, user_color)| *og_color = user_color); 85 | } 86 | 87 | // Initialize the terminal user interface. 88 | let backend = CrosstermBackend::new(io::stderr()); 89 | let terminal = Terminal::new(backend)?; 90 | let events = EventHandler::new(250 /* ms */); 91 | let mut tui = Tui::new(terminal, events)?; 92 | 93 | while app.running { 94 | // Render the interface. 95 | tui.render(&mut app)?; 96 | // Handle events. 97 | match tui.events.next()? { 98 | Event::Tick => {} 99 | Event::Key(key_event) => handle_key_events(key_event, &mut app)?, 100 | Event::Mouse(mouse_event) => handle_mouse_events(mouse_event, &mut app)?, 101 | Event::Resize(width, height) => app.resize(width, height), 102 | Event::Paste(s) => { 103 | // Take the first char from the clipboard and use it as the brush 104 | if let Some(c) = s.chars().next() { 105 | app.brush.char = c; 106 | } 107 | } 108 | } 109 | } 110 | 111 | // Exit the interface 112 | tui.exit()?; 113 | Ok(()) 114 | } 115 | 116 | fn color_parser(c_str: &str) -> core::result::Result { 117 | let full_hex_regex = Regex::new(r#"#([0-9a-fA-F].)(..)(..)"#).unwrap(); 118 | let half_hex_regex = Regex::new(r#"#([0-9a-fA-F])(.)(.)"#).unwrap(); 119 | let full_rgb_regex = Regex::new(r#"rgb\((\d+), ?(\d+), ?(\d+)\)"#).unwrap(); 120 | let rgb_regex = Regex::new(r#"(\d+), ?(\d+), ?(\d+)"#).unwrap(); 121 | 122 | for (i, regex) in [full_hex_regex, half_hex_regex, full_rgb_regex, rgb_regex] 123 | .into_iter() 124 | .enumerate() 125 | { 126 | let captures = regex.captures(c_str).map(|captures| { 127 | captures 128 | .iter() // All the captured groups 129 | .skip(1) // Skipping the complete match 130 | .flatten() // Ignoring all empty optional matches 131 | .map(|c| c.as_str()) // Grab the original strings 132 | .collect::>() // Create a vector 133 | }); 134 | 135 | if let Some(capture_vec) = captures { 136 | println!("{:?}", capture_vec); 137 | 138 | let result_vec: Vec<_> = match i { 139 | 0 => capture_vec 140 | .into_iter() 141 | .map(|i| u8::from_str_radix(i, 16)) 142 | .collect(), 143 | 1 => capture_vec 144 | .into_iter() 145 | .map(|i| u8::from_str_radix(&format!("{}{}", i, i), 16)) 146 | .collect(), 147 | 2 | 3 => capture_vec.into_iter().map(|i| i.parse::()).collect(), 148 | _ => vec![], 149 | }; 150 | 151 | if result_vec.iter().any(Result::is_err) { 152 | return Err(format!("Invalid color format: {}", c_str)); 153 | } else { 154 | let vals: Vec<_> = result_vec.into_iter().map(Result::unwrap).collect(); 155 | return Ok(Color::Rgb(vals[0], vals[1], vals[2])); 156 | } 157 | } 158 | } 159 | 160 | Err(format!("Invalid color format: {}", c_str)) 161 | } 162 | -------------------------------------------------------------------------------- /src/components/save_load.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::io::{BufReader, Read}; 3 | 4 | use anstyle_parse::{DefaultCharAccumulator, Params, Parser, Perform}; 5 | use ratatui::style::Color; 6 | use serde::{Deserialize, Serialize}; 7 | 8 | use super::brush::Brush; 9 | use super::cell::Cell; 10 | use super::layers::{Layer, LayerData}; 11 | use super::palette::Palette; 12 | 13 | #[derive(Debug, Serialize, Deserialize)] 14 | pub struct SaveData { 15 | pub brush: Brush, 16 | pub palette: Palette, 17 | pub layers: Vec, 18 | } 19 | 20 | #[derive(Debug, Clone, Copy, Eq, PartialEq)] 21 | pub enum FileSaveError { 22 | NoName, 23 | NoCanvas, 24 | NameConflict, 25 | CantCreate, 26 | Other, 27 | } 28 | 29 | pub struct AnsiData; 30 | // adapted from: https://github.com/jwalton/rust-ansi-converter/blob/master/src/ansi_parser.rs 31 | 32 | impl AnsiData { 33 | pub fn open_file(file: File) -> LayerData { 34 | let mut performer = AnsiParser::default(); 35 | let mut statemachine = Parser::::new(); 36 | let reader = BufReader::new(file); 37 | for byte in reader.bytes() { 38 | statemachine.advance(&mut performer, byte.unwrap()); 39 | } 40 | 41 | performer.output 42 | } 43 | 44 | pub fn read_str(ansi: String) -> LayerData { 45 | let mut performer = AnsiParser::default(); 46 | let mut statemachine = Parser::::new(); 47 | for byte in ansi.bytes() { 48 | statemachine.advance(&mut performer, byte); 49 | } 50 | 51 | performer.output 52 | } 53 | } 54 | 55 | struct AnsiParser { 56 | current_fg: Color, 57 | current_bg: Color, 58 | current_x: u16, 59 | current_y: u16, 60 | output: LayerData, 61 | } 62 | 63 | impl Default for AnsiParser { 64 | fn default() -> Self { 65 | Self { 66 | current_fg: Default::default(), 67 | current_bg: Default::default(), 68 | current_x: 1, 69 | current_y: 1, 70 | output: Default::default(), 71 | } 72 | } 73 | } 74 | 75 | impl Perform for AnsiParser { 76 | fn print(&mut self, c: char) { 77 | self.output.insert( 78 | (self.current_x, self.current_y), 79 | Cell { 80 | fg: self.current_fg, 81 | bg: self.current_bg, 82 | char: c, 83 | }, 84 | ); 85 | self.current_x += 1; 86 | } 87 | 88 | fn execute(&mut self, byte: u8) { 89 | if byte == b'\n' { 90 | self.current_x = 1; 91 | self.current_y += 1; 92 | } 93 | } 94 | 95 | fn csi_dispatch(&mut self, params: &Params, _intermediates: &[u8], _ignore: bool, _c: u8) { 96 | let mut iter = params.iter(); 97 | while let Some(value) = iter.next() { 98 | match value[0] { 99 | 38 => match iter.next().unwrap()[0] { 100 | 5 => { 101 | let c256 = iter.next().unwrap()[0]; 102 | self.current_fg = Color::Indexed(c256 as u8) 103 | } 104 | 2 => { 105 | let r = iter.next().unwrap()[0] as u8; 106 | let g = iter.next().unwrap()[0] as u8; 107 | let b = iter.next().unwrap()[0] as u8; 108 | self.current_fg = Color::Rgb(r, g, b) 109 | } 110 | _ => { 111 | // panic!("Unknown color mode"); 112 | return; 113 | } 114 | }, 115 | 48 => match iter.next().unwrap()[0] { 116 | 5 => { 117 | let c256 = iter.next().unwrap()[0]; 118 | self.current_bg = Color::Indexed(c256 as u8) 119 | } 120 | 2 => { 121 | let r = iter.next().unwrap()[0] as u8; 122 | let g = iter.next().unwrap()[0] as u8; 123 | let b = iter.next().unwrap()[0] as u8; 124 | self.current_bg = Color::Rgb(r, g, b) 125 | } 126 | _ => { 127 | // panic!("Unknown color mode"); 128 | return; 129 | } 130 | }, 131 | 30..=37 | 39 | 90..=97 => { 132 | self.current_fg = match value[0] { 133 | 30 => Color::Black, 134 | 31 => Color::Red, 135 | 32 => Color::Green, 136 | 33 => Color::Yellow, 137 | 34 => Color::Blue, 138 | 35 => Color::Magenta, 139 | 36 => Color::Cyan, 140 | 37 => Color::Gray, 141 | 39 => Color::Reset, 142 | 90 => Color::DarkGray, 143 | 91 => Color::LightRed, 144 | 92 => Color::LightGreen, 145 | 93 => Color::LightYellow, 146 | 94 => Color::LightBlue, 147 | 95 => Color::LightMagenta, 148 | 96 => Color::LightCyan, 149 | 97 => Color::White, 150 | _ => return, 151 | } 152 | } 153 | 40..=47 | 49 | 100..=107 => { 154 | self.current_bg = match value[0] { 155 | 40 => Color::Black, 156 | 41 => Color::Red, 157 | 42 => Color::Green, 158 | 43 => Color::Yellow, 159 | 44 => Color::Blue, 160 | 45 => Color::Magenta, 161 | 46 => Color::Cyan, 162 | 47 => Color::Gray, 163 | 49 => Color::Reset, 164 | 100 => Color::DarkGray, 165 | 101 => Color::LightRed, 166 | 102 => Color::LightGreen, 167 | 103 => Color::LightYellow, 168 | 104 => Color::LightBlue, 169 | 105 => Color::LightMagenta, 170 | 106 => Color::LightCyan, 171 | 107 => Color::White, 172 | _ => return, 173 | } 174 | } 175 | 0 => { 176 | self.current_fg = Color::Reset; 177 | self.current_bg = Color::Reset; 178 | } 179 | _v => { 180 | // panic!("Unhandled color mode {v}"); 181 | return; 182 | } 183 | } 184 | } 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/components/input/color.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | num::{IntErrorKind, ParseIntError}, 3 | time::{Duration, Instant}, 4 | }; 5 | 6 | use ratatui::prelude::Color; 7 | 8 | use crate::ui::COLOR_STEP_AMT; 9 | 10 | use super::text::TextArea; 11 | 12 | #[derive(Clone, Copy, Default, Debug, PartialEq, Eq)] 13 | pub enum TextFocus { 14 | #[default] 15 | Hex, 16 | Red, 17 | Green, 18 | Blue, 19 | } 20 | 21 | #[derive(Debug)] 22 | pub struct ColorPicker { 23 | pub r: u8, 24 | pub g: u8, 25 | pub b: u8, 26 | pub text: TextArea, 27 | pub focus: TextFocus, 28 | last_update: Instant, 29 | } 30 | 31 | impl Default for ColorPicker { 32 | fn default() -> Self { 33 | Self { 34 | r: 0, 35 | g: 0, 36 | b: 0, 37 | text: TextArea::default(), 38 | focus: TextFocus::default(), 39 | last_update: Instant::now(), 40 | } 41 | } 42 | } 43 | 44 | impl ColorPicker { 45 | pub fn reset(&mut self) { 46 | *self = Self::default(); 47 | } 48 | 49 | pub fn colors(&self) -> [(u8, TextFocus); 3] { 50 | [ 51 | (self.r, TextFocus::Red), 52 | (self.g, TextFocus::Green), 53 | (self.b, TextFocus::Blue), 54 | ] 55 | } 56 | 57 | pub fn pos(&self) -> u16 { 58 | self.text.pos as u16 59 | } 60 | 61 | /// Returns a ratatui Color of the current RGB values 62 | pub fn get_style_color(&self) -> Color { 63 | Color::Rgb(self.r, self.g, self.b) 64 | } 65 | 66 | pub fn get_hex_str(&self) -> String { 67 | match self.focus { 68 | TextFocus::Hex => self.text.buffer.clone(), 69 | _ => format!("{:02X?}{:02X?}{:02X?}", self.r, self.g, self.b), 70 | } 71 | } 72 | 73 | pub fn plus(&mut self, target: TextFocus) { 74 | match target { 75 | TextFocus::Hex => {} 76 | TextFocus::Red => self.r = self.r.saturating_add(COLOR_STEP_AMT), 77 | TextFocus::Green => self.g = self.g.saturating_add(COLOR_STEP_AMT), 78 | TextFocus::Blue => self.b = self.b.saturating_add(COLOR_STEP_AMT), 79 | }; 80 | } 81 | 82 | pub fn minus(&mut self, target: TextFocus) { 83 | match target { 84 | TextFocus::Hex => {} 85 | TextFocus::Red => self.r = self.r.saturating_sub(COLOR_STEP_AMT), 86 | TextFocus::Green => self.g = self.g.saturating_sub(COLOR_STEP_AMT), 87 | TextFocus::Blue => self.b = self.b.saturating_sub(COLOR_STEP_AMT), 88 | }; 89 | } 90 | 91 | pub fn tab(&mut self) { 92 | self.set_attention(match self.focus { 93 | TextFocus::Hex => TextFocus::Red, 94 | TextFocus::Red => TextFocus::Green, 95 | TextFocus::Green => TextFocus::Blue, 96 | TextFocus::Blue => TextFocus::Hex, 97 | }); 98 | } 99 | 100 | pub fn backtab(&mut self) { 101 | self.set_attention(match self.focus { 102 | TextFocus::Hex => TextFocus::Blue, 103 | TextFocus::Red => TextFocus::Hex, 104 | TextFocus::Green => TextFocus::Red, 105 | TextFocus::Blue => TextFocus::Green, 106 | }); 107 | } 108 | 109 | pub fn set(&mut self, target: TextFocus, val: u8) { 110 | if self.last_update.elapsed() < Duration::from_millis(50) { 111 | return; 112 | } 113 | 114 | self.last_update = Instant::now(); 115 | 116 | match target { 117 | TextFocus::Hex => {} 118 | TextFocus::Red => self.r = val, 119 | TextFocus::Green => self.g = val, 120 | TextFocus::Blue => self.b = val, 121 | }; 122 | } 123 | 124 | /// Read the buffer as a hexidecimal color code and set the r, g, b values accordingly 125 | fn buf_to_hex(&mut self) { 126 | let buffer = &mut self.text.buffer; 127 | *buffer = buffer.replacen('#', "", 1); 128 | 129 | match buffer.len() { 130 | 3 => { 131 | let mut new_hex = String::with_capacity(6); 132 | 133 | for char in buffer.chars() { 134 | new_hex += &format!("{}{}", char, char); 135 | } 136 | 137 | *buffer = new_hex; 138 | 139 | self.buf_to_hex(); 140 | } 141 | 6 => { 142 | if buffer.chars().all(|c| c.is_ascii_hexdigit()) { 143 | let r = u8::from_str_radix(&buffer[0..=1], 16).unwrap_or(0); 144 | let g = u8::from_str_radix(&buffer[2..=3], 16).unwrap_or(0); 145 | let b = u8::from_str_radix(&buffer[4..=5], 16).unwrap_or(0); 146 | 147 | self.r = r; 148 | self.g = g; 149 | self.b = b; 150 | } 151 | } 152 | _ => {} 153 | } 154 | } 155 | 156 | /// Read the buffer as a u8 and returns a result to be used to set the color componenet 157 | fn buf_to_comp(&mut self) -> Result { 158 | let value = match self.text.buffer.parse::() { 159 | Err(e) => match e.kind() { 160 | IntErrorKind::PosOverflow => Ok(255), 161 | IntErrorKind::NegOverflow => Ok(0), 162 | _ => Err(e), 163 | }, 164 | i => i, 165 | }; 166 | 167 | value 168 | } 169 | 170 | pub fn update(&mut self) { 171 | match self.focus { 172 | TextFocus::Hex => self.buf_to_hex(), 173 | TextFocus::Red => { 174 | if let Ok(new_value) = self.buf_to_comp() { 175 | self.r = new_value; 176 | } 177 | } 178 | TextFocus::Green => { 179 | if let Ok(new_value) = self.buf_to_comp() { 180 | self.g = new_value; 181 | } 182 | } 183 | TextFocus::Blue => { 184 | if let Ok(new_value) = self.buf_to_comp() { 185 | self.b = new_value; 186 | } 187 | } 188 | } 189 | } 190 | 191 | pub fn set_attention(&mut self, attn: TextFocus) { 192 | if self.focus == attn { 193 | return; 194 | } 195 | 196 | self.update(); 197 | 198 | self.text.clear(); 199 | 200 | self.text.buffer = match attn { 201 | TextFocus::Hex => self.get_hex_str(), 202 | TextFocus::Red => format!("{}", self.r), 203 | TextFocus::Green => format!("{}", self.g), 204 | TextFocus::Blue => format!("{}", self.b), 205 | }; 206 | 207 | self.text.pos = self.text.buffer.len(); 208 | 209 | self.focus = attn; 210 | } 211 | 212 | pub fn input(&mut self, c: char) { 213 | match self.focus { 214 | TextFocus::Hex => { 215 | if !c.is_ascii_hexdigit() { 216 | return; 217 | } 218 | self.text.input(c, 6); 219 | } 220 | _ => { 221 | if !c.is_ascii_digit() { 222 | return; 223 | } 224 | self.text.input(c, 6); 225 | 226 | self.update(); 227 | } 228 | } 229 | 230 | // self.update(); 231 | } 232 | } 233 | 234 | // fn is_hex_color(s: &str) -> bool { 235 | // match s.len() { 236 | // 3 | 6 => s.chars().all(|c| c.is_ascii_hexdigit()), 237 | // _ => false, 238 | // } 239 | // } 240 | -------------------------------------------------------------------------------- /src/components/layers.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use super::cell::Cell; 4 | 5 | /// Wrapper type for the HashMap that stores the layer 6 | /// Indexing begins at (1, 1), values below will be ignored 7 | pub type LayerData = hashbrown::HashMap<(u16, u16), Cell>; 8 | 9 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 10 | pub struct Layer { 11 | pub name: String, 12 | pub visible: bool, 13 | pub id: u32, 14 | pub data: LayerData, 15 | } 16 | 17 | impl Layer { 18 | pub fn new() -> (Self, u32) { 19 | let this = Self { 20 | name: "New Layer".into(), 21 | visible: true, 22 | id: alea::u32(), 23 | data: LayerData::default(), 24 | }; 25 | let id = this.id; 26 | (this, id) 27 | } 28 | 29 | pub fn toggle_visible(&mut self) { 30 | self.visible = !self.visible; 31 | } 32 | } 33 | 34 | #[derive(Debug, Clone)] 35 | pub struct Layers { 36 | pub layers: Vec, 37 | pub last_pos: Option<(u16, u16)>, 38 | pub active: usize, 39 | pub id_list: Vec, 40 | rendered: Option, 41 | } 42 | 43 | impl Default for Layers { 44 | fn default() -> Self { 45 | let (layer, id) = Layer::new(); 46 | Self { 47 | layers: vec![layer], 48 | active: 0, 49 | last_pos: None, 50 | id_list: vec![id], 51 | rendered: None, 52 | } 53 | } 54 | } 55 | 56 | impl Layers { 57 | /// This checks the [selected] property against the amount of [Layers] 58 | /// It will add a Layer if necessary 59 | #[rustfmt::skip] 60 | fn check_self(&mut self) { // Before you wreck self 61 | let len = self.layers.len(); 62 | 63 | if self.layers.is_empty() { 64 | self.add_layer(); 65 | } 66 | 67 | if self.active >= len { 68 | self.active = len.saturating_sub(1); 69 | } 70 | } 71 | 72 | pub fn current_layer_mut(&mut self) -> &mut Layer { 73 | self.check_self(); 74 | self.queue_render(); 75 | &mut self.layers[self.active] 76 | } 77 | 78 | pub fn toggle_visible(&mut self, index: u8) { 79 | self.queue_render(); 80 | if let Some(layer) = self.layers.get_mut(index as usize) { 81 | layer.toggle_visible(); 82 | } 83 | } 84 | 85 | pub fn add_layer(&mut self) -> u32 { 86 | let (new_layer, new_layer_id) = Layer::new(); 87 | 88 | // Id generation is purely random and has a non-zero chance of having duplicate ids, this is a shitty solution to that 89 | if self.id_list.contains(&new_layer_id) { 90 | self.add_layer() 91 | } else { 92 | self.id_list.push(new_layer_id); 93 | self.layers.push(new_layer); 94 | new_layer_id 95 | } 96 | } 97 | 98 | pub fn get_active_layer(&mut self) -> &Layer { 99 | self.check_self(); 100 | &self.layers[self.active] 101 | } 102 | pub fn get_layer_mut(&mut self, layer_id: u32) -> &mut Layer { 103 | let index_option = self.layers.iter().position(|l| l.id == layer_id); 104 | self.queue_render(); 105 | 106 | match index_option { 107 | Some(index) => &mut self.layers[index], 108 | None => { 109 | let (new_layer, new_layer_id) = Layer::new(); 110 | self.layers.push(new_layer); 111 | self.id_list.push(new_layer_id); 112 | #[allow(clippy::unwrap_used)] 113 | self.layers.last_mut().unwrap() 114 | } 115 | } 116 | } 117 | 118 | pub fn set_active_layer(&mut self, index: u8) { 119 | self.active = index as usize; 120 | } 121 | 122 | /// Removes the currently selected layer 123 | pub fn remove_active_layer(&mut self) -> (Layer, usize) { 124 | let layer = self.layers.remove(self.active); 125 | let old_index = self.active; 126 | self.active = self.active.saturating_sub(1); 127 | self.check_self(); 128 | self.queue_render(); 129 | (layer, old_index) 130 | } 131 | 132 | /// Returns the lauer identifier and the old name 133 | pub fn rename_active_layer(&mut self, new_name: String) -> (u32, String) { 134 | self.check_self(); 135 | let layer = &mut self.layers[self.active]; 136 | let old_name = layer.name.clone(); 137 | layer.name = new_name; 138 | (layer.id, old_name) 139 | } 140 | 141 | pub fn move_layer_up(&mut self) -> u32 { 142 | let max_index = self.layers.len() - 1; 143 | let new_index = (self.active + 1).min(max_index); 144 | self.layers.swap(self.active, new_index); 145 | self.queue_render(); 146 | self.active = new_index; 147 | self.layers[new_index].id 148 | } 149 | 150 | pub fn move_layer_up_by_id(&mut self, layer_id: u32) -> bool { 151 | let max_index = self.layers.len() - 1; 152 | let Some(layer_index) = self.layers.iter().position(|l| l.id == layer_id) else { 153 | return false; 154 | }; 155 | let new_index = (layer_index + 1).min(max_index); 156 | if new_index != layer_index { 157 | self.layers.swap(layer_index, new_index); 158 | self.active = new_index; 159 | self.queue_render(); 160 | return true; 161 | } 162 | false 163 | } 164 | 165 | pub fn move_layer_down(&mut self) -> u32 { 166 | let new_index = self.active.saturating_sub(1); 167 | self.layers.swap(self.active, new_index); 168 | self.queue_render(); 169 | self.active = new_index; 170 | self.layers[new_index].id 171 | } 172 | 173 | pub fn move_layer_down_by_id(&mut self, layer_id: u32) -> bool { 174 | let Some(layer_index) = self.layers.iter().position(|l| l.id == layer_id) else { 175 | return false; 176 | }; 177 | let new_index = layer_index.saturating_sub(1); 178 | if new_index != layer_index { 179 | self.layers.swap(layer_index, new_index); 180 | self.active = new_index; 181 | self.queue_render(); 182 | return true; 183 | } 184 | false 185 | } 186 | 187 | pub fn get_display_info(&self) -> Vec<(usize, (&str, bool))> { 188 | self.layers 189 | .iter() 190 | .map(|l| (l.name.as_str(), l.visible)) 191 | .rev() 192 | .enumerate() 193 | .collect() 194 | } 195 | 196 | pub fn queue_render(&mut self) { 197 | self.rendered = None; 198 | } 199 | 200 | /// Combine all of the layers into a final output 201 | pub fn render(&mut self) -> LayerData { 202 | self.rendered 203 | .get_or_insert_with(|| { 204 | self.layers 205 | .iter() 206 | // Only render visible layers 207 | .filter(|l| l.visible) 208 | .fold(LayerData::default(), |mut page, layer| { 209 | page.extend(layer.data.iter().filter(|&(_, &c)| c != Cell::default())); 210 | page 211 | }) 212 | }) 213 | .clone() 214 | } 215 | 216 | pub fn remove_layer_by_id(&mut self, id: u32) { 217 | self.layers.retain(|l| l.id != id); 218 | self.id_list.retain(|id0| id0 != &id); 219 | } 220 | 221 | pub fn insert_layer(&mut self, layer: Layer, pos: usize) { 222 | let id = layer.id; 223 | self.id_list.push(id); 224 | self.layers.insert(pos, layer); 225 | } 226 | 227 | pub fn add_layer_with_id(&mut self, id: u32) { 228 | let (mut new_layer, _) = Layer::new(); 229 | new_layer.id = id; 230 | 231 | self.id_list.push(id); 232 | self.layers.push(new_layer); 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | use crate::components::brush::Brush; 2 | use crate::components::cell::Cell; 3 | use crate::components::charpicker::CharPicker; 4 | use crate::components::history::{History, HistoryAction}; 5 | use crate::components::input::InputCapture; 6 | use crate::components::layers::{LayerData, Layers}; 7 | use crate::components::palette::Palette; 8 | use crate::ui::TOOLBOX_WIDTH; 9 | 10 | /// Application result type. 11 | pub type AppResult = core::result::Result>; 12 | 13 | /// Application. 14 | #[derive(Default, Debug)] 15 | pub struct App { 16 | pub running: bool, 17 | pub layers: Layers, 18 | pub input_capture: InputCapture, 19 | pub history: History, 20 | pub palette: Palette, 21 | pub char_picker: CharPicker, 22 | pub brush: Brush, 23 | } 24 | 25 | impl App { 26 | /// Constructs a new instance of [`App`]. 27 | #[must_use] 28 | pub fn new() -> Self { 29 | Self { 30 | running: true, 31 | ..Default::default() 32 | } 33 | } 34 | 35 | /// Handles the tick event of the terminal. 36 | pub const fn tick(&self) {} 37 | 38 | pub fn quit(&mut self) { 39 | self.running = false; 40 | } 41 | 42 | pub fn resize(&mut self, width: u16, height: u16) { 43 | self.input_capture.clear(); 44 | 45 | for layer in self.layers.layers.iter_mut() { 46 | layer 47 | .data 48 | .retain(|&(cx, cy), _cell| cx < width && cy < height); 49 | } 50 | } 51 | 52 | /// Removes a cell from the current layer and returns the cell value 53 | pub fn erase(&mut self, x: u16, y: u16) -> Cell { 54 | let layer = self.layers.current_layer_mut(); 55 | let old_cell = layer.data.remove(&(x, y)).unwrap_or_default(); 56 | 57 | self.history.forget_redo(); 58 | 59 | old_cell 60 | } 61 | 62 | pub fn draw(&mut self, x: u16, y: u16) -> LayerData { 63 | let x = x - TOOLBOX_WIDTH; 64 | 65 | let size = self.brush.size; 66 | let tool = self.brush.tool; 67 | let mut old_cells = LayerData::new(); 68 | 69 | let path = connect_points((x, y), self.layers.last_pos); 70 | 71 | for (x, y) in path { 72 | let mut partial_draw_step = tool.draw(x, y, size, self); 73 | partial_draw_step.extend(old_cells); 74 | 75 | old_cells = partial_draw_step; 76 | } 77 | 78 | self.layers.last_pos = Some((x, y)); 79 | 80 | old_cells 81 | } 82 | 83 | pub fn put_cell(&mut self, x: u16, y: u16) -> Cell { 84 | let layer = self.layers.current_layer_mut(); 85 | let new_cell = self.brush.as_cell(); 86 | 87 | let old_cell = layer.data.insert((x, y), new_cell).unwrap_or_default(); 88 | 89 | self.history.forget_redo(); 90 | 91 | old_cell 92 | } 93 | 94 | pub fn insert_at_cell(&mut self, x: u16, y: u16, cell: Cell) -> Cell { 95 | let layer = self.layers.current_layer_mut(); 96 | 97 | let old_cell = layer.data.insert((x, y), cell).unwrap_or_default(); 98 | 99 | self.history.forget_redo(); 100 | 101 | old_cell 102 | } 103 | 104 | pub fn undo(&mut self) { 105 | let Some(mut action) = self.history.past.pop() else { 106 | return; 107 | }; 108 | 109 | match action { 110 | HistoryAction::LayerAdded(id) => { 111 | self.layers.remove_layer_by_id(id); 112 | } 113 | HistoryAction::LayerRemoved(ref layer, index) => { 114 | self.layers.insert_layer(layer.clone(), index); 115 | } 116 | HistoryAction::LayerRenamed(id, old_name) => { 117 | let layer = self.layers.get_layer_mut(id); 118 | let current_name = layer.name.clone(); 119 | 120 | layer.name = old_name; 121 | 122 | action = HistoryAction::LayerRenamed(id, current_name); 123 | } 124 | HistoryAction::Draw(layer_id, ref draw_data) => { 125 | let mut old_data = LayerData::new(); 126 | for (&pos, &cell) in draw_data { 127 | let cell_op = self.layers.get_layer_mut(layer_id).data.insert(pos, cell); 128 | 129 | if let Some(cell) = cell_op { 130 | old_data.insert(pos, cell); 131 | } 132 | } 133 | action = HistoryAction::Draw(layer_id, old_data); 134 | } 135 | HistoryAction::LayerUp(layer_id) => { 136 | let _ = self.layers.move_layer_down_by_id(layer_id); 137 | } 138 | HistoryAction::LayerDown(layer_id) => { 139 | let _ = self.layers.move_layer_up_by_id(layer_id); 140 | } 141 | } 142 | 143 | self.history.future.push(action); 144 | self.layers.queue_render(); 145 | } 146 | 147 | pub fn redo(&mut self) { 148 | let Some(mut action) = self.history.future.pop() else { 149 | return; 150 | }; 151 | 152 | match action { 153 | HistoryAction::LayerAdded(id) => self.layers.add_layer_with_id(id), 154 | HistoryAction::LayerRemoved(ref layer, _index) => { 155 | self.layers.remove_layer_by_id(layer.id); 156 | } 157 | HistoryAction::LayerRenamed(id, name) => { 158 | let layer = self.layers.get_layer_mut(id); 159 | let current_name = layer.name.clone(); 160 | layer.name = name; 161 | 162 | action = HistoryAction::LayerRenamed(id, current_name); 163 | } 164 | HistoryAction::Draw(layer_id, ref draw_data) => { 165 | let mut old_data = LayerData::new(); 166 | for (&pos, &cell) in draw_data { 167 | let cell_op = self.layers.get_layer_mut(layer_id).data.insert(pos, cell); 168 | 169 | if let Some(cell) = cell_op { 170 | old_data.insert(pos, cell); 171 | } 172 | } 173 | action = HistoryAction::Draw(layer_id, old_data); 174 | } 175 | HistoryAction::LayerUp(layer_id) => { 176 | self.layers.move_layer_up_by_id(layer_id); 177 | } 178 | HistoryAction::LayerDown(layer_id) => { 179 | self.layers.move_layer_down_by_id(layer_id); 180 | } 181 | } 182 | 183 | self.history.past.push(action); 184 | self.layers.queue_render(); 185 | } 186 | 187 | pub fn remove_active_layer(&mut self) { 188 | let (layer, index) = self.layers.remove_active_layer(); 189 | self.history.remove_layer(layer, index); 190 | } 191 | 192 | pub fn apply_rename(&mut self) -> Option<()> { 193 | let new_name = self.input_capture.text_area.get()?; 194 | let (id, old_name) = self.layers.rename_active_layer(new_name); 195 | self.history.rename_layer(id, old_name); 196 | Some(()) 197 | } 198 | 199 | pub fn reset(&mut self) { 200 | self.layers = Layers::default(); 201 | self.history = History::default(); 202 | self.palette = Palette::default(); 203 | self.brush = Brush::default(); 204 | self.layers.queue_render(); 205 | } 206 | } 207 | 208 | fn connect_points(start: (u16, u16), end: Option<(u16, u16)>) -> Vec<(u16, u16)> { 209 | let Some(end) = end else { 210 | return vec![start]; 211 | }; 212 | 213 | let start_x = start.0 as i16; 214 | let start_y = start.1 as i16; 215 | let end_x = end.0 as i16; 216 | let end_y = end.1 as i16; 217 | 218 | let x_diff = start_x - end_x; 219 | let y_diff = start_y - end_y; 220 | let x_diff_abs = x_diff.abs(); 221 | let y_diff_abs = y_diff.abs(); 222 | 223 | let x_is_larger = x_diff_abs > y_diff_abs; 224 | 225 | let x_mod = if x_diff < 0 { 1 } else { -1 }; 226 | let y_mod = if y_diff < 0 { 1 } else { -1 }; 227 | 228 | let longer_side = x_diff_abs.max(y_diff_abs); 229 | let shorter_side = x_diff_abs.min(y_diff_abs); 230 | 231 | let slope = if longer_side == 0 { 232 | 0.0 233 | } else { 234 | shorter_side as f64 / longer_side as f64 235 | }; 236 | 237 | let mut out = Vec::with_capacity(longer_side as usize); 238 | 239 | for i in 1..=longer_side { 240 | let shorter_side_increase = (i as f64 * slope).round() as i16; 241 | 242 | let (x_add, y_add) = if x_is_larger { 243 | (i, shorter_side_increase) 244 | } else { 245 | (shorter_side_increase, i) 246 | }; 247 | 248 | let new_x = start_x + x_add * x_mod; 249 | let new_y = start_y + y_add * y_mod; 250 | 251 | if let (Ok(x), Ok(y)) = (u16::try_from(new_x), u16::try_from(new_y)) { 252 | out.push((x, y)) 253 | } 254 | } 255 | 256 | out 257 | } 258 | -------------------------------------------------------------------------------- /src/components/tools.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use crate::app::App; 6 | 7 | use super::layers::LayerData; 8 | 9 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] 10 | #[repr(u8)] 11 | pub enum Tools { 12 | Eraser = 1, 13 | Square = 2, 14 | Box = 3, 15 | Disk = 4, 16 | Circle = 5, 17 | #[default] 18 | Point = 6, 19 | Plus = 7, 20 | Vertical = 8, 21 | Horizontal = 9, 22 | } 23 | 24 | impl fmt::Display for Tools { 25 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 26 | write!(f, "{:?}", self) 27 | } 28 | } 29 | 30 | impl Tools { 31 | pub fn all() -> Vec { 32 | vec![ 33 | Self::Eraser, 34 | Self::Square, 35 | Self::Box, 36 | Self::Disk, 37 | Self::Circle, 38 | Self::Point, 39 | Self::Plus, 40 | Self::Vertical, 41 | Self::Horizontal, 42 | ] 43 | } 44 | 45 | pub fn char(&self) -> String { 46 | match self { 47 | Self::Eraser => '×', 48 | Self::Square => '■', 49 | Self::Box => '□', 50 | Self::Disk => '●', 51 | Self::Circle => '○', 52 | Self::Point => '∙', 53 | Self::Plus => '🞣', 54 | Self::Vertical => '|', 55 | Self::Horizontal => '─', 56 | } 57 | .to_string() 58 | } 59 | 60 | pub fn draw(&self, x: u16, y: u16, size: u16, app: &mut App) -> LayerData { 61 | match self { 62 | Tools::Eraser => eraser_tool(x, y, size, app), 63 | Tools::Square => square_tool(x, y, size, app), 64 | Tools::Box => box_tool(x, y, size, app), 65 | Tools::Disk => todo!("Disk (filled Circle)"), 66 | Tools::Circle => circle_tool(x, y, size, app), 67 | Tools::Point => { 68 | let mut old_cell = LayerData::new(); 69 | old_cell.insert((x, y), app.put_cell(x, y)); 70 | old_cell 71 | } 72 | Tools::Plus => plus(x, y, size, app), 73 | Tools::Vertical => vert(x, y, size, app), 74 | Tools::Horizontal => horiz(x, y, size, app), 75 | } 76 | } 77 | } 78 | 79 | pub fn plus(x: u16, y: u16, size: u16, app: &mut App) -> LayerData { 80 | let mut old_cells = LayerData::new(); 81 | 82 | // old_cells.insert((x, y), app.draw(x, y)); 83 | old_cells.insert((x, y), app.put_cell(x, y)); 84 | 85 | if size == 1 { 86 | return old_cells; 87 | } 88 | 89 | for s in [-1, 1] as [i16; 2] { 90 | for i in 1..=size as i16 { 91 | let x_arm = s * i + x as i16; 92 | let y_arm = s * i + y as i16; 93 | if x_arm >= 0 { 94 | let x_arm = x_arm as u16; 95 | // old_cells.insert((x_arm, y), app.draw(x_arm, y)); 96 | old_cells.insert((x, y), app.put_cell(x_arm, y)); 97 | } 98 | if y_arm >= 0 { 99 | let y_arm = y_arm as u16; 100 | // old_cells.insert((x, y_arm), app.draw(x, y_arm)); 101 | old_cells.insert((x, y), app.put_cell(x, y_arm)); 102 | } 103 | } 104 | } 105 | 106 | old_cells 107 | } 108 | 109 | pub fn horiz(x: u16, y: u16, size: u16, app: &mut App) -> LayerData { 110 | let mut old_cells = LayerData::new(); 111 | 112 | // old_cells.insert((x, y), app.draw(x, y)); 113 | old_cells.insert((x, y), app.put_cell(x, y)); 114 | 115 | if size == 1 { 116 | return old_cells; 117 | } 118 | 119 | for s in [-1, 1] as [i16; 2] { 120 | for i in 1..=size as i16 { 121 | let x_arm = s * i + x as i16; 122 | if x_arm >= 0 { 123 | let x_arm = x_arm as u16; 124 | // old_cells.insert((x_arm, y), app.draw(x_arm, y)); 125 | old_cells.insert((x, y), app.put_cell(x_arm, y)); 126 | } 127 | } 128 | } 129 | 130 | old_cells 131 | } 132 | 133 | pub fn vert(x: u16, y: u16, size: u16, app: &mut App) -> LayerData { 134 | let mut old_cells = LayerData::new(); 135 | 136 | // old_cells.insert((x, y), app.draw(x, y)); 137 | old_cells.insert((x, y), app.put_cell(x, y)); 138 | 139 | if size == 1 { 140 | return old_cells; 141 | } 142 | 143 | for s in [-1, 1] as [i16; 2] { 144 | for i in 1..=size as i16 { 145 | let y_arm = s * i + y as i16; 146 | if y_arm >= 0 { 147 | let y_arm = y_arm as u16; 148 | // old_cells.insert((x, y_arm), app.draw(x, y_arm)); 149 | old_cells.insert((x, y), app.put_cell(x, y_arm)); 150 | } 151 | } 152 | } 153 | 154 | old_cells 155 | } 156 | 157 | // fn disk_tool(x: u16, y: u16, size: u16, app: &mut App) -> LayerData { 158 | // let mut old_cells = LayerData::new(); 159 | // let (x, y) = (x as i16, y as i16); 160 | // 161 | // let mut x_r = size as i16; 162 | // let mut y_r = 0; 163 | // let mut p = 1 - x_r; 164 | // 165 | // // if self.size > 0 { 166 | // app.draw(x + x_r, y); 167 | // app.draw(x - x_r, y); 168 | // app.draw(x, y + x_r); 169 | // app.draw(x, y - x_r); 170 | // // } 171 | // 172 | // while x_r > y_r { 173 | // y_r += 1; 174 | // if p <= 0 { 175 | // p = p + 2 * y_r + 1; 176 | // } else { 177 | // x_r -= 1; 178 | // p = p + 2 * y_r - 2 * x_r + 1; 179 | // } 180 | // 181 | // if x_r < y_r { 182 | // break; 183 | // } 184 | // 185 | // // Draw the points at the circumference 186 | // app.draw(x + x_r, y + y_r); 187 | // app.draw(x - x_r, y + y_r); 188 | // app.draw(x + x_r, y - y_r); 189 | // app.draw(x - x_r, y - y_r); 190 | // 191 | // if x_r != y_r { 192 | // app.draw(x + y_r, y + x_r); 193 | // app.draw(x - y_r, y + x_r); 194 | // app.draw(x + y_r, y - x_r); 195 | // app.draw(x - y_r, y - x_r); 196 | // } 197 | // } 198 | // old_cells 199 | // } 200 | 201 | fn circle_tool(x: u16, y: u16, size: u16, app: &mut App) -> LayerData { 202 | let mut old_cells = LayerData::new(); 203 | 204 | let (cx, cy, radius) = (x as i16, y as i16, size as i16); 205 | 206 | for y in -radius..=radius { 207 | for x in -radius..=radius { 208 | if x * x + y * y <= radius * radius { 209 | let (fx, fy) = (cx + x, cy + y); 210 | if fx < 0 || fy < 0 { 211 | continue; 212 | } 213 | let (fx, fy) = (fx as u16, fy as u16); 214 | // old_cells.insert((fx, fy), app.draw(fx, fy)); 215 | old_cells.insert((fx, fy), app.put_cell(fx, fy)); 216 | } 217 | } 218 | } 219 | 220 | old_cells 221 | } 222 | 223 | fn box_tool(x: u16, y: u16, size: u16, app: &mut App) -> LayerData { 224 | let mut old_cells = LayerData::new(); 225 | let (left, right, bottom, top) = get_brush_rect_i16(x, y, size); 226 | for y in bottom..top { 227 | if y < 0 { 228 | continue; 229 | }; 230 | 231 | if y == bottom || y == top { 232 | for x in left..right { 233 | if x < 0 { 234 | continue; 235 | }; 236 | let (x, y) = (x as u16, y as u16); 237 | 238 | if old_cells.contains_key(&(x, y)) { 239 | continue; 240 | } 241 | // old_cells.insert((x, y), app.draw(x, y)); 242 | old_cells.insert((x, y), app.put_cell(x, y)); 243 | } 244 | } else { 245 | for x in [left, right - 1] { 246 | if x < 0 { 247 | continue; 248 | }; 249 | let (x, y) = (x as u16, y as u16); 250 | // old_cells.push(app.draw2(x, y)) 251 | if old_cells.contains_key(&(x, y)) { 252 | continue; 253 | } 254 | // old_cells.insert((x, y), app.draw(x, y)); 255 | old_cells.insert((x, y), app.put_cell(x, y)); 256 | } 257 | } 258 | } 259 | old_cells 260 | } 261 | 262 | fn square_tool(x: u16, y: u16, size: u16, app: &mut App) -> LayerData { 263 | let mut old_cells = LayerData::new(); 264 | let (left, right, bottom, top) = get_brush_rect_i16(x, y, size); 265 | // Loop through the brush coordinates and write the values 266 | for x in left..right { 267 | for y in bottom..top { 268 | if x < 0 || y < 0 { 269 | continue; 270 | } 271 | let (x, y) = (x as u16, y as u16); 272 | 273 | if old_cells.contains_key(&(x, y)) { 274 | continue; 275 | } 276 | // old_cells.insert((x, y), app.draw(x, y)); 277 | old_cells.insert((x, y), app.put_cell(x, y)); 278 | } 279 | } 280 | old_cells 281 | } 282 | 283 | fn eraser_tool(x: u16, y: u16, size: u16, app: &mut App) -> LayerData { 284 | let mut old_cells = LayerData::new(); 285 | let (left, right, bottom, top) = get_brush_rect_u16(x, y, size); 286 | 287 | for x in left..right { 288 | for y in bottom..top { 289 | if old_cells.contains_key(&(x, y)) { 290 | continue; 291 | } 292 | old_cells.insert((x, y), app.erase(x, y)); 293 | } 294 | } 295 | 296 | old_cells 297 | } 298 | 299 | fn get_brush_rect_i16(x: u16, y: u16, size: u16) -> (i16, i16, i16, i16) { 300 | // Allow negatives 301 | let (x, y, size) = (x as i16, y as i16, size as i16); 302 | // Calculate brush offset 303 | let brush_offset = (size - 1) / 2; 304 | // Left and bottom 305 | let left = x - brush_offset; 306 | let bottom = y - brush_offset; 307 | // Right and top 308 | let right = left + size; 309 | let top = bottom + size; 310 | 311 | (left, right, bottom, top) 312 | } 313 | 314 | fn get_brush_rect_u16(x: u16, y: u16, size: u16) -> (u16, u16, u16, u16) { 315 | // Calculate brush offset 316 | let brush_offset = size.saturating_sub(1) / 2; 317 | // Left and bottom 318 | let left = x.saturating_sub(brush_offset); 319 | let bottom = y.saturating_sub(brush_offset); 320 | // Right and top 321 | let right = x + brush_offset; 322 | let top = y + brush_offset; 323 | 324 | (left, right, bottom, top) 325 | } 326 | -------------------------------------------------------------------------------- /src/ui/popup_colorpicker.rs: -------------------------------------------------------------------------------- 1 | use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; 2 | use ratatui::style::{Color, Style, Stylize}; 3 | use ratatui::text::{Line, Span}; 4 | use ratatui::widgets::block::Title; 5 | use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph}; 6 | use ratatui::Frame; 7 | 8 | use crate::app::App; 9 | use crate::components::clicks::{ClickAction::PickColor, PickAction::*}; 10 | use crate::components::input::color::TextFocus; 11 | 12 | use super::sidebar::Button; 13 | use super::{centered_box, RED, WHITE}; 14 | use super::{BG, BLACK, BUTTON_COLOR, COLOR_STEPS, COLOR_STEP_AMT, DARK_TEXT, TOOL_BORDER}; 15 | 16 | pub fn show(app: &mut App, f: &mut Frame) { 17 | let area = f.area(); 18 | let box_height = 18; 19 | let box_width = 66; 20 | 21 | let block_area = centered_box(box_width, box_height, area); 22 | 23 | app.input_capture 24 | .click_mode_popup(&block_area, PickColor(Nothing)); 25 | 26 | let block = Block::new() 27 | .title(" Color Picker ") 28 | .title_alignment(Alignment::Center) 29 | .title_style(Style::new().reversed().bold()) 30 | .borders(Borders::all()) 31 | .border_type(BorderType::Rounded); 32 | 33 | let block_inner = block.inner(block_area); 34 | 35 | f.render_widget(Clear, block_area); 36 | f.render_widget(block, block_area); 37 | 38 | let horiz_split = Layout::new( 39 | Direction::Vertical, 40 | [Constraint::Min(9), Constraint::Min(7)], 41 | ) 42 | .split(block_inner); 43 | 44 | let top_half = horiz_split[0]; 45 | let bot_half = horiz_split[1]; 46 | 47 | let column_layout = Layout::new( 48 | Direction::Horizontal, 49 | [ 50 | Constraint::Min(1), 51 | Constraint::Min(9), 52 | Constraint::Min(1), 53 | Constraint::Min(COLOR_STEPS as u16), 54 | Constraint::Min(1), 55 | Constraint::Min(18), 56 | ], 57 | ); 58 | 59 | let cols = column_layout.split(top_half); 60 | 61 | let values_col = cols[1]; 62 | let sliders_col = cols[3]; 63 | let info_col = cols[5]; 64 | 65 | rgb_boxes_and_buttons(app, f, values_col); 66 | sliders(app, f, sliders_col); 67 | preview_block(app, f, info_col); 68 | control_buttons(app, f, column_layout.split(bot_half)); 69 | } 70 | 71 | fn rgb_boxes_and_buttons(app: &mut App, f: &mut Frame, area: Rect) { 72 | let rows = Layout::new(Direction::Vertical, [Constraint::Min(3); 3]).split(area); 73 | 74 | let color_values = app.input_capture.color_picker.colors(); 75 | let text_focus = app.input_capture.color_picker.focus; 76 | 77 | let base_row = Layout::new(Direction::Vertical, [Constraint::Min(1); 3]); 78 | 79 | for (&row, (value, name)) in rows.iter().zip(color_values.into_iter()) { 80 | let layout = base_row.split(row); 81 | 82 | let title = Paragraph::new(format!("{:?}", name)).alignment(Alignment::Center); 83 | 84 | f.render_widget(title, layout[0]); 85 | 86 | let text_bg = Block::new().bg(BG).fg(TOOL_BORDER); 87 | 88 | let controls_layout = 89 | Layout::new(Direction::Horizontal, [Constraint::Min(3); 3]).split(layout[1]); 90 | 91 | let minus_area = controls_layout[0]; 92 | let text_bg_area = controls_layout[1]; 93 | let plus_area = controls_layout[2]; 94 | 95 | let text_area = text_bg.inner(text_bg_area); 96 | 97 | app.input_capture 98 | .click_mode_popup(&text_bg_area, PickColor(ChangeFocus(name))); 99 | f.render_widget(text_bg, text_bg_area); 100 | 101 | let text = Paragraph::new(value.to_string()); 102 | 103 | f.render_widget(text, text_area); 104 | 105 | // If the box is focused 106 | if name == text_focus { 107 | let cursor_area = Rect { 108 | x: text_area.x + app.input_capture.color_picker.pos(), 109 | width: 1, 110 | height: 1, 111 | ..text_area 112 | }; 113 | 114 | let cursor_block = Block::new().reversed(); 115 | 116 | f.render_widget(cursor_block, cursor_area); 117 | } 118 | 119 | let minus_button = Paragraph::new(Line::from(Button::normal("-"))); 120 | let plus_button = Paragraph::new(Line::from(Button::normal("+"))); 121 | 122 | app.input_capture 123 | .click_mode_popup(&minus_area, PickColor(Minus(name))); 124 | f.render_widget(minus_button, minus_area); 125 | 126 | app.input_capture 127 | .click_mode_popup(&minus_area, PickColor(Plus(name))); 128 | f.render_widget(plus_button, plus_area); 129 | } 130 | } 131 | 132 | fn sliders(app: &mut App, f: &mut Frame, area: Rect) { 133 | let rows = Layout::new(Direction::Vertical, [Constraint::Min(3); 3]).split(area); 134 | 135 | let color_values = app.input_capture.color_picker.colors(); 136 | 137 | let base_layout = Layout::new(Direction::Vertical, [Constraint::Min(1); 3]); 138 | let column_layout = Layout::new( 139 | Direction::Horizontal, 140 | [Constraint::Length(1); COLOR_STEPS as usize], 141 | ); 142 | 143 | for (&row, (color_value, color_name)) in rows.iter().zip(color_values.into_iter()) { 144 | let base = base_layout.split(row); 145 | 146 | let upper_row = column_layout.split(base[0]); 147 | let colors_row = column_layout.split(base[1]); 148 | let lower_row = column_layout.split(base[2]); 149 | 150 | let active_column = color_value.div_ceil(COLOR_STEP_AMT) as usize; 151 | 152 | f.render_widget(Paragraph::new("┬"), upper_row[active_column]); 153 | f.render_widget(Paragraph::new("┴"), lower_row[active_column]); 154 | 155 | for i in 0..COLOR_STEPS as usize { 156 | let color_strength = COLOR_STEP_AMT.saturating_mul(i as u8); 157 | 158 | let row_color = match color_name { 159 | TextFocus::Hex => Color::White, // This won't be used 160 | TextFocus::Red => Color::Rgb(color_strength, 0, 0), 161 | TextFocus::Green => Color::Rgb(0, color_strength, 0), 162 | TextFocus::Blue => Color::Rgb(0, 0, color_strength), 163 | }; 164 | 165 | if i == active_column { 166 | f.render_widget(Paragraph::new("│").bg(row_color), colors_row[i]); 167 | continue; 168 | } 169 | 170 | app.input_capture.click_mode_popup( 171 | &colors_row[i], 172 | PickColor(Update(color_name, color_strength)), 173 | ); 174 | f.render_widget(Paragraph::new(" ").bg(row_color), colors_row[i]); 175 | } 176 | } 177 | } 178 | 179 | fn preview_block(app: &mut App, f: &mut Frame, area: Rect) { 180 | let center = Layout::new( 181 | Direction::Horizontal, 182 | [Constraint::Min(3), Constraint::Min(12), Constraint::Min(3)], 183 | ) 184 | .split(area)[1]; 185 | 186 | let layout = Layout::new( 187 | Direction::Vertical, 188 | [ 189 | Constraint::Length(1), 190 | Constraint::Length(1), 191 | Constraint::Length(6), 192 | ], 193 | ) 194 | .split(center); 195 | 196 | let current_color = app.input_capture.color_picker.get_style_color(); 197 | 198 | let preview = Block::new().bg(current_color); 199 | 200 | let x_button = Paragraph::new(Line::from(Button::custom("x", RED, WHITE))); 201 | let x_button_area = Layout::new( 202 | Direction::Horizontal, 203 | [Constraint::Length(11), Constraint::Length(3)], 204 | ) 205 | .split(layout[0])[1]; 206 | 207 | app.input_capture 208 | .click_mode_popup(&x_button_area, PickColor(Exit)); 209 | 210 | f.render_widget(x_button, x_button_area); 211 | f.render_widget(preview, layout[2]); 212 | } 213 | 214 | fn control_buttons(app: &mut App, f: &mut Frame, areas: std::rc::Rc<[Rect]>) { 215 | let left = areas[1]; 216 | let center = areas[3]; 217 | let right = areas[5]; 218 | 219 | hex_input_and_exit(app, f, left); 220 | replace_palette_color(app, f, center); 221 | set_brush_colors(app, f, right); 222 | } 223 | 224 | fn hex_input_and_exit(app: &mut App, f: &mut Frame, area: Rect) { 225 | let layout = Layout::new( 226 | Direction::Vertical, 227 | vec![Constraint::Length(1); area.height as usize], 228 | ) 229 | .split(area); 230 | 231 | let title = Paragraph::new("Hex").alignment(Alignment::Center).bold(); 232 | f.render_widget(title, layout[0]); 233 | 234 | let text_bg = Block::new().bg(BG).fg(TOOL_BORDER); 235 | 236 | let text_layout = Layout::new( 237 | Direction::Horizontal, 238 | [ 239 | Constraint::Length(1), // Space 240 | Constraint::Length(1), // # 241 | Constraint::Length(7), // Hex 242 | Constraint::Length(1), // Space 243 | ], 244 | ) 245 | .split(layout[1]); 246 | 247 | let text_bg_area = Rect { 248 | width: 8, 249 | ..text_layout[1] 250 | }; // Combine the 2 inner areas 251 | let text_area = text_layout[2]; 252 | 253 | let text = Paragraph::new(app.input_capture.color_picker.get_hex_str()); 254 | 255 | f.render_widget(Paragraph::new("▐").fg(BG), text_layout[0]); 256 | f.render_widget(Paragraph::new("#"), text_layout[1]); 257 | f.render_widget(text, text_area); 258 | // f.render_widget(Paragraph::new("▌").fg(BG), text_layout[3]); 259 | 260 | app.input_capture 261 | .click_mode_popup(&text_bg_area, PickColor(ChangeFocus(TextFocus::Hex))); 262 | f.render_widget(text_bg, text_bg_area); 263 | 264 | let text_focus = app.input_capture.color_picker.focus; 265 | // If the box is focused 266 | if text_focus == TextFocus::Hex { 267 | let cursor_area = Rect { 268 | x: text_area.x + app.input_capture.color_picker.pos(), 269 | width: 1, 270 | height: 1, 271 | ..text_area 272 | }; 273 | 274 | let cursor_block = Block::new().reversed(); 275 | 276 | f.render_widget(cursor_block, cursor_area); 277 | } 278 | 279 | let exit_button = Paragraph::new(Line::from(Button::custom("Close", RED, WHITE))) 280 | .alignment(Alignment::Center); 281 | 282 | app.input_capture 283 | .click_mode_popup(&layout[4], PickColor(Exit)); 284 | 285 | f.render_widget(exit_button, layout[4]); 286 | } 287 | 288 | fn replace_palette_color(app: &mut App, f: &mut Frame, area: Rect) { 289 | let block = Block::new() 290 | .borders(Borders::all()) 291 | .title(Title::from(" Replace palette color ".bold()).alignment(Alignment::Center)) 292 | .border_type(BorderType::Rounded) 293 | .border_style(Style::new().fg(TOOL_BORDER)); 294 | 295 | let block_center = Layout::new( 296 | Direction::Horizontal, 297 | [Constraint::Min(6), Constraint::Max(26), Constraint::Min(0)], 298 | ) 299 | .split(area)[1]; 300 | 301 | let block_inner = block.inner(block_center); 302 | f.render_widget(block, block_center); 303 | 304 | let rows = Layout::new(Direction::Vertical, [Constraint::Min(1); 3]).split(block_inner); 305 | 306 | let cols_layout = Layout::new(Direction::Horizontal, [Constraint::Min(3); 8]); 307 | 308 | let row1 = cols_layout.split(rows[0]); 309 | let row2 = cols_layout.split(rows[2]); 310 | 311 | let row_iter = row1.iter().chain(row2.iter()); 312 | 313 | app.palette 314 | .colors() 315 | .iter() 316 | .zip(row_iter) 317 | .enumerate() 318 | .for_each(|(i, (&color, &area))| { 319 | let button = Paragraph::new(Line::from(Button::blank(color))); 320 | 321 | let pick_action = ReplacePColor(app.input_capture.color_picker.get_style_color(), i); 322 | 323 | app.input_capture 324 | .click_mode_popup(&area, PickColor(pick_action)); 325 | 326 | f.render_widget(button, area); 327 | }); 328 | } 329 | 330 | fn set_brush_colors(app: &mut App, f: &mut Frame, area: Rect) { 331 | let layout = Layout::new( 332 | Direction::Vertical, 333 | vec![Constraint::Min(1); area.height as usize], 334 | ) 335 | .split(area); 336 | 337 | let fg_button = Paragraph::new(Line::from(vec![ 338 | Span::raw("▐").fg(BUTTON_COLOR), 339 | Span::raw("■").bg(BUTTON_COLOR).fg(BLACK), 340 | Span::raw(" Set to FG").bg(BUTTON_COLOR).fg(DARK_TEXT), 341 | Span::raw("▌").fg(BUTTON_COLOR), 342 | ])) 343 | .alignment(Alignment::Center); 344 | 345 | let bg_button = Paragraph::new(Line::from(vec![ 346 | Span::raw("▐").fg(BUTTON_COLOR), 347 | Span::raw("■").bg(BUTTON_COLOR).fg(WHITE), 348 | Span::raw(" Set to BG").bg(BUTTON_COLOR).fg(DARK_TEXT), 349 | Span::raw("▌").fg(BUTTON_COLOR), 350 | ])) 351 | .alignment(Alignment::Center); 352 | 353 | app.input_capture 354 | .click_mode_popup(&layout[1], PickColor(AcceptFG)); 355 | app.input_capture 356 | .click_mode_popup(&layout[3], PickColor(AcceptBG)); 357 | 358 | f.render_widget(fg_button, layout[1]); 359 | f.render_widget(bg_button, layout[3]); 360 | } 361 | -------------------------------------------------------------------------------- /src/handler.rs: -------------------------------------------------------------------------------- 1 | use crate::app::{App, AppResult}; 2 | use crate::components::cell::Cell; 3 | use crate::components::clicks::*; 4 | use crate::components::input::{InputMode, MouseMode}; 5 | use crate::components::layers::LayerData; 6 | use crate::components::save_load::{FileSaveError, SaveData}; 7 | use crate::ui::TOOLBOX_WIDTH; 8 | 9 | use anstyle::{Ansi256Color, AnsiColor, RgbColor}; 10 | use crossterm::event::MouseEventKind::{Down, Drag, Up}; 11 | use crossterm::event::{self, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent}; 12 | use ratatui::style::Color; 13 | 14 | use std::borrow::Borrow; 15 | use std::time::{Duration, Instant}; 16 | use std::{fs::File, io::Write, sync::mpsc, thread}; 17 | 18 | /// Terminal events. 19 | #[derive(Clone, Debug)] 20 | pub enum Event { 21 | /// Terminal tick. 22 | Tick, 23 | /// Key press. 24 | Key(KeyEvent), 25 | /// Mouse click/scroll. 26 | Mouse(MouseEvent), 27 | /// Terminal resize. 28 | Resize(u16, u16), 29 | /// Paste signal 30 | Paste(String), 31 | } 32 | 33 | /// Terminal event handler. 34 | #[allow(dead_code)] 35 | #[derive(Debug)] 36 | pub struct EventHandler { 37 | /// Event sender channel. 38 | sender: mpsc::Sender, 39 | /// Event receiver channel. 40 | receiver: mpsc::Receiver, 41 | /// Event handler thread. 42 | handler: thread::JoinHandle<()>, 43 | } 44 | 45 | impl EventHandler { 46 | /// Constructs a new instance of [`EventHandler`]. 47 | pub fn new(tick_rate: u64) -> Self { 48 | let tick_rate = Duration::from_millis(tick_rate); 49 | let (sender, receiver) = mpsc::channel(); 50 | let handler = { 51 | let sender = sender.clone(); 52 | thread::spawn(move || { 53 | let mut last_tick = Instant::now(); 54 | loop { 55 | let timeout = tick_rate 56 | .checked_sub(last_tick.elapsed()) 57 | .unwrap_or(tick_rate); 58 | 59 | #[allow(clippy::expect_used)] 60 | if event::poll(timeout).expect("failed to poll new events") { 61 | match event::read().expect("unable to read event") { 62 | event::Event::Key(e) => sender.send(Event::Key(e)), 63 | event::Event::Mouse(e) => sender.send(Event::Mouse(e)), 64 | event::Event::Resize(w, h) => sender.send(Event::Resize(w, h)), 65 | event::Event::FocusGained => Ok(()), 66 | event::Event::FocusLost => Ok(()), 67 | event::Event::Paste(s) => sender.send(Event::Paste(s)), 68 | } 69 | .expect("failed to send terminal event") 70 | } 71 | 72 | if last_tick.elapsed() >= tick_rate { 73 | #[allow(clippy::expect_used)] 74 | sender.send(Event::Tick).expect("failed to send tick event"); 75 | last_tick = Instant::now(); 76 | } 77 | } 78 | }) 79 | }; 80 | Self { 81 | sender, 82 | receiver, 83 | handler, 84 | } 85 | } 86 | 87 | /// Receive the next event from the handler thread. 88 | /// 89 | /// This function will always block the current thread if 90 | /// there is no data available and it's possible for more data to be sent. 91 | pub fn next(&self) -> AppResult { 92 | Ok(self.receiver.recv()?) 93 | } 94 | } 95 | 96 | /// Handles the key events and updates the state of [`App`]. 97 | pub fn handle_key_events(key_event: KeyEvent, app: &mut App) -> AppResult<()> { 98 | match app.input_capture.mode { 99 | InputMode::Normal => normal_mode_keymaps(key_event, app)?, 100 | InputMode::Rename => rename_mode_keymaps(key_event, app), 101 | InputMode::Color => color_mode_keymaps(key_event, app), 102 | InputMode::Export => export_mode_keymaps(key_event, app), 103 | InputMode::Save => save_mode_keymaps(key_event, app), 104 | InputMode::Help => match key_event.code { 105 | KeyCode::Char('c') => { 106 | if key_event.modifiers == KeyModifiers::CONTROL { 107 | app.input_capture.change_mode(InputMode::Exit) 108 | } 109 | } 110 | KeyCode::Esc | KeyCode::Char('?') => app.input_capture.toggle_help(), 111 | KeyCode::Char('Q') => app.input_capture.change_mode(InputMode::Exit), 112 | _ => {} 113 | }, 114 | InputMode::Exit => match key_event.code { 115 | KeyCode::Char('c') => { 116 | if key_event.modifiers == KeyModifiers::CONTROL { 117 | app.quit(); 118 | } 119 | } 120 | KeyCode::Char('Q' | 'y' | 'Y') => app.quit(), 121 | KeyCode::Esc | KeyCode::Char('n' | 'N') => app.input_capture.exit(), 122 | _ => {} 123 | }, 124 | InputMode::TooSmall => match key_event.code { 125 | KeyCode::Char('c') => { 126 | if key_event.modifiers == KeyModifiers::CONTROL { 127 | app.quit(); 128 | } 129 | } 130 | KeyCode::Esc | KeyCode::Char('Q') => app.quit(), 131 | _ => {} 132 | }, 133 | 134 | #[cfg(debug_assertions)] 135 | InputMode::Debug => match key_event.code { 136 | KeyCode::Char('d' | 'D') => app.input_capture.toggle_debug(), 137 | KeyCode::Esc | KeyCode::Char('Q') => app.quit(), 138 | _ => {} 139 | }, 140 | } 141 | Ok(()) 142 | } 143 | 144 | pub fn handle_mouse_events(event: MouseEvent, app: &mut App) -> AppResult<()> { 145 | let (x, y) = (event.column, event.row); 146 | 147 | match app.input_capture.mode { 148 | InputMode::Color => color_mode_mouse(event, app, x, y), 149 | InputMode::Normal => normal_mouse_mode(event, app, x, y)?, 150 | InputMode::Rename => { 151 | if event.kind == Down(MouseButton::Left) { 152 | if let Some(ClickAction::Rename(action)) = app.input_capture.get(x, y) { 153 | match action { 154 | PopupBoxAction::Accept => { 155 | if app.apply_rename().is_some() { 156 | app.input_capture.exit(); 157 | } else { 158 | app.input_capture.text_area.error = Some(FileSaveError::NoName); 159 | } 160 | } 161 | PopupBoxAction::Deny => app.input_capture.exit(), 162 | PopupBoxAction::Nothing => {} 163 | } 164 | } 165 | }; 166 | } 167 | InputMode::Help => { 168 | app.input_capture.toggle_help(); 169 | normal_mouse_mode(event, app, x, y)? 170 | } 171 | InputMode::Export => { 172 | if event.kind == Down(MouseButton::Left) { 173 | if let Some(ClickAction::Export(action)) = app.input_capture.get(x, y) { 174 | match action { 175 | PopupBoxAction::Accept => { 176 | if let Err(file_error) = export_file(app) { 177 | app.input_capture.text_area.error = Some(file_error); 178 | return Ok(()); 179 | } 180 | app.input_capture.text_area.error = None; 181 | app.input_capture.exit(); 182 | } 183 | PopupBoxAction::Deny => app.input_capture.exit(), 184 | PopupBoxAction::Nothing => {} 185 | } 186 | } 187 | }; 188 | } 189 | InputMode::Save => { 190 | if event.kind == Down(MouseButton::Left) { 191 | if let Some(ClickAction::Save(action)) = app.input_capture.get(x, y) { 192 | match action { 193 | PopupBoxAction::Accept => { 194 | if let Err(file_error) = save_file(app) { 195 | app.input_capture.text_area.error = Some(file_error); 196 | return Ok(()); 197 | } 198 | app.input_capture.text_area.error = None; 199 | app.input_capture.exit(); 200 | } 201 | PopupBoxAction::Deny => app.input_capture.exit(), 202 | PopupBoxAction::Nothing => {} 203 | } 204 | } 205 | }; 206 | } 207 | InputMode::Exit => { 208 | if event.kind == Down(MouseButton::Left) { 209 | if let Some(ClickAction::Exit(action)) = app.input_capture.get(x, y) { 210 | match action { 211 | PopupBoxAction::Accept => app.quit(), 212 | PopupBoxAction::Deny => app.input_capture.exit(), 213 | PopupBoxAction::Nothing => {} 214 | } 215 | } 216 | }; 217 | } 218 | InputMode::TooSmall => {} 219 | 220 | #[cfg(debug_assertions)] 221 | InputMode::Debug => {} 222 | } 223 | Ok(()) 224 | } 225 | 226 | fn save_mode_keymaps(key_event: KeyEvent, app: &mut App) { 227 | match key_event.code { 228 | KeyCode::Char('c') => { 229 | if key_event.modifiers == KeyModifiers::CONTROL { 230 | app.input_capture.change_mode(InputMode::Exit) 231 | } else { 232 | app.input_capture.text_area.input('c', 20); 233 | } 234 | } 235 | KeyCode::Char(ch) => app.input_capture.text_area.input(ch, 20), 236 | KeyCode::Esc => app.input_capture.exit(), 237 | KeyCode::Backspace => app.input_capture.text_area.backspace(), 238 | KeyCode::Delete => app.input_capture.text_area.delete(), 239 | KeyCode::Left => app.input_capture.text_area.left(), 240 | KeyCode::Right => app.input_capture.text_area.right(), 241 | KeyCode::Home => app.input_capture.text_area.home(), 242 | KeyCode::End => app.input_capture.text_area.end(), 243 | KeyCode::Enter => { 244 | if let Err(file_error) = save_file(app) { 245 | app.input_capture.text_area.error = Some(file_error); 246 | return; 247 | } 248 | app.input_capture.text_area.error = None; 249 | app.input_capture.exit(); 250 | } 251 | _ => {} 252 | } 253 | } 254 | 255 | fn export_mode_keymaps(key_event: KeyEvent, app: &mut App) { 256 | match key_event.code { 257 | KeyCode::Char('c') => { 258 | if key_event.modifiers == KeyModifiers::CONTROL { 259 | app.input_capture.change_mode(InputMode::Exit) 260 | } else { 261 | app.input_capture.text_area.input('c', 20); 262 | } 263 | } 264 | KeyCode::Char(ch) => app.input_capture.text_area.input(ch, 20), 265 | KeyCode::Esc => app.input_capture.exit(), 266 | KeyCode::Backspace => app.input_capture.text_area.backspace(), 267 | KeyCode::Delete => app.input_capture.text_area.delete(), 268 | KeyCode::Left => app.input_capture.text_area.left(), 269 | KeyCode::Right => app.input_capture.text_area.right(), 270 | KeyCode::Home => app.input_capture.text_area.home(), 271 | KeyCode::End => app.input_capture.text_area.end(), 272 | KeyCode::Enter => { 273 | if let Err(file_error) = export_file(app) { 274 | app.input_capture.text_area.error = Some(file_error); 275 | return; 276 | } 277 | app.input_capture.text_area.error = None; 278 | app.input_capture.exit(); 279 | } 280 | _ => {} 281 | } 282 | } 283 | 284 | fn color_mode_keymaps(key_event: KeyEvent, app: &mut App) { 285 | match key_event.code { 286 | KeyCode::Char('c') => { 287 | if key_event.modifiers == KeyModifiers::CONTROL { 288 | app.input_capture.change_mode(InputMode::Exit) 289 | } else { 290 | app.input_capture.color_picker.input('c'); 291 | } 292 | } 293 | KeyCode::Char('Q') => app.input_capture.change_mode(InputMode::Exit), 294 | KeyCode::Char(ch) => app.input_capture.color_picker.input(ch), 295 | KeyCode::Esc => app.input_capture.exit(), 296 | KeyCode::Tab | KeyCode::Down => app.input_capture.color_picker.tab(), 297 | KeyCode::BackTab | KeyCode::Up => app.input_capture.color_picker.backtab(), 298 | KeyCode::Backspace => app.input_capture.color_picker.text.backspace(), 299 | KeyCode::Delete => app.input_capture.color_picker.text.delete(), 300 | KeyCode::Left => app.input_capture.color_picker.text.left(), 301 | KeyCode::Right => app.input_capture.color_picker.text.right(), 302 | KeyCode::Home => app.input_capture.color_picker.text.home(), 303 | KeyCode::End => app.input_capture.color_picker.text.end(), 304 | KeyCode::Enter => app.input_capture.color_picker.update(), 305 | _ => {} 306 | } 307 | } 308 | 309 | fn rename_mode_keymaps(key_event: KeyEvent, app: &mut App) { 310 | match key_event.code { 311 | KeyCode::Char('c') => { 312 | if key_event.modifiers == KeyModifiers::CONTROL { 313 | // Exit application on `Ctrl-C` 314 | app.input_capture.change_mode(InputMode::Exit) 315 | } else { 316 | app.input_capture.text_area.input('c', 20); 317 | } 318 | } 319 | // Input text 320 | KeyCode::Char(ch) => app.input_capture.text_area.input(ch, 20), 321 | // Keymaps 322 | KeyCode::Esc => app.input_capture.exit(), 323 | KeyCode::Backspace => app.input_capture.text_area.backspace(), 324 | KeyCode::Delete => app.input_capture.text_area.delete(), 325 | KeyCode::Left => app.input_capture.text_area.left(), 326 | KeyCode::Right => app.input_capture.text_area.right(), 327 | KeyCode::Home => app.input_capture.text_area.home(), 328 | KeyCode::End => app.input_capture.text_area.end(), 329 | KeyCode::Enter => { 330 | if app.apply_rename().is_some() { 331 | app.input_capture.exit(); 332 | } else { 333 | app.input_capture.text_area.error = Some(FileSaveError::NoName); 334 | } 335 | } 336 | _ => {} 337 | } 338 | } 339 | 340 | fn normal_mode_keymaps(key_event: KeyEvent, app: &mut App) -> AppResult<()> { 341 | match key_event.code { 342 | // Exit application on `ESC` or `Q` 343 | KeyCode::Esc | KeyCode::Char('Q') => app.input_capture.change_mode(InputMode::Exit), 344 | // Exit application on `Ctrl-C` 345 | KeyCode::Char('c' | 'C') => { 346 | if key_event.modifiers == KeyModifiers::CONTROL { 347 | app.input_capture.change_mode(InputMode::Exit) 348 | } 349 | } 350 | // Reset 351 | KeyCode::Char('R') => app.reset(), 352 | KeyCode::Char('s') => { 353 | if key_event.modifiers == KeyModifiers::CONTROL { 354 | app.input_capture.change_mode(InputMode::Save); 355 | if let Some(last_file_name) = app.input_capture.last_file_name.borrow() { 356 | app.input_capture.text_area.pos = last_file_name.len(); 357 | app.input_capture.text_area.buffer = last_file_name.into(); 358 | } 359 | } else { 360 | // Brush size 361 | app.brush.up(1) 362 | } 363 | } 364 | KeyCode::Char('e') => { 365 | if key_event.modifiers == KeyModifiers::CONTROL { 366 | app.input_capture.change_mode(InputMode::Export); 367 | if let Some(last_file_name) = app.input_capture.last_file_name.borrow() { 368 | app.input_capture.text_area.pos = last_file_name.len(); 369 | app.input_capture.text_area.buffer = last_file_name.into(); 370 | } 371 | } 372 | } 373 | // Cycle foreground color through palette 374 | KeyCode::Char('f') => app.brush.fg = app.palette.fg_next(), 375 | KeyCode::Char('F') => app.brush.fg = app.palette.fg_prev(), 376 | // Cycle background color through palette 377 | KeyCode::Char('b') => app.brush.bg = app.palette.bg_next(), 378 | KeyCode::Char('B') => app.brush.bg = app.palette.bg_prev(), 379 | // Copy canvas contents to clipboard 380 | KeyCode::Char('Y') => copy_canvas_text(app)?, 381 | KeyCode::Char('y') => copy_canvas_ansi(app)?, 382 | // Use clipboard to set brush char 383 | KeyCode::Char('p') => { 384 | if let Ok(s) = cli_clipboard::get_contents() { 385 | if let Some(c) = s.chars().next() { 386 | app.brush.char = c; 387 | } 388 | } 389 | } 390 | // Help window 391 | KeyCode::Char('?') => app.input_capture.toggle_help(), 392 | // Undo / Redo 393 | KeyCode::Char('u') => app.undo(), 394 | KeyCode::Char('U') => app.redo(), 395 | #[cfg(debug_assertions)] 396 | KeyCode::Char('d' | 'D') => app.input_capture.toggle_debug(), 397 | _ => {} 398 | } 399 | Ok(()) 400 | } 401 | 402 | fn color_mode_mouse(event: MouseEvent, app: &mut App, x: u16, y: u16) { 403 | if event.kind == Down(MouseButton::Left) || event.kind == Drag(MouseButton::Left) { 404 | if let Some(&ClickAction::PickColor(action)) = app.input_capture.get(x, y) { 405 | match action { 406 | PickAction::AcceptFG => { 407 | app.brush.fg = app.input_capture.color_picker.get_style_color() 408 | } 409 | PickAction::AcceptBG => { 410 | app.brush.bg = app.input_capture.color_picker.get_style_color() 411 | } 412 | PickAction::ReplacePColor(c, i) => app.palette.replace(i, c), 413 | PickAction::ChangeFocus(new_focus) => { 414 | app.input_capture.color_picker.set_attention(new_focus) 415 | } 416 | PickAction::Update(color, value) => { 417 | app.input_capture.color_picker.set(color, value) 418 | } 419 | PickAction::Plus(c) => app.input_capture.color_picker.plus(c), 420 | PickAction::Minus(c) => app.input_capture.color_picker.minus(c), 421 | PickAction::New => app.input_capture.color_picker.reset(), 422 | PickAction::Exit => app.input_capture.exit(), 423 | PickAction::Nothing => {} 424 | } 425 | } else { 426 | app.input_capture.exit(); 427 | } 428 | } 429 | } 430 | 431 | fn normal_mouse_mode(event: MouseEvent, app: &mut App, x: u16, y: u16) -> AppResult<()> { 432 | match event.kind { 433 | Down(btn) => { 434 | if let Some(&action) = app.input_capture.get(x, y) { 435 | let count = match event.modifiers { 436 | KeyModifiers::CONTROL => 5, 437 | KeyModifiers::ALT => 2, 438 | _ => 1, 439 | }; 440 | 441 | match action { 442 | ClickAction::Draw => { 443 | if btn == MouseButton::Middle { 444 | let (old_cells, id) = paste_into_canvas(app, x - TOOLBOX_WIDTH, y)?; 445 | app.history.draw(id, old_cells); 446 | return Ok(()); 447 | } 448 | 449 | app.input_capture.mouse_mode = MouseMode::Click; 450 | 451 | // let drawn_cells = draw_wrapper(x, y, app); 452 | let drawn_cells = app.draw(x, y); 453 | let layer_id = app.layers.get_active_layer().id; 454 | 455 | app.history.draw(layer_id, drawn_cells); 456 | } 457 | ClickAction::Next(i) => match i { 458 | Increment::CharPicker => app.char_picker.next(), 459 | Increment::BrushSize => app.brush.up(count), 460 | }, 461 | ClickAction::Prev(i) => match i { 462 | Increment::CharPicker => app.char_picker.prev(), 463 | Increment::BrushSize => app.brush.down(count), 464 | }, 465 | ClickAction::Set(v) => match v { 466 | SetValue::Tool(t) => app.brush.tool = t, 467 | SetValue::Char(c) => app.brush.char = c, 468 | SetValue::Reset(rv) => match rv { 469 | ResetValue::FG => app.brush.fg = Color::Reset, 470 | ResetValue::BG => app.brush.bg = Color::Reset, 471 | }, 472 | SetValue::Color(color) => match btn { 473 | MouseButton::Left => app.brush.fg = color, 474 | MouseButton::Right => app.brush.bg = color, 475 | MouseButton::Middle => match color { 476 | c if c == app.brush.fg => app.brush.fg = Color::Reset, 477 | c if c == app.brush.bg => app.brush.bg = Color::Reset, 478 | _ => {} 479 | }, 480 | }, 481 | }, 482 | ClickAction::Layer(action) => match action { 483 | LayerAction::Add => { 484 | let new_layer_id = app.layers.add_layer(); 485 | app.history.add_layer(new_layer_id); 486 | } 487 | LayerAction::Select(index) => app.layers.set_active_layer(index), 488 | LayerAction::Remove => app.remove_active_layer(), 489 | LayerAction::Rename => app.input_capture.change_mode(InputMode::Rename), 490 | LayerAction::MoveUp => { 491 | let layer_id = app.layers.get_active_layer().id; 492 | let move_was_sucessful = app.layers.move_layer_up_by_id(layer_id); 493 | if move_was_sucessful { 494 | app.history.layer_up(layer_id); 495 | } 496 | } 497 | LayerAction::MoveDown => { 498 | let layer_id = app.layers.get_active_layer().id; 499 | let move_was_sucessful = app.layers.move_layer_down_by_id(layer_id); 500 | if move_was_sucessful { 501 | app.history.layer_down(layer_id); 502 | } 503 | } 504 | LayerAction::ToggleVis(index) => app.layers.toggle_visible(index), 505 | }, 506 | ClickAction::PickColor(PickAction::New) => { 507 | app.input_capture.change_mode(InputMode::Color) 508 | } 509 | _ => {} 510 | } 511 | } 512 | } 513 | 514 | Drag(MouseButton::Left | MouseButton::Right) => { 515 | if let Some(&action) = app.input_capture.get(x, y) { 516 | if action != ClickAction::Draw { 517 | // INFO: If the action isnt a draw action 518 | // INFO: return early because we only want draw to respond to drag events 519 | return Ok(()); 520 | } 521 | 522 | if app.input_capture.mouse_mode == MouseMode::Click { 523 | app.history.click_to_partial_draw(); 524 | } 525 | 526 | app.input_capture.mouse_mode = MouseMode::Drag; 527 | 528 | let old_data = app.draw(x, y); 529 | 530 | app.history.add_partial_draw(old_data); 531 | } 532 | } 533 | Up(MouseButton::Left | MouseButton::Right) => { 534 | if event.modifiers != KeyModifiers::CONTROL { 535 | app.layers.last_pos = None; 536 | } 537 | 538 | if app.input_capture.mouse_mode == MouseMode::Drag { 539 | let layer_id = app.layers.get_active_layer().id; 540 | app.history.finish_partial_draw(layer_id); 541 | } 542 | 543 | app.input_capture.mouse_mode = MouseMode::Normal; 544 | } 545 | 546 | _ => {} 547 | } 548 | Ok(()) 549 | } 550 | 551 | fn convert_color(c: Color) -> Option { 552 | Some(match c { 553 | Color::Black => AnsiColor::Black.into(), 554 | Color::Red => AnsiColor::Red.into(), 555 | Color::Green => AnsiColor::Green.into(), 556 | Color::Yellow => AnsiColor::Yellow.into(), 557 | Color::Blue => AnsiColor::Blue.into(), 558 | Color::Magenta => AnsiColor::Magenta.into(), 559 | Color::Cyan => AnsiColor::Cyan.into(), 560 | Color::Gray => AnsiColor::White.into(), 561 | Color::DarkGray => AnsiColor::BrightBlack.into(), 562 | Color::LightRed => AnsiColor::BrightRed.into(), 563 | Color::LightGreen => AnsiColor::BrightGreen.into(), 564 | Color::LightYellow => AnsiColor::BrightYellow.into(), 565 | Color::LightBlue => AnsiColor::BrightBlue.into(), 566 | Color::LightMagenta => AnsiColor::BrightMagenta.into(), 567 | Color::LightCyan => AnsiColor::BrightCyan.into(), 568 | Color::White => AnsiColor::White.into(), 569 | Color::Rgb(r, g, b) => RgbColor(r, g, b).into(), 570 | Color::Indexed(i) => Ansi256Color(i).into(), 571 | // Anstyle has no reset code, instead we return None instead of Some(Color) 572 | Color::Reset => return None, 573 | }) 574 | } 575 | 576 | fn get_drawing_region(app: &mut App) -> Option<(u16, u16, u16, u16, LayerData)> { 577 | let (mut left, mut bottom) = (u16::MAX, u16::MAX); 578 | let (mut right, mut top) = (u16::MIN, u16::MIN); 579 | let page = app.layers.render(); 580 | for &(x, y) in page.keys() { 581 | left = left.min(x); 582 | right = right.max(x); 583 | bottom = bottom.min(y); 584 | top = top.max(y); 585 | } 586 | if left == u16::MAX || bottom == u16::MAX { 587 | return None; 588 | } 589 | Some((left, right, bottom, top, page)) 590 | } 591 | 592 | fn get_canvas_ansi(app: &mut App) -> Option { 593 | let (left, right, bottom, top, page) = get_drawing_region(app)?; 594 | let mut lines_vec = Vec::with_capacity((top - bottom) as usize); 595 | 596 | for y in bottom..=top { 597 | let mut line = String::new(); 598 | let mut current_fg = None; 599 | let mut current_bg = None; 600 | 601 | for x in left..=right { 602 | if let Some(target_cell) = page.get(&(x, y)) { 603 | let fg_color = target_cell.fg; 604 | let bg_color = target_cell.bg; 605 | let char = target_cell.char(); 606 | 607 | let mut style = anstyle::Style::new(); 608 | 609 | if Some(fg_color) != current_fg { 610 | style = style.fg_color(convert_color(fg_color)); 611 | current_fg = Some(fg_color); 612 | } 613 | 614 | if Some(bg_color) != current_bg { 615 | style = style.bg_color(convert_color(bg_color)); 616 | current_bg = Some(bg_color); 617 | } 618 | 619 | line.push_str(&format!("{style}{char}")); 620 | 621 | if page.get(&(x + 1, y)).map(|c| (c.fg, c.bg)) != Some((fg_color, bg_color)) { 622 | style = anstyle::Style::new() 623 | .bg_color(convert_color(bg_color)) 624 | .fg_color(convert_color(fg_color)); 625 | line.push_str(&format!("{style:#}")); 626 | } 627 | } else { 628 | line.push(' '); 629 | current_fg = None; 630 | current_bg = None; 631 | } 632 | } 633 | lines_vec.push(line); 634 | } 635 | 636 | Some(lines_vec.join("\n")) 637 | } 638 | 639 | fn copy_canvas_ansi(app: &mut App) -> AppResult<()> { 640 | let Some(output_str) = get_canvas_ansi(app) else { 641 | return Ok(()); 642 | }; 643 | 644 | if !output_str.is_empty() { 645 | cli_clipboard::set_contents(output_str)?; 646 | } 647 | 648 | Ok(()) 649 | } 650 | 651 | fn get_canvas_text(app: &mut App) -> Option { 652 | let (left, right, bottom, top, page) = get_drawing_region(app)?; 653 | let mut lines_vec = Vec::with_capacity((top - bottom) as usize); 654 | 655 | for y in bottom..=top { 656 | let mut line = String::with_capacity((right - left) as usize); 657 | for x in left..=right { 658 | if let Some(cell) = page.get(&(x, y)) { 659 | line += &cell.char(); 660 | } else { 661 | line += " "; 662 | } 663 | } 664 | lines_vec.push(line); 665 | } 666 | 667 | Some(lines_vec.join("\n")) 668 | } 669 | 670 | fn copy_canvas_text(app: &mut App) -> AppResult<()> { 671 | let Some(output_str) = get_canvas_text(app) else { 672 | return Ok(()); 673 | }; 674 | 675 | if !output_str.is_empty() { 676 | cli_clipboard::set_contents(output_str)?; 677 | } 678 | 679 | Ok(()) 680 | } 681 | 682 | fn paste_into_canvas(app: &mut App, x: u16, y: u16) -> AppResult<(LayerData, u32)> { 683 | let clipboard = cli_clipboard::get_contents()?; 684 | let mut old_cells = LayerData::new(); 685 | for (dy, row) in clipboard.split('\n').enumerate() { 686 | for (dx, char) in row.chars().enumerate() { 687 | let (fx, fy) = (x + dx as u16, y + dy as u16); 688 | let old_cell = app.insert_at_cell( 689 | fx, 690 | fy, 691 | Cell { 692 | char, 693 | ..Default::default() 694 | }, 695 | ); 696 | old_cells.insert((fx, fy), old_cell); 697 | } 698 | } 699 | 700 | let active_id = app.layers.layers[app.layers.active].id; 701 | Ok((old_cells, active_id)) 702 | } 703 | 704 | fn save_file(app: &mut App) -> core::result::Result<(), FileSaveError> { 705 | let file_name = app 706 | .input_capture 707 | .text_area 708 | .get() 709 | .ok_or(FileSaveError::NoName)?; 710 | 711 | let canvas_ansi = get_canvas_ansi(app).ok_or(FileSaveError::NoCanvas)?; 712 | 713 | let mut file = if app.input_capture.text_area.error == Some(FileSaveError::NameConflict) { 714 | File::create(&file_name).map_err(|_| FileSaveError::CantCreate) 715 | } else { 716 | File::create_new(&file_name).map_err(|_| FileSaveError::NameConflict) 717 | }?; 718 | 719 | writeln!(file, "{}", canvas_ansi).map_err(|_| FileSaveError::Other)?; 720 | 721 | app.input_capture.last_file_name = Some(file_name); 722 | 723 | Ok(()) 724 | } 725 | 726 | fn export_file(app: &mut App) -> core::result::Result<(), FileSaveError> { 727 | let base_name = app 728 | .input_capture 729 | .text_area 730 | .get() 731 | .ok_or(FileSaveError::NoName)?; 732 | 733 | let file_name = format!("{}.tart", base_name); 734 | 735 | let mut file = if app.input_capture.text_area.error == Some(FileSaveError::NameConflict) { 736 | File::create(&file_name).map_err(|_| FileSaveError::CantCreate) 737 | } else { 738 | File::create_new(&file_name).map_err(|_| FileSaveError::NameConflict) 739 | }?; 740 | 741 | let save_data = SaveData { 742 | brush: app.brush, 743 | palette: app.palette.clone(), 744 | layers: app.layers.layers.clone(), 745 | }; 746 | 747 | ciborium::into_writer(&save_data, &mut file).map_err(|_| FileSaveError::Other)?; 748 | 749 | app.input_capture.last_file_name = Some(base_name); 750 | 751 | Ok(()) 752 | } 753 | --------------------------------------------------------------------------------