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, log_result_with_pixbuf};
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),
    PinchStart,
    PinchScale(f32),
    PinchEnd,
    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),
}

// from https://flatuicolors.com/palette/au

#[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,
    //Motion(Vec2D),
}

#[derive(Debug, Clone, Copy)]
pub struct MouseEventMsg {
    pub type_: MouseEventType,
    pub button: MouseButton,
    pub modifier: ModifierType,
    pub screen_pos: Vec2D,
    pub is_touchpad: bool,
    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,
            screen_pos: pos,
            pos,
            is_touchpad: false,
            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_x: f64,
        delta_y: f64,
        modifier: ModifierType,
        is_touchpad: bool,
    ) -> SketchBoardInput {
        SketchBoardInput::InputEvent(InputEvent::Mouse(MouseEventMsg {
            type_: MouseEventType::Scroll,
            button: MouseButton::Middle,
            n_pressed: 0,
            modifier,
            screen_pos: Vec2D::new(delta_x as f32, delta_y as f32),
            pos: Vec2D::new(delta_x as f32, delta_y as f32),
            is_touchpad,
            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.screen_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 => {
                    if me.modifier.contains(ModifierType::CONTROL_MASK) {
                        let mut factor = APP_CONFIG.read().zoom_factor();
                        if me.is_touchpad {
                            factor = 1.0 + (factor - 1.0) / 2.0; // decrease a bit
                        };
                        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),
                            _ => {}
                        }
                    } else {
                        let pan_step_size = if me.is_touchpad {
                            -2.0 // invert for natural scrolling and decrease a bit
                        } else {
                            APP_CONFIG.read().pan_step_size()
                        };
                        renderer.set_drag_offset(me.screen_pos * pan_step_size);
                        renderer.store_last_offset();
                    }
                    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,
    pinch_last_scale: f32,
    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();
                    }
                }
                /* SaveToFileAs runs through a callback, so any further actions need to be triggered
                from the callback rather than further iterating actions here */
                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", true);
                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;
        };

        // TODO: we could support more data types
        if output_filename != "-" && !output_filename.ends_with(".png") {
            log_result(
                "The only supported format is png, but the filename does not end in png",
                true,
            );
            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 == "-" {
            // "-" means stdout
            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}"), true),
            Ok(_) => {
                // Store the filepath for copy-filepath action
                *self.last_saved_filepath.borrow_mut() = Some(output_filename.clone());
                log_result_with_pixbuf(
                    &format!("File saved to '{}'.", output_filename),
                    image.clone(),
                )
            }
        };
    }

    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}"), true),
                        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_with_pixbuf(
                                &format!("File saved to '{}'.", output_filename),
                                pixbuf.clone(),
                            )
                        }
                    };
                }
                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_with_pixbuf("Copied to clipboard.", image.clone());

                // TODO: rethink order and messaging patterns
                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,
        };

        // Copy the filepath to clipboard
        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}"), true),
            Ok(()) => log_result(&format!("Filepath copied to clipboard: {}", filepath), true),
        }
    }

    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_clear_all(&mut self) -> ToolUpdateResult {
        // can't use lazy || here
        if self.deactivate_active_tool() | self.renderer.clear_all() {
            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
    }

    // Toolbars = Tools Toolbar + Style Toolbar
    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::FocusCanvas => {
                self.renderer.grab_focus();
                ToolUpdateResult::Unmodified
            }
            ToolbarEvent::ToolSelected(tool) => {
                // deactivate old tool and save drawable, if any
                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]);
                    }
                    // we handle commit directly and "downgrade" to a simple redraw result
                    deactivate_result = ToolUpdateResult::Redraw;
                }

                // change active tool
                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,
                    }));

                // set sender for tool
                self.active_tool
                    .borrow_mut()
                    .set_sender(sender.input_sender().clone());

                // send style event
                self.active_tool
                    .borrow_mut()
                    .handle_event(ToolEvent::StyleChanged(self.style));

                // send activated event
                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::ClearAll => self.handle_clear_all(),
            ToolbarEvent::ToggleFill => {
                self.style.fill = !self.style.fill;
                self.active_tool
                    .borrow_mut()
                    .handle_event(ToolEvent::StyleChanged(self.style))
            }
            ToolbarEvent::ToggleRoundCaps => {
                self.style.round_caps = !self.style.round_caps;
                self.active_tool
                    .borrow_mut()
                    .handle_event(ToolEvent::StyleChanged(self.style))
            }
            ToolbarEvent::AnnotationSizeFactorChanged(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(),
            ToolbarEvent::ToolCommit => self.active_tool.borrow_mut().handle_deactivated(),
            ToolbarEvent::ToolDismiss => self.active_tool.borrow_mut().handle_dismissed(),
        }
    }

    fn handle_text_commit(
        &self,
        event: TextEventMsg,
        sender: ComponentSender<Self>,
    ) -> ToolUpdateResult {
        match event {
            TextEventMsg::Commit(txt) => {
                // NOTE:
                // If there's an IMContext binded to the controller, single letter-key events will
                // always go through it first, denying a bypass, so the only way we can do single-key
                // bindings is to act upon the IMMulticontext's commit event itself.
                // NOTE:
                // Here we're basically bypassing the IMMulticontext. If the text tool is active
                // and wants text inputs, we're interested in the single-letter keypress as a text character.
                // If not, we parse it as a shortcut event.
                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
                        | gtk::EventControllerScrollFlags::HORIZONTAL,
                    connect_scroll[sender] => move |controller, dx, dy| {
                        let is_touchpad = controller
                            .current_event()
                            .and_then(|event| event.device())
                            .is_some_and(|device| {
                                matches!(device.source(), gtk::gdk::InputSource::Touchpad)
                            });

                        sender.input(SketchBoardInput::new_scroll_event(
                            dx,
                            dy,
                            controller.current_event_state(),
                            is_touchpad,
                        ));
                        relm4::gtk::glib::Propagation::Stop
                    },
                },

                add_controller = gtk::GestureZoom {
                    connect_begin[sender] => move |_, _| {
                        sender.input(SketchBoardInput::PinchStart);
                    },
                    connect_scale_changed[sender] => move |_, scale| {
                        sender.input(SketchBoardInput::PinchScale(scale as f32));
                    },
                    connect_end[sender] => move |_, _| {
                        sender.input(SketchBoardInput::PinchEnd);
                    },
                },

                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) {
        // handle resize ourselves, pass everything else to tool
        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()));

                    // eprintln!("active_tool_result={:?}", active_tool_result);

                    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 does not appear to offer any tracking for this, so
                                we'd have to track the state ourselves. But since the user may
                                just choose to close the inspector window, doing so adds little
                                benefit.

                                Just enable it everytime, and let the user close the window if they
                                so wish.
                                 */
                                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))
                                    }
                                    _ => { /* unreachable */ }
                                }

                                self.renderer.store_last_offset();
                                self.renderer.request_render(&[]);
                                ToolUpdateResult::Unmodified
                            } else if ke.modifier.is_empty() && ke.key == Key::Delete {
                                self.handle_clear_all()
                            } else if ke.modifier.is_empty()
                                && (ke.key == Key::Escape
                                    || ke.key == Key::Return
                                    || ke.key == Key::KP_Enter)
                            {
                                // First, let the tool handle the event. If the tool does nothing, we can do our thing (otherwise require a second keyboard press)
                                // Relying on ToolUpdateResult::Unmodified is probably not a good idea, but it's the only way at the moment. See discussion in #144
                                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 {
                    if let InputEvent::Mouse(me) = &ie
                        && matches!(me.type_, MouseEventType::Click | MouseEventType::BeginDrag)
                    {
                        self.renderer.grab_focus();
                    }

                    ie.handle_event_mouse_input(&self.renderer);
                    let active_tool_result = self
                        .active_tool
                        .borrow_mut()
                        .handle_event(ToolEvent::Input(ie.clone()));

                    // eprintln!("active_tool_result={:?}", active_tool_result);

                    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::PinchStart => {
                self.pinch_last_scale = 1.0;
                ToolUpdateResult::Unmodified
            }
            SketchBoardInput::PinchScale(scale) => {
                if scale.is_finite() && scale > 0.0 && self.pinch_last_scale > 0.0 {
                    let factor = scale / self.pinch_last_scale;
                    if factor.is_finite() && factor > 0.0 {
                        self.renderer.set_pointer_offset_center();
                        self.renderer.set_zoom_scale(factor);
                        self.renderer.request_render(&[]);
                    }
                    self.pinch_last_scale = scale;
                }
                ToolUpdateResult::Unmodified
            }
            SketchBoardInput::PinchEnd => {
                self.pinch_last_scale = 1.0;
                ToolUpdateResult::Unmodified
            }
            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(),
            pinch_last_scale: 1.0,
            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,
        }
    }

    /// Matches one of providen keys. The modifier is not considered.
    /// And the key has more priority over keycode.
    fn is_one_of(&self, key: Key, code: KeyMappingId) -> bool {
        // INFO: on linux the keycode from gtk4 is evdev keycode, so need to match by him if need
        // to use layout-independent shortcuts. And notice that there is subtraction by 8, it's
        // because of x11 compatibility in which the keycodes are in range [8,255]. So need shift
        // them to get correct evdev keycode.
        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"));
    }
}
