use anyhow::anyhow;
use femtovg::imgref::Img;
use femtovg::rgb::{ComponentBytes, RGBA};
use keycode::{KeyMap, KeyMappingId};
use relm4::gtk::gdk_pixbuf::Pixbuf;
use relm4::gtk::gdk_pixbuf::glib::Bytes;
use std::cell::RefCell;
use std::io::Write;
use std::panic;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::rc::Rc;
use std::{fs, io};
use gtk::prelude::*;
use relm4::gtk::gdk::{DisplayManager, Key, ModifierType, Texture};
use relm4::{Component, ComponentParts, ComponentSender, RelmWidgetExt, gtk};
use crate::configuration::{APP_CONFIG, Action};
use crate::femtovg_area::FemtoVGArea;
use crate::ime::pango_adapter::spans_from_pango_attrs;
use crate::math::Vec2D;
use crate::notification::log_result;
use crate::style::Style;
use crate::tools::{Tool, ToolEvent, ToolUpdateResult, Tools, ToolsManager};
use crate::ui::toolbars::ToolbarEvent;
use xdg::BaseDirectories;
type RenderedImage = Img<Vec<RGBA<u8>>>;
const SAVE_AS_LAST_DIR_FILE: &str = "save_as_last_dir";
const SAVE_AS_LAST_DIR_MAX_BYTES: u64 = 10_000;
#[derive(Debug, Clone)]
pub enum SketchBoardInput {
InputEvent(InputEvent),
ToolbarEvent(ToolbarEvent),
RenderResult(RenderedImage, Vec<Action>),
RenderResultFollowup(Option<Pixbuf>, Vec<Action>, Option<String>),
CommitEvent(TextEventMsg),
Refresh,
Exit,
ScaleFactorChanged,
Output(SketchBoardOutput),
}
#[derive(Debug, Clone)]
pub enum SketchBoardOutput {
ToggleToolbarsDisplay,
ToolSwitchShortcut(Tools),
ColorSwitchShortcut(u64),
DimensionsUpdate(Option<(i32, i32)>),
ToolEditingChanged(bool),
}
#[derive(Debug, Clone)]
pub enum InputEvent {
Mouse(MouseEventMsg),
Key(KeyEventMsg),
KeyRelease(KeyEventMsg),
Text(TextEventMsg),
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
pub enum MouseButton {
Primary,
Secondary,
Middle,
}
#[derive(Debug, Clone, Copy)]
pub struct KeyEventMsg {
pub key: Key,
pub code: u32,
pub modifier: ModifierType,
}
#[derive(Debug, Clone)]
pub enum TextEventMsg {
Commit(String),
Preedit {
text: String,
cursor_chars: Option<usize>,
spans: Vec<crate::ime::preedit::PreeditSpan>,
},
PreeditEnd,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum MouseEventType {
BeginDrag,
EndDrag,
UpdateDrag,
Click,
Scroll,
PointerPos,
Release,
}
#[derive(Debug, Clone, Copy)]
pub struct MouseEventMsg {
pub type_: MouseEventType,
pub button: MouseButton,
pub modifier: ModifierType,
pub pos: Vec2D,
pub n_pressed: i32,
pub release: bool,
}
impl SketchBoardInput {
pub fn new_mouse_event(
event_type: MouseEventType,
button: u32,
n_pressed: i32,
modifier: ModifierType,
pos: Vec2D,
release: bool,
) -> SketchBoardInput {
SketchBoardInput::InputEvent(InputEvent::Mouse(MouseEventMsg {
type_: event_type,
button: button.into(),
n_pressed,
modifier,
pos,
release,
}))
}
pub fn new_key_event(event: KeyEventMsg) -> 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))
}
pub fn new_commit_event(event: TextEventMsg) -> SketchBoardInput {
SketchBoardInput::CommitEvent(event)
}
pub fn new_scroll_event(delta_y: f64) -> SketchBoardInput {
SketchBoardInput::InputEvent(InputEvent::Mouse(MouseEventMsg {
type_: MouseEventType::Scroll,
button: MouseButton::Middle,
n_pressed: 0,
modifier: ModifierType::empty(),
pos: Vec2D::new(0.0, delta_y as f32),
release: false,
}))
}
}
impl From<u32> for MouseButton {
fn from(value: u32) -> Self {
match value {
gtk::gdk::BUTTON_PRIMARY => MouseButton::Primary,
gtk::gdk::BUTTON_MIDDLE => MouseButton::Middle,
gtk::gdk::BUTTON_SECONDARY => MouseButton::Secondary,
_ => MouseButton::Primary,
}
}
}
impl InputEvent {
fn handle_event_mouse_input(&mut self, renderer: &FemtoVGArea) -> Option<ToolUpdateResult> {
if let InputEvent::Mouse(me) = self {
match me.type_ {
MouseEventType::Click => {
me.pos = renderer.abs_canvas_to_image_coordinates(me.pos);
None
}
MouseEventType::Release => {
me.pos = renderer.abs_canvas_to_image_coordinates(me.pos);
None
}
MouseEventType::BeginDrag => {
me.pos = renderer.abs_canvas_to_image_coordinates(me.pos);
None
}
MouseEventType::EndDrag | MouseEventType::UpdateDrag => {
me.pos = renderer.rel_canvas_to_image_coordinates(me.pos);
None
}
_ => None,
}
} else {
None
}
}
fn handle_mouse_event(&mut self, renderer: &FemtoVGArea) -> Option<ToolUpdateResult> {
if let InputEvent::Mouse(me) = self {
match me.type_ {
MouseEventType::Click => {
if me.button == MouseButton::Secondary {
renderer.request_render(&APP_CONFIG.read().actions_on_right_click());
None
} else {
None
}
}
MouseEventType::EndDrag | MouseEventType::UpdateDrag => {
if me.button == MouseButton::Middle {
renderer.set_drag_offset(me.pos);
renderer.set_is_drag(true);
if me.type_ == MouseEventType::EndDrag {
renderer.store_last_offset();
renderer.set_is_drag(false);
}
renderer.request_render(&[]);
}
None
}
MouseEventType::Scroll => {
let factor = APP_CONFIG.read().zoom_factor();
match me.pos.y {
v if v < 0.0 => renderer.set_zoom_scale(factor),
v if v > 0.0 => renderer.set_zoom_scale(1f32 / factor),
_ => {}
}
renderer.request_render(&[]);
None
}
MouseEventType::PointerPos => {
renderer.set_pointer_offset(me.pos);
None
}
_ => None,
}
} else {
None
}
}
}
pub struct SketchBoard {
renderer: FemtoVGArea,
active_tool: Rc<RefCell<dyn Tool>>,
tool_edit_mode: bool,
tools: ToolsManager,
style: Style,
im_context: gtk::IMMulticontext,
last_saved_filepath: RefCell<Option<String>>,
}
impl SketchBoard {
fn refresh_screen(&mut self) {
self.renderer.queue_render();
}
fn image_to_pixbuf(image: RenderedImage) -> Pixbuf {
let (buf, w, h) = image.into_contiguous_buf();
Pixbuf::from_bytes(
&Bytes::from(buf.as_bytes()),
relm4::gtk::gdk_pixbuf::Colorspace::Rgb,
true,
8,
w as i32,
h as i32,
w as i32 * 4,
)
}
fn deactivate_active_tool(&mut self) -> bool {
if self.active_tool.borrow().active()
&& let ToolUpdateResult::Commit(result) =
self.active_tool.borrow_mut().handle_deactivated()
{
self.renderer.commit(result);
return true;
}
false
}
fn handle_action(&mut self, actions: &[Action]) -> ToolUpdateResult {
let rv = if self.deactivate_active_tool() {
ToolUpdateResult::Redraw
} else {
ToolUpdateResult::Unmodified
};
self.renderer.request_render(actions);
rv
}
fn handle_render_result_with_pixbuf(
&self,
pix_buf: Option<Pixbuf>,
actions: Vec<Action>,
sender: ComponentSender<Self>,
) {
let mut iter = actions.into_iter();
let mut early_exit = false;
while let Some(action) = iter.next() {
match action {
Action::CopyFilepathToClipboard => {
self.handle_copy_filepath();
}
Action::SaveToClipboard => {
if let Some(ref pix_buf) = pix_buf {
self.handle_copy_clipboard(pix_buf);
if !APP_CONFIG.read().auto_copy() {
early_exit = APP_CONFIG.read().early_exit_copy();
}
}
}
Action::SaveToFile => {
if let Some(ref pix_buf) = pix_buf {
self.handle_save(pix_buf);
early_exit = APP_CONFIG.read().early_exit_save();
}
}
Action::SaveToFileAs => {
if let Some(pix_buf) = pix_buf {
let followup_actions: Vec<Action> = iter.collect();
let is_modal =
APP_CONFIG.read().early_exit_save_as() || !followup_actions.is_empty();
self.handle_save_as(is_modal, pix_buf, sender, followup_actions);
}
return;
}
_ => (),
}
if early_exit {
log_result("Early exit, ignoring further actions.", false);
self.handle_exit();
return;
}
if action == Action::Exit {
log_result("Exit action, ignoring further actions.", false);
self.handle_exit();
return;
}
}
}
fn handle_render_result(
&self,
image: RenderedImage,
actions: Vec<Action>,
sender: ComponentSender<Self>,
) {
let needs_pixbuf = actions.iter().any(|action| {
matches!(
action,
Action::SaveToClipboard | Action::SaveToFile | Action::SaveToFileAs
)
});
let pix_buf = if needs_pixbuf {
Some(Self::image_to_pixbuf(image))
} else {
None
};
self.handle_render_result_with_pixbuf(pix_buf, actions, sender);
}
fn handle_exit(&self) {
relm4::main_application().quit();
}
fn resolve_output_filename(output_filename: &str) -> Option<String> {
let delayed_format = chrono::Local::now().format(output_filename);
let mut output_filename = if panic::catch_unwind(|| delayed_format.to_string()).is_ok() {
delayed_format.to_string()
} else {
eprintln!(
"Warning: Could not format filename {output_filename} due to chrono format error, falling back to literal filename."
);
output_filename.to_owned()
};
if let Some(tilde_stripped) =
output_filename.strip_prefix(&format!("~{}", std::path::MAIN_SEPARATOR_STR))
{
if let Some(mut home_dir) = std::env::home_dir() {
home_dir.push(tilde_stripped);
output_filename = home_dir.to_string_lossy().into_owned();
} else {
log_result(
"~ found but could not determine homedir",
!APP_CONFIG.read().disable_notifications(),
);
return None;
}
}
Some(output_filename)
}
fn configured_output_path() -> Option<PathBuf> {
APP_CONFIG
.read()
.output_filename()
.and_then(|output_filename| {
if output_filename == "-" {
None
} else {
Self::resolve_output_filename(output_filename).map(PathBuf::from)
}
})
}
fn save_as_last_dir_file() -> Option<PathBuf> {
let dirs = BaseDirectories::with_prefix(env!("CARGO_PKG_NAME"));
dirs.get_state_file(SAVE_AS_LAST_DIR_FILE)
}
fn save_as_last_dir_file_for_write() -> Option<PathBuf> {
let dirs = BaseDirectories::with_prefix(env!("CARGO_PKG_NAME"));
dirs.place_state_file(SAVE_AS_LAST_DIR_FILE).ok()
}
fn save_as_initial_dir(
last_dir_file: Option<&Path>,
configured_output_path: Option<&Path>,
) -> Option<PathBuf> {
if let Some(last_dir_file) = last_dir_file
&& fs::metadata(last_dir_file).is_ok_and(|metadata| {
metadata.is_file() && metadata.len() <= SAVE_AS_LAST_DIR_MAX_BYTES
})
&& let Ok(last_dir) = fs::read_to_string(last_dir_file)
{
let last_dir = PathBuf::from(last_dir);
if last_dir.is_dir() {
return Some(last_dir);
}
}
configured_output_path
.and_then(Path::parent)
.filter(|parent| parent.is_dir())
.map(Path::to_path_buf)
}
fn remember_save_as_dir(output_filename: &Path) {
let Some(last_dir_file) = Self::save_as_last_dir_file_for_write() else {
return;
};
Self::write_save_as_last_dir(&last_dir_file, output_filename);
}
fn write_save_as_last_dir(last_dir_file: &Path, output_filename: &Path) {
let Some(parent) = output_filename.parent() else {
return;
};
let _ = fs::write(last_dir_file, parent.to_string_lossy().as_bytes());
}
fn handle_save(&self, image: &Pixbuf) {
let output_filename = match APP_CONFIG.read().output_filename() {
None => {
eprintln!("No Output filename specified!");
return;
}
Some(o) => o.clone(),
};
let Some(output_filename) = Self::resolve_output_filename(&output_filename) else {
return;
};
if output_filename != "-" && !output_filename.ends_with(".png") {
log_result(
"The only supported format is png, but the filename does not end in png",
!APP_CONFIG.read().disable_notifications(),
);
return;
}
let data = match image.save_to_bufferv("png", &Vec::new()) {
Ok(d) => d,
Err(e) => {
eprintln!("Error serializing image: {e}");
return;
}
};
if output_filename == "-" {
let stdout = io::stdout();
let mut handle = stdout.lock();
if let Err(e) = handle.write_all(&data) {
eprintln!("Error writing image to stdout: {e}");
}
return;
}
match fs::write(&output_filename, data) {
Err(e) => log_result(
&format!("Error while saving file: {e}"),
!APP_CONFIG.read().disable_notifications(),
),
Ok(_) => {
*self.last_saved_filepath.borrow_mut() = Some(output_filename.clone());
log_result(
&format!("File saved to '{}'.", &output_filename),
!APP_CONFIG.read().disable_notifications(),
)
}
};
}
fn handle_save_as(
&self,
is_modal: bool,
pixbuf: Pixbuf,
sender: ComponentSender<Self>,
followup_actions: Vec<Action>,
) {
let configured_output_path = Self::configured_output_path();
let initial_dir = Self::save_as_initial_dir(
Self::save_as_last_dir_file().as_deref(),
configured_output_path.as_deref(),
);
let suggested_filename = configured_output_path
.as_deref()
.and_then(Path::file_name)
.map(|name| name.to_string_lossy().into_owned());
let data = match pixbuf.save_to_bufferv("png", &Vec::new()) {
Ok(d) => d,
Err(e) => {
eprintln!("Error serializing image: {e}");
return;
}
};
let root = self.renderer.toplevel_window();
relm4::spawn_local(async move {
let builder = gtk::FileChooserNative::builder()
.modal(is_modal)
.title("Save Image As")
.action(gtk::FileChooserAction::Save)
.accept_label("Save")
.cancel_label("Cancel");
let dialog = match root {
Some(w) => builder.transient_for(&w),
None => builder,
}
.build();
if let Some(initial_dir) = initial_dir {
let initial_dir = gtk::gio::File::for_path(initial_dir);
if let Err(e) = dialog.set_current_folder(Some(&initial_dir)) {
eprintln!("Error setting Save As folder: {e}");
}
}
if let Some(filename) = suggested_filename {
dialog.set_current_name(&filename);
}
dialog.connect_response(move |dialog, response| {
let mut exit_app = false;
let mut filename: Option<String> = None;
if response == gtk::ResponseType::Accept
&& let Some(file) = dialog.file()
{
let output_filename = match file.path() {
Some(path) => path.to_string_lossy().into_owned(),
None => return,
};
match fs::write(&output_filename, &data) {
Err(e) => log_result(
&format!("Error while saving file: {e}"),
!APP_CONFIG.read().disable_notifications(),
),
Ok(_) => {
exit_app = APP_CONFIG.read().early_exit_save_as();
filename = Some(output_filename.clone());
Self::remember_save_as_dir(Path::new(&output_filename));
log_result(
&format!("File saved to '{}'.", &output_filename),
!APP_CONFIG.read().disable_notifications(),
)
}
};
}
if exit_app {
log_result("early exit after save as, ignoring further actions.", false);
sender.input(SketchBoardInput::Exit);
} else if filename.is_some() || !followup_actions.is_empty() {
let followup_actions_clone = followup_actions.clone();
let pixbuf_clone = Some(pixbuf.clone());
sender.input(SketchBoardInput::RenderResultFollowup(
pixbuf_clone,
followup_actions_clone,
filename,
));
}
});
dialog.show();
});
}
fn save_texture_to_clipboard(&self, texture: &impl IsA<Texture>) -> anyhow::Result<()> {
let display = DisplayManager::get()
.default_display()
.ok_or(anyhow!("Cannot open default display for clipboard."))?;
display.clipboard().set_texture(texture);
Ok(())
}
fn save_bytes_to_external_process(&self, bytes: &[u8], command: &str) -> anyhow::Result<()> {
let mut child = Command::new("sh")
.arg("-c")
.arg(command)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.spawn()?;
let child_stdin = child.stdin.as_mut().unwrap();
child_stdin.write_all(bytes)?;
if !child.wait()?.success() {
return Err(anyhow!("Writing to process '{command}' failed."));
}
Ok(())
}
fn save_texture_to_external_process(
&self,
texture: &impl IsA<Texture>,
command: &str,
) -> anyhow::Result<()> {
self.save_bytes_to_external_process(texture.save_to_png_bytes().as_ref(), command)
}
fn handle_copy_clipboard(&self, image: &Pixbuf) {
let texture = Texture::for_pixbuf(image);
let result = if let Some(command) = APP_CONFIG.read().copy_command() {
self.save_texture_to_external_process(&texture, command)
} else {
self.save_texture_to_clipboard(&texture)
};
match result {
Err(e) => eprintln!("Error saving {e}"),
Ok(()) => {
log_result(
"Copied to clipboard.",
!APP_CONFIG.read().disable_notifications(),
);
if APP_CONFIG.read().save_after_copy() {
self.handle_save(image);
};
}
}
}
fn copy_text_to_clipboard(&self, text: &str) -> anyhow::Result<()> {
let display = DisplayManager::get()
.default_display()
.ok_or(anyhow!("Cannot open default display for clipboard."))?;
display.clipboard().set_text(text);
Ok(())
}
fn copy_text_to_external_process(&self, text: &str, command: &str) -> anyhow::Result<()> {
self.save_bytes_to_external_process(text.as_bytes(), command)
}
fn handle_copy_filepath(&self) {
let filepath = match self.last_saved_filepath.borrow().clone() {
Some(path) => path,
None => return,
};
let result = if let Some(command) = APP_CONFIG.read().copy_command() {
self.copy_text_to_external_process(&filepath, command)
} else {
self.copy_text_to_clipboard(&filepath)
};
match result {
Err(e) => log_result(
&format!("Error copying filepath: {e}"),
!APP_CONFIG.read().disable_notifications(),
),
Ok(()) => log_result(
&format!("Filepath copied to clipboard: {}", filepath),
!APP_CONFIG.read().disable_notifications(),
),
}
}
fn handle_undo(&mut self) -> ToolUpdateResult {
if self.active_tool.borrow().active() {
self.active_tool.borrow_mut().handle_undo()
} else if self.renderer.undo() {
ToolUpdateResult::Redraw
} else {
ToolUpdateResult::Unmodified
}
}
fn handle_redo(&mut self) -> ToolUpdateResult {
if self.active_tool.borrow().active() {
self.active_tool.borrow_mut().handle_redo()
} else if self.renderer.redo() {
ToolUpdateResult::Redraw
} else {
ToolUpdateResult::Unmodified
}
}
fn handle_reset(&mut self) -> ToolUpdateResult {
if self.deactivate_active_tool() | self.renderer.reset() {
ToolUpdateResult::Redraw
} else {
ToolUpdateResult::Unmodified
}
}
fn handle_resize(&mut self) -> ToolUpdateResult {
self.renderer.reset_size(0.);
self.renderer.request_render(&[]);
ToolUpdateResult::Unmodified
}
fn handle_original_scale(&mut self) -> ToolUpdateResult {
self.renderer.reset_size(1.);
self.renderer.request_render(&[]);
ToolUpdateResult::Unmodified
}
fn handle_toggle_toolbars_display(
&mut self,
sender: ComponentSender<Self>,
) -> ToolUpdateResult {
sender
.output_sender()
.emit(SketchBoardOutput::ToggleToolbarsDisplay);
ToolUpdateResult::Unmodified
}
fn handle_toolbar_event(
&mut self,
toolbar_event: ToolbarEvent,
sender: ComponentSender<Self>,
) -> ToolUpdateResult {
match toolbar_event {
ToolbarEvent::ToolSelected(tool) => {
let old_tool = self.active_tool.clone();
let mut deactivate_result =
old_tool.borrow_mut().handle_event(ToolEvent::Deactivated);
old_tool.borrow_mut().set_im_context(None);
if let ToolUpdateResult::Commit(d) = deactivate_result {
self.renderer.commit(d);
if APP_CONFIG.read().auto_copy() {
self.renderer.request_render(&[Action::SaveToClipboard]);
}
deactivate_result = ToolUpdateResult::Redraw;
}
self.active_tool = self.tools.get(&tool);
self.renderer.set_active_tool(self.active_tool.clone());
let widget_ref: gtk::Widget = self.renderer.clone().upcast();
self.active_tool
.borrow_mut()
.set_im_context(Some(crate::tools::InputContext {
im_context: self.im_context.clone(),
widget: widget_ref,
}));
self.active_tool
.borrow_mut()
.set_sender(sender.input_sender().clone());
self.active_tool
.borrow_mut()
.handle_event(ToolEvent::StyleChanged(self.style));
let activate_result = self
.active_tool
.borrow_mut()
.handle_event(ToolEvent::Activated);
match activate_result {
ToolUpdateResult::Unmodified => deactivate_result,
_ => activate_result,
}
}
ToolbarEvent::ColorSelected(color) => {
self.style.color = color;
self.active_tool
.borrow_mut()
.handle_event(ToolEvent::StyleChanged(self.style))
}
ToolbarEvent::SizeSelected(size) => {
self.style.size = size;
self.active_tool
.borrow_mut()
.handle_event(ToolEvent::StyleChanged(self.style))
}
ToolbarEvent::SaveFile => self.handle_action(&[Action::SaveToFile]),
ToolbarEvent::CopyClipboard => self.handle_action(&[Action::SaveToClipboard]),
ToolbarEvent::Undo => self.handle_undo(),
ToolbarEvent::Redo => self.handle_redo(),
ToolbarEvent::Reset => self.handle_reset(),
ToolbarEvent::ToggleFill => {
self.style.fill = !self.style.fill;
self.active_tool
.borrow_mut()
.handle_event(ToolEvent::StyleChanged(self.style))
}
ToolbarEvent::AnnotationSizeChanged(value) => {
self.style.annotation_size_factor = value;
self.active_tool
.borrow_mut()
.handle_event(ToolEvent::StyleChanged(self.style))
}
ToolbarEvent::SaveFileAs => self.handle_action(&[Action::SaveToFileAs]),
ToolbarEvent::Resize => self.handle_resize(),
ToolbarEvent::OriginalScale => self.handle_original_scale(),
}
}
fn handle_text_commit(
&self,
event: TextEventMsg,
sender: ComponentSender<Self>,
) -> ToolUpdateResult {
match event {
TextEventMsg::Commit(txt) => {
if self.active_tool_type() == Tools::Text
&& self.active_tool.borrow().input_enabled()
{
sender.input(SketchBoardInput::new_text_event(TextEventMsg::Commit(
txt.to_string(),
)));
} else if let Some(tool) = txt
.chars()
.next()
.and_then(|char| APP_CONFIG.read().keybinds().get_tool(char))
{
sender.input(SketchBoardInput::ToolbarEvent(ToolbarEvent::ToolSelected(
tool,
)));
sender
.output_sender()
.emit(SketchBoardOutput::ToolSwitchShortcut(tool));
} else if let Some(hotkey_digit) =
txt.chars().next().and_then(|char| char.to_digit(10))
{
let index_digit = if hotkey_digit == 0 {
9
} else {
hotkey_digit - 1
};
sender
.output_sender()
.emit(SketchBoardOutput::ColorSwitchShortcut(index_digit as u64));
}
}
TextEventMsg::Preedit {
text,
cursor_chars,
spans,
} => {
if self.active_tool_type() == Tools::Text
&& self.active_tool.borrow().input_enabled()
{
sender.input(SketchBoardInput::new_text_event(TextEventMsg::Preedit {
text,
cursor_chars,
spans,
}));
}
}
TextEventMsg::PreeditEnd => {
if self.active_tool_type() == Tools::Text
&& self.active_tool.borrow().input_enabled()
{
sender.input(SketchBoardInput::new_text_event(TextEventMsg::PreeditEnd));
}
}
}
ToolUpdateResult::Unmodified
}
pub fn active_tool_type(&self) -> Tools {
self.active_tool.borrow().get_tool_type()
}
}
#[relm4::component(pub)]
impl Component for SketchBoard {
type CommandOutput = ();
type Input = SketchBoardInput;
type Output = SketchBoardOutput;
type Init = Pixbuf;
view! {
gtk::Box {
add_css_class: "inner_box",
#[local_ref]
area -> FemtoVGArea {
set_vexpand: true,
set_hexpand: true,
set_can_focus: true,
set_focusable: true,
grab_focus: (),
add_controller = gtk::GestureDrag {
set_button: 0,
connect_drag_begin[sender] => move |controller, x, y| {
sender.input(SketchBoardInput::new_mouse_event(
MouseEventType::BeginDrag,
controller.current_button(),
1,
controller.current_event_state(),
Vec2D::new(x as f32, y as f32),
false,
));
},
connect_drag_update[sender] => move |controller, x, y| {
sender.input(SketchBoardInput::new_mouse_event(
MouseEventType::UpdateDrag,
controller.current_button(),
1,
controller.current_event_state(),
Vec2D::new(x as f32, y as f32),
false,
));
},
connect_drag_end[sender] => move |controller, x, y| {
sender.input(SketchBoardInput::new_mouse_event(
MouseEventType::EndDrag,
controller.current_button(),
1,
controller.current_event_state(),
Vec2D::new(x as f32, y as f32),
false
));
}
},
add_controller = gtk::GestureClick {
set_button: 0,
connect_pressed[sender] => move |controller, n_pressed, x, y| {
sender.input(SketchBoardInput::new_mouse_event(
MouseEventType::Click,
controller.current_button(),
n_pressed,
controller.current_event_state(),
Vec2D::new(x as f32, y as f32),
false,
));
},
connect_released[sender] => move |controller, n_released, x, y| {
sender.input(SketchBoardInput::new_mouse_event(
MouseEventType::Release,
controller.current_button(),
n_released,
controller.current_event_state(),
Vec2D::new(x as f32, y as f32),
true,
));
}
},
add_controller = gtk::EventControllerScroll{
set_flags: gtk::EventControllerScrollFlags::VERTICAL,
connect_scroll[sender] => move |_, _, dy| {
sender.input(SketchBoardInput::new_scroll_event(dy));
relm4::gtk::glib::Propagation::Stop
},
},
add_controller = gtk::EventControllerKey {
connect_key_pressed[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()) {
sender.input(SketchBoardInput::new_key_event(KeyEventMsg::new(key, code, modifier)));
}
} else {
sender.input(SketchBoardInput::new_key_event(KeyEventMsg::new(key, code, modifier)));
}
relm4::gtk::glib::Propagation::Stop
},
connect_key_released[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()) {
sender.input(SketchBoardInput::new_key_release_event(KeyEventMsg::new(key, code, modifier)));
}
} else {
sender.input(SketchBoardInput::new_key_release_event(KeyEventMsg::new(key, code, modifier)));
}
},
set_im_context: Some(&model.im_context),
},
add_controller = gtk::EventControllerMotion {
connect_motion[sender] => move |controller, x, y| {
sender.input(SketchBoardInput::new_mouse_event(
MouseEventType::PointerPos,
0,
0,
controller.current_event_state(),
Vec2D::new(x as f32, y as f32),
false
));
}
}
}
},
}
fn update(&mut self, msg: SketchBoardInput, sender: ComponentSender<Self>, _root: &Self::Root) {
let sender_clone = sender.clone();
let result = match msg {
SketchBoardInput::InputEvent(mut ie) => {
if let InputEvent::Key(ke) = ie {
let active_tool_result = self
.active_tool
.borrow_mut()
.handle_event(ToolEvent::Input(ie.clone()));
match active_tool_result {
ToolUpdateResult::StopPropagation
| ToolUpdateResult::RedrawAndStopPropagation => active_tool_result,
_ => {
if ke.key == Key::y && ke.modifier == ModifierType::CONTROL_MASK {
self.handle_redo()
} else if ke.is_one_of(Key::z, KeyMappingId::UsZ)
&& ke.modifier == ModifierType::CONTROL_MASK
{
self.handle_undo()
} else if ke.is_one_of(Key::y, KeyMappingId::UsY)
&& ke.modifier == ModifierType::CONTROL_MASK
{
self.handle_redo()
} else if ke.is_one_of(Key::t, KeyMappingId::UsT)
&& ke.modifier == ModifierType::CONTROL_MASK
{
self.handle_toggle_toolbars_display(sender)
} else if ke.is_one_of(Key::s, KeyMappingId::UsS)
&& ke.modifier == ModifierType::CONTROL_MASK
{
self.renderer.request_render(&[Action::SaveToFile]);
ToolUpdateResult::Unmodified
} else if ke.is_one_of(Key::s, KeyMappingId::UsS)
&& ke.modifier
== (ModifierType::CONTROL_MASK | ModifierType::SHIFT_MASK)
{
self.renderer.request_render(&[Action::SaveToFileAs]);
ToolUpdateResult::Unmodified
} else if ke.is_one_of(Key::c, KeyMappingId::UsC)
&& ke.modifier == ModifierType::CONTROL_MASK
{
self.renderer.request_render(&[Action::SaveToClipboard]);
ToolUpdateResult::Unmodified
} else if ke.is_one_of(Key::c, KeyMappingId::UsC)
&& ke.modifier
== (ModifierType::CONTROL_MASK | ModifierType::ALT_MASK)
{
self.renderer
.request_render(&[Action::CopyFilepathToClipboard]);
ToolUpdateResult::Unmodified
} else if (ke.is_one_of(Key::d, KeyMappingId::UsD)
|| ke.is_one_of(Key::i, KeyMappingId::UsI))
&& ke.modifier
== (ModifierType::CONTROL_MASK | ModifierType::SHIFT_MASK)
{
gtk::Window::set_interactive_debugging(true);
ToolUpdateResult::Unmodified
} else if (ke.is_one_of(Key::leftarrow, KeyMappingId::ArrowLeft)
|| ke.is_one_of(Key::rightarrow, KeyMappingId::ArrowRight)
|| ke.is_one_of(Key::uparrow, KeyMappingId::ArrowUp)
|| ke.is_one_of(Key::downarrow, KeyMappingId::ArrowDown))
&& ke.modifier == ModifierType::ALT_MASK
{
let pan_step_size = APP_CONFIG.read().pan_step_size();
match ke.key {
Key::Left => self
.renderer
.set_drag_offset(Vec2D::new(-pan_step_size, 0.)),
Key::Right => {
self.renderer.set_drag_offset(Vec2D::new(pan_step_size, 0.))
}
Key::Up => self
.renderer
.set_drag_offset(Vec2D::new(0., -pan_step_size)),
Key::Down => {
self.renderer.set_drag_offset(Vec2D::new(0., pan_step_size))
}
_ => { }
}
self.renderer.store_last_offset();
self.renderer.request_render(&[]);
ToolUpdateResult::Unmodified
} else if ke.modifier.is_empty() && ke.key == Key::Delete {
self.handle_reset()
} else if ke.modifier.is_empty()
&& (ke.key == Key::Escape
|| ke.key == Key::Return
|| ke.key == Key::KP_Enter)
{
if let ToolUpdateResult::Unmodified = active_tool_result {
let actions = if ke.key == Key::Escape {
APP_CONFIG.read().actions_on_escape()
} else {
APP_CONFIG.read().actions_on_enter()
};
self.renderer.request_render(&actions);
};
active_tool_result
} else {
active_tool_result
}
}
}
} else {
ie.handle_event_mouse_input(&self.renderer);
let active_tool_result = self
.active_tool
.borrow_mut()
.handle_event(ToolEvent::Input(ie.clone()));
match active_tool_result {
ToolUpdateResult::StopPropagation
| ToolUpdateResult::RedrawAndStopPropagation => active_tool_result,
_ => {
if let Some(result) = ie.handle_mouse_event(&self.renderer) {
result
} else {
active_tool_result
}
}
}
}
}
SketchBoardInput::ToolbarEvent(toolbar_event) => {
self.handle_toolbar_event(toolbar_event, sender)
}
SketchBoardInput::RenderResult(img, action) => {
self.handle_render_result(img, action, sender);
ToolUpdateResult::Unmodified
}
SketchBoardInput::RenderResultFollowup(pix_buf, action, filename) => {
if filename.is_some() {
*self.last_saved_filepath.borrow_mut() = filename;
}
self.handle_render_result_with_pixbuf(pix_buf, action, sender);
ToolUpdateResult::Unmodified
}
SketchBoardInput::CommitEvent(txt) => {
self.handle_text_commit(txt, sender);
ToolUpdateResult::Unmodified
}
SketchBoardInput::Refresh => ToolUpdateResult::Redraw,
SketchBoardInput::Exit => {
self.handle_exit();
ToolUpdateResult::Unmodified
}
SketchBoardInput::ScaleFactorChanged => {
self.renderer.resize(0, 0);
ToolUpdateResult::Redraw
}
SketchBoardInput::Output(output) => {
sender.output_sender().emit(output);
ToolUpdateResult::Unmodified
}
};
let editing = self.active_tool.borrow().active();
if editing != self.tool_edit_mode {
self.tool_edit_mode = editing;
sender_clone
.output_sender()
.emit(SketchBoardOutput::ToolEditingChanged(editing));
}
match result {
ToolUpdateResult::Commit(drawable) => {
self.renderer.commit(drawable);
if APP_CONFIG.read().auto_copy() {
self.renderer.request_render(&[Action::SaveToClipboard]);
}
self.refresh_screen();
}
ToolUpdateResult::Unmodified | ToolUpdateResult::StopPropagation => (),
ToolUpdateResult::Redraw | ToolUpdateResult::RedrawAndStopPropagation => {
self.refresh_screen()
}
};
}
fn init(
image: Self::Init,
root: Self::Root,
sender: ComponentSender<Self>,
) -> ComponentParts<Self> {
let config = APP_CONFIG.read();
let tools = ToolsManager::new();
let im_context = gtk::IMMulticontext::new();
let mut model = Self {
renderer: FemtoVGArea::default(),
active_tool: tools.get(&config.initial_tool()),
tool_edit_mode: false,
style: Style::default(),
tools,
im_context,
last_saved_filepath: RefCell::new(None),
};
let area = &mut model.renderer;
area.init(
sender.input_sender().clone(),
model.tools.get_crop_tool(),
model.active_tool.clone(),
image,
);
let widgets = view_output!();
model.im_context.set_client_widget(Some(&model.renderer));
model.im_context.set_use_preedit(true);
if let Ok(module) = std::env::var("GTK_IM_MODULE")
&& (module.eq_ignore_ascii_case("fcitx") || module.eq_ignore_ascii_case("fcitx5"))
{
model.im_context.set_context_id(Some("fcitx"));
}
{
let sender = sender.input_sender().clone();
model.im_context.connect_commit(move |_cx, txt| {
sender.emit(SketchBoardInput::new_commit_event(TextEventMsg::Commit(
txt.to_string(),
)));
});
}
{
let sender = sender.input_sender().clone();
model.im_context.connect_preedit_changed(move |cx| {
let (text, attrs, cursor) = cx.preedit_string();
let cursor_chars = if cursor >= 0 {
Some(cursor as usize)
} else {
None
};
let spans = spans_from_pango_attrs(text.as_str(), Some(attrs));
sender.emit(SketchBoardInput::new_commit_event(TextEventMsg::Preedit {
text: text.to_string(),
cursor_chars,
spans,
}));
});
}
{
let sender = sender.input_sender().clone();
model.im_context.connect_preedit_end(move |_cx| {
sender.emit(SketchBoardInput::new_commit_event(TextEventMsg::PreeditEnd));
});
}
let focus_controller = gtk::EventControllerFocus::new();
{
let im_context = model.im_context.clone();
focus_controller.connect_enter(move |_| {
im_context.focus_in();
});
}
{
let im_context = model.im_context.clone();
focus_controller.connect_leave(move |_| {
im_context.focus_out();
});
}
model.renderer.add_controller(focus_controller);
let widget_ref: gtk::Widget = model.renderer.clone().upcast();
model
.active_tool
.borrow_mut()
.set_im_context(Some(crate::tools::InputContext {
im_context: model.im_context.clone(),
widget: widget_ref,
}));
ComponentParts { model, widgets }
}
}
impl KeyEventMsg {
pub fn new(key: Key, code: u32, modifier: ModifierType) -> Self {
Self {
key,
code,
modifier,
}
}
fn is_one_of(&self, key: Key, code: KeyMappingId) -> bool {
let keymap = KeyMap::from(code);
self.key == key || self.code as u16 - 8 == keymap.evdev
}
}
#[cfg(test)]
mod tests {
use super::SketchBoard;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
struct TempDir {
path: PathBuf,
}
impl TempDir {
fn new(name: &str) -> Self {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before Unix epoch")
.as_nanos();
let path = std::env::temp_dir().join(format!("satty-{name}-{nanos}"));
fs::create_dir_all(&path).expect("create temp dir");
Self { path }
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
#[test]
fn save_as_initial_dir_uses_remembered_existing_directory() {
let temp = TempDir::new("remembered-dir");
let remembered_dir = temp.path().join("remembered");
let fallback_dir = temp.path().join("fallback");
fs::create_dir_all(&remembered_dir).expect("create remembered dir");
fs::create_dir_all(&fallback_dir).expect("create fallback dir");
let state_file = temp.path().join("state").join("save_as_last_dir");
fs::create_dir_all(state_file.parent().expect("state parent")).expect("create state dir");
fs::write(&state_file, remembered_dir.to_string_lossy().as_bytes())
.expect("write state file");
let initial_dir = SketchBoard::save_as_initial_dir(
Some(&state_file),
Some(&fallback_dir.join("screenshot.png")),
);
assert_eq!(initial_dir, Some(remembered_dir));
}
#[test]
fn save_as_initial_dir_falls_back_when_remembered_directory_is_invalid() {
let temp = TempDir::new("invalid-remembered-dir");
let fallback_dir = temp.path().join("fallback");
fs::create_dir_all(&fallback_dir).expect("create fallback dir");
let state_file = temp.path().join("save_as_last_dir");
fs::write(
&state_file,
temp.path().join("missing").to_string_lossy().as_bytes(),
)
.expect("write invalid state file");
let initial_dir = SketchBoard::save_as_initial_dir(
Some(&state_file),
Some(&fallback_dir.join("screenshot.png")),
);
assert_eq!(initial_dir, Some(fallback_dir));
}
#[test]
fn save_as_initial_dir_handles_missing_state_and_output_path() {
let initial_dir = SketchBoard::save_as_initial_dir(None, None);
assert_eq!(initial_dir, None);
}
#[test]
fn remember_save_as_dir_creates_state_file() {
let temp = TempDir::new("remember-save-as-dir");
let saved_dir = temp.path().join("saved");
fs::create_dir_all(&saved_dir).expect("create saved dir");
let state_dir = temp.path().join("state");
fs::create_dir_all(&state_dir).expect("create state dir");
let state_file = state_dir.join("save_as_last_dir");
SketchBoard::write_save_as_last_dir(&state_file, &saved_dir.join("image.png"));
let remembered_dir = fs::read_to_string(state_file).expect("read state file");
assert_eq!(remembered_dir, saved_dir.to_string_lossy());
}
#[test]
fn write_save_as_last_dir_ignores_unwritable_state_path() {
let temp = TempDir::new("unwritable-state-path");
let saved_dir = temp.path().join("saved");
fs::create_dir_all(&saved_dir).expect("create saved dir");
SketchBoard::write_save_as_last_dir(temp.path(), &saved_dir.join("image.png"));
}
}