diff --git a/README.md b/README.md index cff5ae2e..8434b398 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ Text: - Ctrl+X to cut selected text to clipboard. 0.20.1 - Ctrl+V to paste text from clipboard. 0.20.1 - Alt+Ctrl with Left or Right or Up or Down to move the text. Use Alt+Ctrl+Shift with arrow keys to nudge the text. 0.20.1 +- Press Alt to cycle the text outline: none → inverted text color → contrast (black or white, by luminance). experimental NEXTRELEASE Marker: - Hold Alt to get extra ring. NEXTRELEASE diff --git a/src/style.rs b/src/style.rs index 2733bd00..d2b7babe 100644 --- a/src/style.rs +++ b/src/style.rs @@ -109,6 +109,22 @@ impl Color { Self::new(200, 37, 184, 255) } + /// Returns the color with its RGB channels inverted, preserving alpha. + pub fn inverted(self) -> Self { + Self::new(255 - self.r, 255 - self.g, 255 - self.b, self.a) + } + + /// Returns black or white, whichever contrasts better with this color, + /// based on its perceived luminance (YIQ), preserving alpha. + pub fn contrast(self) -> Self { + let luminance = (self.r as u32 * 299 + self.g as u32 * 587 + self.b as u32 * 114) / 1000; + if luminance >= 128 { + Self::new(0, 0, 0, self.a) + } else { + Self::new(255, 255, 255, self.a) + } + } + pub fn to_rgba_f64(self) -> (f64, f64, f64, f64) { ( (self.r as f64) / 255.0, diff --git a/src/tools/text.rs b/src/tools/text.rs index 9afe91d4..495894ea 100644 --- a/src/tools/text.rs +++ b/src/tools/text.rs @@ -26,6 +26,35 @@ use relm4::gtk::gdk::DisplayManager; use std::cell::RefCell; use std::rc::Rc; +/// How the text outline is rendered, cycled through with the Alt key. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum OutlineMode { + #[default] + None, + Inverted, + Contrast, +} + +impl OutlineMode { + /// Cycles None -> Inverted -> Contrast -> None. + fn next(self) -> Self { + match self { + Self::None => Self::Inverted, + Self::Inverted => Self::Contrast, + Self::Contrast => Self::None, + } + } + + /// The outline color for the given text color, or None when disabled. + fn outline_color(self, text_color: crate::style::Color) -> Option { + match self { + Self::None => None, + Self::Inverted => Some(text_color.inverted()), + Self::Contrast => Some(text_color.contrast()), + } + } +} + #[derive(Clone, Debug)] pub struct Text { pos: Vec2D, @@ -39,6 +68,7 @@ pub struct Text { line_ranges: RefCell>>, cursor_visible: RefCell, draw_rect: RefCell, + outline: OutlineMode, font_ids: Vec, } @@ -83,6 +113,7 @@ impl Text { line_ranges: RefCell::new(Vec::new()), cursor_visible: RefCell::new(true), draw_rect: RefCell::new(true), + outline: OutlineMode::default(), font_ids: femtovg_area::font_stack().to_vec(), } } @@ -352,13 +383,22 @@ impl Drawable for Text { canvas.stroke_path(&rect_paint, &paint); } + // When outlining, stroke each line with the outline color first, then fill on + // top so the visible border wraps the glyphs. The stroke is widened because it + // is centered on the glyph outline and half of it is hidden by the fill. + let outline_paint = self.outline.outline_color(self.style.color).map(|color| { + let mut paint = base_paint.clone(); + paint.set_color(color.into()); + paint.set_line_width(base_paint.line_width() * 2.0); + paint + }); + for line_range in &lines { - canvas.fill_text( - self.pos.x, - draw_baseline, - &text[line_range.clone()], - &base_paint, - )?; + let line = &text[line_range.clone()]; + if let Some(outline_paint) = &outline_paint { + canvas.stroke_text(self.pos.x, draw_baseline, line, outline_paint)?; + } + canvas.fill_text(self.pos.x, draw_baseline, line, &base_paint)?; draw_baseline += line_height; } @@ -710,6 +750,7 @@ pub struct TextTool { sender: Option>, drag_start_pos: Vec2D, dragged: Rc>, + alt_tap: bool, } impl Tool for TextTool { @@ -794,6 +835,11 @@ impl Tool for TextTool { } fn handle_key_event(&mut self, event: KeyEventMsg) -> ToolUpdateResult { + // Any key other than Alt cancels a pending Alt tap: Alt is being used as a + // modifier (e.g. Ctrl+Alt+Arrow), not tapped on its own to toggle the outline. + if !matches!(event.key, Key::Alt_L | Key::Alt_R) { + self.alt_tap = false; + } let mut tool_update_result = ToolUpdateResult::StopPropagation; if let Some(t) = &mut self.text { match event.key { @@ -829,6 +875,11 @@ impl Tool for TextTool { Key::Escape => { tool_update_result = self.handle_deactivated(); } + Key::Alt_L | Key::Alt_R => { + // Start tracking a potential Alt tap; the outline mode is cycled on + // release (see handle_key_release_event) if no other key was pressed. + self.alt_tap = true; + } Key::BackSpace | Key::Delete => { let ctrl_mask = match event.key { Key::BackSpace => ActionScope::BackwardWord, @@ -1078,6 +1129,17 @@ impl Tool for TextTool { tool_update_result } + fn handle_key_release_event(&mut self, event: KeyEventMsg) -> ToolUpdateResult { + if (event.key == Key::Alt_L || event.key == Key::Alt_R) && self.alt_tap { + self.alt_tap = false; + if let Some(t) = &mut self.text { + t.outline = t.outline.next(); + return ToolUpdateResult::RedrawAndStopPropagation; + } + } + ToolUpdateResult::Unmodified + } + fn handle_mouse_event(&mut self, event: MouseEventMsg) -> ToolUpdateResult { match event.type_ { MouseEventType::Click => {