diff --git a/README.md b/README.md index 116c190..dffd121 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,6 @@ apk add satty You can download a prebuilt binary for x86-64 on the [Satty Releases](https://github.com/gabm/satty/releases) page. - ## Usage Start by providing a filename or a screenshot via stdin and annotate using the available tools. Save to clipboard or file when finished. Tools and Interface have been kept simple. @@ -78,6 +77,9 @@ output-filename = "/tmp/test-%Y-%m-%d_%H:%M:%S.png" save-after-copy = false # Hide toolbars by default default-hide-toolbars = false +# The primary highlighter to use, the other is accessible by holding CTRL at the start of a highlight [possible values: block, freehand] +primary-highlighter = "block" + # Font to use for text annotations [font] family = "Roboto" @@ -122,6 +124,8 @@ Options: After copying the screenshot, save it to a file as well -d, --default-hide-toolbars Hide toolbars by default + --primary-highlighter + The primary highlighter to use, secondary is accessible with CTRL [possible values: block, freehand] --font-family Font family to use for text annotations --font-style @@ -140,7 +144,6 @@ You can bind a key to the following command: grim -g "$(slurp -o -r -c '#ff0000ff')" - | satty --filename - --fullscreen --output-filename ~/Pictures/Screenshots/satty-$(date '+%Y%m%d-%H:%M:%S').png ``` - ## Build from source You first need to install the native dependencies of Satty (see below) and then run: diff --git a/config.toml b/config.toml index 67515d9..d72ad50 100644 --- a/config.toml +++ b/config.toml @@ -15,6 +15,9 @@ output-filename = "/tmp/test-%Y-%m-%d_%H:%M:%S.png" save-after-copy = false # Hide toolbars by default default-hide-toolbars = false +# The primary highlighter to use, the other is accessible by holding CTRL at the start of a highlight [possible values: block, freehand] +primary-highlighter = "block" + # Font to use for text annotations [font] family = "Roboto" diff --git a/src/command_line.rs b/src/command_line.rs index 0aa9008..3c0e9d6 100644 --- a/src/command_line.rs +++ b/src/command_line.rs @@ -51,6 +51,10 @@ pub struct CommandLine { /// Font style to use for text annotations #[arg(long)] pub font_style: Option, + + /// The primary highlighter to use, secondary is accessible with CTRL. + #[arg(long)] + pub primary_highlighter: Option, } #[derive(Debug, Clone, Copy, Default, ValueEnum)] @@ -68,6 +72,13 @@ pub enum Tools { Brush, } +#[derive(Debug, Clone, Copy, Default, ValueEnum)] +pub enum Highlighters { + #[default] + Block, + Freehand, +} + impl std::fmt::Display for Tools { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { use Tools::*; diff --git a/src/configuration.rs b/src/configuration.rs index acbd546..ddd87b5 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -11,7 +11,11 @@ use serde_derive::Deserialize; use thiserror::Error; use xdg::{BaseDirectories, BaseDirectoriesError}; -use crate::{command_line::CommandLine, style::Color, tools::Tools}; +use crate::{ + command_line::CommandLine, + style::Color, + tools::{Highlighters, Tools}, +}; pub static APP_CONFIG: SharedState = SharedState::new(); @@ -39,6 +43,7 @@ pub struct Configuration { color_palette: ColorPalette, default_hide_toolbars: bool, font: FontConfiguration, + primary_highlighter: Highlighters, } #[derive(Default)] @@ -170,6 +175,9 @@ impl Configuration { if let Some(v) = general.default_hide_toolbars { self.default_hide_toolbars = v; } + if let Some(v) = general.primary_highlighter { + self.primary_highlighter = v; + } } fn merge(&mut self, file: Option, command_line: CommandLine) { // input_filename is required and needs to be overwritten @@ -219,6 +227,10 @@ impl Configuration { if let Some(v) = command_line.font_style { self.font.style = Some(v); } + + if let Some(v) = command_line.primary_highlighter { + self.primary_highlighter = v.into(); + } } pub fn early_exit(&self) -> bool { @@ -261,6 +273,9 @@ impl Configuration { self.default_hide_toolbars } + pub fn primary_highlighter(&self) -> Highlighters { + self.primary_highlighter + } pub fn font(&self) -> &FontConfiguration { &self.font } @@ -280,6 +295,7 @@ impl Default for Configuration { color_palette: ColorPalette::default(), default_hide_toolbars: false, font: FontConfiguration::default(), + primary_highlighter: Highlighters::Block, } } } @@ -323,6 +339,7 @@ struct ConfiguationFileGeneral { output_filename: Option, save_after_copy: Option, default_hide_toolbars: Option, + primary_highlighter: Option, } #[derive(Deserialize)] diff --git a/src/main.rs b/src/main.rs index 9132343..32ad989 100644 --- a/src/main.rs +++ b/src/main.rs @@ -169,6 +169,17 @@ impl Component for App { glib::Propagation::Stop }, + connect_key_released[sketch_board_sender] => move |controller, key, code, modifier | { + if let Some(im_context) = controller.im_context() { + im_context.focus_in(); + if !im_context.filter_keypress(controller.current_event().unwrap()) { + sketch_board_sender.emit(SketchBoardInput::new_key_release_event(KeyEventMsg::new(key, code, modifier))); + } + } else { + sketch_board_sender.emit(SketchBoardInput::new_key_release_event(KeyEventMsg::new(key, code, modifier))); + } + }, + #[wrap(Some)] set_im_context = >k::IMMulticontext { connect_commit[sketch_board_sender] => move |_cx, txt| { diff --git a/src/sketch_board.rs b/src/sketch_board.rs index dbddc70..437d224 100644 --- a/src/sketch_board.rs +++ b/src/sketch_board.rs @@ -47,6 +47,7 @@ pub enum SketchBoardOutput { pub enum InputEvent { Mouse(MouseEventMsg), Key(KeyEventMsg), + KeyRelease(KeyEventMsg), Text(TextEventMsg), } @@ -105,6 +106,10 @@ impl SketchBoardInput { SketchBoardInput::InputEvent(InputEvent::Key(event)) } + pub fn new_key_release_event(event: KeyEventMsg) -> SketchBoardInput { + SketchBoardInput::InputEvent(InputEvent::KeyRelease(event)) + } + pub fn new_text_event(event: TextEventMsg) -> SketchBoardInput { SketchBoardInput::InputEvent(InputEvent::Text(event)) } diff --git a/src/style.rs b/src/style.rs index ccbcc15..1dcc045 100644 --- a/src/style.rs +++ b/src/style.rs @@ -205,11 +205,12 @@ impl Size { } } - pub fn to_highlight_opacity(self) -> u8 { + pub fn to_highlight_width(self) -> f32 { + let size_factor = APP_CONFIG.read().annotation_size_factor(); match self { - Size::Small => 50, - Size::Medium => 100, - Size::Large => 150, + Size::Small => 15.0 * size_factor, + Size::Medium => 30.0 * size_factor, + Size::Large => 45.0 * size_factor, } } } diff --git a/src/tools/highlight.rs b/src/tools/highlight.rs index e67d375..dc33ec6 100644 --- a/src/tools/highlight.rs +++ b/src/tools/highlight.rs @@ -1,45 +1,102 @@ +use std::ops::{Add, Sub}; + use anyhow::Result; use femtovg::{Paint, Path}; -use relm4::gtk::gdk::Key; +use relm4::gtk::gdk::{Key, ModifierType}; +use serde_derive::Deserialize; use crate::{ + command_line, + configuration::APP_CONFIG, math::{self, Vec2D}, sketch_board::{MouseEventMsg, MouseEventType}, - style::{Size, Style}, + style::Style, + tools::DrawableClone, }; -use super::{Drawable, DrawableClone, Tool, ToolUpdateResult}; +use super::{Drawable, Tool, ToolUpdateResult}; + +const HIGHLIGHT_OPACITY: f64 = 0.4; + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Highlighters { + Block = 0, + Freehand = 1, +} + +impl From for Highlighters { + fn from(tool: command_line::Highlighters) -> Self { + match tool { + command_line::Highlighters::Block => Self::Block, + command_line::Highlighters::Freehand => Self::Freehand, + } + } +} #[derive(Clone, Debug)] -pub struct Highlight { +struct BlockHighlight { top_left: Vec2D, size: Option, +} + +#[derive(Clone, Debug)] +struct FreehandHighlight { + points: Vec, + shift_pressed: bool, +} + +#[derive(Clone, Debug)] +struct Highlighter { + data: T, style: Style, - editing: bool, } -impl Drawable for Highlight { - fn draw( - &self, - canvas: &mut femtovg::Canvas, - _font: femtovg::FontId, - ) -> Result<()> { - let size = match self.size { +trait Highlight { + fn highlight(&self, canvas: &mut femtovg::Canvas) -> Result<()>; +} + +impl Highlight for Highlighter { + fn highlight(&self, canvas: &mut femtovg::Canvas) -> Result<()> { + canvas.save(); + + let mut path = Path::new(); + let first = self + .data + .points + .first() + .expect("should exist atleast one point in highlight instance."); + + path.move_to(first.x, first.y); + for p in self.data.points.iter().skip(1) { + path.line_to(first.x + p.x, first.y + p.y); + } + + let mut paint = Paint::color(femtovg::Color::rgba( + self.style.color.r, + self.style.color.g, + self.style.color.b, + (255.0 * HIGHLIGHT_OPACITY) as u8, + )); + paint.set_line_width(self.style.size.to_highlight_width()); + paint.set_line_join(femtovg::LineJoin::Round); + paint.set_line_cap(femtovg::LineCap::Square); + + canvas.stroke_path(&path, &paint); + canvas.restore(); + Ok(()) + } +} + +impl Highlight for Highlighter { + fn highlight(&self, canvas: &mut femtovg::Canvas) -> Result<()> { + let size = match self.data.size { Some(s) => s, None => return Ok(()), // early exit if size is none }; - let (pos, size) = math::rect_ensure_positive_size(self.top_left, size); - - if self.editing { - // include a border when selecting an area. - let border_paint = - Paint::color(self.style.color.into()).with_line_width(Size::Small.to_line_width()); - let mut border_path = Path::new(); - border_path.rect(pos.x, pos.y, size.x, size.y); - canvas.stroke_path(&border_path, &border_paint); - } + let (pos, size) = math::rect_ensure_positive_size(self.data.top_left, size); let mut shadow_path = Path::new(); shadow_path.rect(pos.x, pos.y, size.x, size.y); @@ -48,7 +105,7 @@ impl Drawable for Highlight { self.style.color.r, self.style.color.g, self.style.color.b, - self.style.size.to_highlight_opacity(), + (255.0 * HIGHLIGHT_OPACITY) as u8, )); canvas.fill_path(&shadow_path, &shadow_paint); @@ -56,67 +113,184 @@ impl Drawable for Highlight { } } -#[derive(Default)] +#[derive(Clone, Debug)] +enum HighlightKind { + Block(Highlighter), + Freehand(Highlighter), +} + +#[derive(Default, Clone, Debug)] pub struct HighlightTool { - highlight: Option, + highlighter: Option, style: Style, } +impl Drawable for HighlightKind { + fn draw( + &self, + canvas: &mut femtovg::Canvas, + _font: femtovg::FontId, + ) -> Result<()> { + match self { + HighlightKind::Block(highlighter) => highlighter.highlight(canvas), + HighlightKind::Freehand(highlighter) => highlighter.highlight(canvas), + } + } +} + impl Tool for HighlightTool { fn handle_mouse_event(&mut self, event: MouseEventMsg) -> ToolUpdateResult { + let shift_pressed = event.modifier.intersects(ModifierType::SHIFT_MASK); + let ctrl_pressed = event.modifier.intersects(ModifierType::CONTROL_MASK); + let primary_highlighter = APP_CONFIG.read().primary_highlighter(); match event.type_ { MouseEventType::BeginDrag => { - self.highlight = Some(Highlight { - top_left: event.pos, - size: None, - style: self.style, - editing: true, - }); + // There exists two types of highlighting modes currently: freehand, block + // A user may set a primary highlighter mode, with the other being accessible + // by clicking CTRL when starting a highlight (doesn't need to be held). + match (primary_highlighter, ctrl_pressed) { + // This matches when CTRL is not pressed and the primary highlighting mode + // is block, along with its inverse, CTRL pressed with the freehand mode + // being their primary highlighting mode. + (Highlighters::Block, false) | (Highlighters::Freehand, true) => { + self.highlighter = + Some(HighlightKind::Block(Highlighter:: { + data: BlockHighlight { + top_left: event.pos, + size: None, + }, + style: self.style, + })) + } + // This matches the remaining two cases, which is when the user has the + // freehand mode as the primary mode and CTRL is not pressed, and conversely, + // when CTRL is pressed and the users primary mode is block. + (Highlighters::Freehand, false) | (Highlighters::Block, true) => { + self.highlighter = + Some(HighlightKind::Freehand(Highlighter:: { + data: FreehandHighlight { + points: vec![event.pos], + shift_pressed, + }, + style: self.style, + })) + } + } ToolUpdateResult::Redraw } - MouseEventType::EndDrag => { - if let Some(a) = &mut self.highlight { - if event.pos == Vec2D::zero() { - self.highlight = None; - + MouseEventType::UpdateDrag | MouseEventType::EndDrag => { + if self.highlighter.is_none() { + return ToolUpdateResult::Unmodified; + } + let mut highlighter_kind = self.highlighter.as_mut().unwrap(); + let update: ToolUpdateResult = match &mut highlighter_kind { + HighlightKind::Block(highlighter) => { + // When shift is pressed when using the block highlighter, it transforms + // the area into a perfect square (in the direction they intended). + if shift_pressed { + let max_size = event.pos.x.abs().max(event.pos.y.abs()); + highlighter.data.size = Some(Vec2D { + x: max_size * event.pos.x.signum(), + y: max_size * event.pos.y.signum(), + }); + } else { + highlighter.data.size = Some(event.pos); + }; ToolUpdateResult::Redraw - } else { - a.size = Some(event.pos); - a.editing = false; + } + HighlightKind::Freehand(highlighter) => { + if event.pos == Vec2D::zero() { + return ToolUpdateResult::Unmodified; + }; - let result = a.clone_box(); - self.highlight = None; + // The freehand highlighter has a more complex shift model: + // when pressing shift it begins a straight line, which is aligned + // from the point after shift was pressed, to any 15*n degree rotation. + // + // After releasing shift, it creates an extra point, this is useful since + // it means that users do not need to move their mouse to achieve perfectly + // aligned turns, since they can release, then hold shift again to continue + // another aligned line. + // This extra point can be removed by releasing shift again (if the cursor + // hasn't moved) + if shift_pressed { + // if shift was pressed before we remove an extra point which would + // have been the previous aligned point. However ignore if there is + // only one point which means the highlight has just started. + if highlighter.data.shift_pressed && highlighter.data.points.len() >= 2 + { + highlighter + .data + .points + .pop() + .expect("atleast 2 points in highlight path."); + }; + // use the last point to position the snapping guide, or 0 if the point + // is the first one. + let last = if highlighter.data.points.len() == 1 { + Vec2D::zero() + } else { + *highlighter + .data + .points + .last_mut() + .expect("atleast one point") + }; + let snapped_pos = event.pos.sub(last).snapped_vector_15deg().add(last); + highlighter.data.points.push(snapped_pos); + } else { + highlighter.data.points.push(event.pos); + } - ToolUpdateResult::Commit(result) + highlighter.data.shift_pressed = shift_pressed; + ToolUpdateResult::Redraw } - } else { - ToolUpdateResult::Unmodified - } + }; + if event.type_ == MouseEventType::UpdateDrag { + return update; + }; + let result = highlighter_kind.clone_box(); + self.highlighter = None; + ToolUpdateResult::Commit(result) } - MouseEventType::UpdateDrag => { - if let Some(a) = &mut self.highlight { - if event.pos == Vec2D::zero() { - return ToolUpdateResult::Unmodified; - } - a.size = Some(event.pos); - ToolUpdateResult::Redraw - } else { - ToolUpdateResult::Unmodified - } - } _ => ToolUpdateResult::Unmodified, } } fn handle_key_event(&mut self, event: crate::sketch_board::KeyEventMsg) -> ToolUpdateResult { - if event.key == Key::Escape && self.highlight.is_some() { - self.highlight = None; - ToolUpdateResult::Redraw - } else { - ToolUpdateResult::Unmodified + if event.key == Key::Escape && self.highlighter.is_some() { + self.highlighter = None; + return ToolUpdateResult::Redraw; + } + ToolUpdateResult::Unmodified + } + + fn handle_key_release_event( + &mut self, + event: crate::sketch_board::KeyEventMsg, + ) -> ToolUpdateResult { + // Adds an extra point when shift is released in the freehand mode, this + // allows for users to make sharper turns. Release shift a second time + // to remove the added point (only if the cursor has not moved). + if event.key == Key::Shift_L || event.key == Key::Shift_R { + if let Some(HighlightKind::Freehand(highlighter)) = &mut self.highlighter { + let points = &mut highlighter.data.points; + let last = points + .last() + .expect("line highlight must have atleast one point"); + if points.len() >= 2 { + if *last == points[points.len() - 2] { + points.pop(); + } else { + points.push(*last); + } + return ToolUpdateResult::Redraw; + }; + }; } + ToolUpdateResult::Unmodified } fn handle_style_event(&mut self, style: Style) -> ToolUpdateResult { @@ -125,7 +299,7 @@ impl Tool for HighlightTool { } fn get_drawable(&self) -> Option<&dyn Drawable> { - match &self.highlight { + match &self.highlighter { Some(d) => Some(d), None => None, } diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 75ecfd6..91e26e1 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -56,6 +56,7 @@ pub trait Tool { match event { InputEvent::Mouse(e) => self.handle_mouse_event(e), InputEvent::Key(e) => self.handle_key_event(e), + InputEvent::KeyRelease(e) => self.handle_key_release_event(e), InputEvent::Text(e) => self.handle_text_event(e), } } @@ -75,6 +76,11 @@ pub trait Tool { ToolUpdateResult::Unmodified } + fn handle_key_release_event(&mut self, event: KeyEventMsg) -> ToolUpdateResult { + let _ = event; + ToolUpdateResult::Unmodified + } + fn handle_style_event(&mut self, style: Style) -> ToolUpdateResult { let _ = style; ToolUpdateResult::Unmodified @@ -126,7 +132,7 @@ pub enum ToolUpdateResult { pub use arrow::ArrowTool; pub use blur::BlurTool; pub use crop::CropTool; -pub use highlight::HighlightTool; +pub use highlight::{HighlightTool, Highlighters}; pub use line::LineTool; pub use rectangle::RectangleTool; pub use text::TextTool;