diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.vscode/tasks.json b/.vscode/tasks.json index b3e620a..3401289 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,17 +2,16 @@ "version": "2.0.0", "tasks": [ { - "type": "cargo", - "command": "run", + "type": "shell", + "command": "cargo", "args": [ + "run", "--", "--filename", - //"/home/gabm/Pictures/Screenshots/swappy-20230921-054340.png", - //"/home/gabm/Pictures/Wallpaper/torres_1.jpg", - "/home/gabm/Pictures/Screenshots/satty-20231116-10:35:51.png", + "/home/gabm/Pictures/Screenshots/satty-20240109-22:19:08.png", //"--fullscreen", - "--output-filename", - "/tmp/out.png", + //"--output-filename", + //"/tmp/out.png", "--copy-command", "wl-copy", ], diff --git a/Cargo.lock b/Cargo.lock index bf8a308..3b2b8c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,21 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.5" @@ -71,6 +86,12 @@ version = "1.0.77" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9d19de80eff169429ac1e9f48fffb163916b448a44e8e046186232046d9e1f9" +[[package]] +name = "arrayvec" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" + [[package]] name = "async-trait" version = "0.1.75" @@ -177,6 +198,20 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets 0.48.5", +] + [[package]] name = "clap" version = "4.4.11" @@ -252,6 +287,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +[[package]] +name = "core-foundation-sys" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" + [[package]] name = "crc32fast" version = "1.3.2" @@ -713,6 +754,40 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" +[[package]] +name = "hex_color" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d37f101bf4c633f7ca2e4b5e136050314503dd198e78e325ea602c327c484ef0" +dependencies = [ + "arrayvec", + "rand", + "serde", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8326b86b6cff230b97d0d312a6c40a60726df3332e721f72a1b035f451663b20" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "indexmap" version = "2.1.0" @@ -852,6 +927,15 @@ dependencies = [ "getrandom", ] +[[package]] +name = "num-traits" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c" +dependencies = [ + "autocfg", +] + [[package]] name = "num_cpus" version = "1.16.0" @@ -991,6 +1075,12 @@ version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69d3587f8a9e599cc7ec2c00e331f71c4e69a5f9a4b8a6efd5b07466b9736f9a" +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -1053,6 +1143,36 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -1142,15 +1262,22 @@ name = "satty" version = "0.8.3" dependencies = [ "anyhow", + "chrono", "clap", "clap_complete", "clap_complete_fig", "clap_complete_nushell", "gdk-pixbuf", + "hex_color", "pangocairo", "relm4", "relm4-icons", + "serde", + "serde_derive", + "thiserror", "tokio", + "toml", + "xdg", ] [[package]] @@ -1552,6 +1679,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -1693,6 +1829,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "xdg" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213b7324336b53d2414b2db8537e56544d981803139155afa84f76eeebb7a546" + [[package]] name = "zvariant" version = "3.15.0" diff --git a/Cargo.toml b/Cargo.toml index a31c192..3e249dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "satty" version = "0.8.3" edition = "2021" authors = ["Matthias Gabriel "] -description = "A screenshot annotation tool inspired by Swappy and Flameshot." +description = "Modern Screenshot Annotation. A Screenshot Annotation Tool inspired by Swappy and Flameshot." homepage = "https://github.com/gabm/satty" repository = "https://github.com/gabm/satty" license = "MPL-2.0" @@ -25,10 +25,19 @@ gdk-pixbuf = "0.17.2" # error handling anyhow = "1.0" +thiserror = "1.0" # command line clap = { version = "4.4.10", features = ["derive"] } +# configuration file +xdg = "^2.5" +toml = "0.8.8" +serde = "1.0" +serde_derive = "1.0" +hex_color = {version = "3", features = ["serde"]} +chrono = "0.4.31" + [dependencies.relm4-icons] version = "0.6.0" diff --git a/README.md b/README.md index 606e8b4..4ececdc 100644 --- a/README.md +++ b/README.md @@ -51,27 +51,60 @@ You can download a prebuilt binary for x86-64 on the [Satty Releases](https://gi Start by providing a filename or a screenshot via stdin and annotate using the available tools. Save to clipboard or file when finished. Tools and Interface have been kept simple. -All configuration is done via the command line interface: +All configuration is done either at the config file in `XDG_CONFIG_DIR/.config/satty/config.toml` or via the command line interface. In case both are specified, the command line options always override the configuration file. + +### Configuration File + +```toml +[general] +# Start Satty in fullscreen mode +fullscreen = true +# Exit directly after copy/save action +early-exit = true +# Select the tool on startup [possible values: pointer, crop, line, arrow, rectangle, text, marker, blur, brush] +initial-tool = "brush" +# Configure the command to be called on copy, for example `wl-copy` +copy-command = "wl-copy" +# Increase or decrease the size of the annotations +annotation-size-factor = 2 +# Filename to use for saving action. Omit to disable saving to file. Might contain format specifiers: https://docs.rs/chrono/latest/chrono/format/strftime/index.html +output-filename = "/tmp/test-%Y-%m-%d_%H:%M:%S.png" + +# custom colours for the colour palette +[color-palette] +first= "#00ffff" +second= "#a52a2a" +third= "#dc143c" +fourth= "#ff1493" +fifth= "#ffd700" +custom= "#008000" +``` + +### Command Line ```sh ยป satty --help -A screenshot annotation tool inspired by Swappy and Flameshot. +Modern Screenshot Annotation. A Screenshot Annotation Tool inspired by Swappy and Flameshot. Usage: satty [OPTIONS] --filename Options: + -c, --config + Path to the config file. Otherwise will be read from XDG_CONFIG_DIR/satty/config.toml -f, --filename Path to input image or '-' to read from stdin --fullscreen Start Satty in fullscreen mode --output-filename - Filename to use for saving action, omit to disable saving to file + Filename to use for saving action. Omit to disable saving to file. Might contain format specifiers: https://docs.rs/chrono/latest/chrono/format/strftime/index.html --early-exit Exit directly after copy/save action - --init-tool - Select the tool on startup [default: pointer] [possible values: pointer, crop, line, arrow, rectangle, text, marker, blur, brush] + --initial-tool + Select the tool on startup [aliases: init-tool] [possible values: pointer, crop, line, arrow, rectangle, text, marker, blur, brush] --copy-command Configure the command to be called on copy, for example `wl-copy` + --annotation-size-factor + Increase or decrease the size of the annotations -h, --help Print help -V, --version diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..b73c291 --- /dev/null +++ b/config.toml @@ -0,0 +1,22 @@ +[general] +# Start Satty in fullscreen mode +fullscreen = true +# Exit directly after copy/save action +early-exit = true +# Select the tool on startup [possible values: pointer, crop, line, arrow, rectangle, text, marker, blur, brush] +initial-tool = "brush" +# Configure the command to be called on copy, for example `wl-copy` +copy-command = "wl-copy" +# Increase or decrease the size of the annotations +annotation-size-factor = 2 +# Filename to use for saving action. Omit to disable saving to file. Might contain format specifiers: https://docs.rs/chrono/latest/chrono/format/strftime/index.html +output-filename = "/tmp/test-%Y-%m-%d_%H:%M:%S.png" + +# custom colours for the colour palette +[color-palette] +first= "#00ffff" +second= "#a52a2a" +third= "#dc143c" +fourth= "#ff1493" +fifth= "#ffd700" +custom= "#008000" diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..a64839c --- /dev/null +++ b/flake.lock @@ -0,0 +1,130 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1705309234, + "narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_2": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1705309234, + "narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1706550542, + "narHash": "sha256-UcsnCG6wx++23yeER4Hg18CXWbgNpqNXcHIo5/1Y+hc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "97b17f32362e475016f942bbdfda4a4a72a8a652", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1707205916, + "narHash": "sha256-fmRJilYGlB7VCt3XsdYxrA0u8e/K84O5xYucerUY0iM=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "8cc79aa39bbc6eaedaf286ae655b224c71e02907", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "flake-utils": "flake-utils_2", + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1706753617, + "narHash": "sha256-ZKqTFzhFwSWFEpQTJ0uXnfJBs5Y/po9/8TK4bzssdbs=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "58be43ae223034217ea1bd58c73210644031b687", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..cc5d463 --- /dev/null +++ b/flake.nix @@ -0,0 +1,41 @@ +{ + description = "A basic Rust devshell for NixOS users developing gtk/libadwaita apps"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + rust-overlay.url = "github:oxalica/rust-overlay"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, rust-overlay, flake-utils, ... }: + flake-utils.lib.eachDefaultSystem (system: + let + overlays = [ (import rust-overlay) ]; + pkgs = import nixpkgs { + inherit system overlays; + }; + + in + with pkgs; + { + devShells.default = mkShell { + buildInputs = [ + pkg-config + + gtk4 + wrapGAppsHook4 # this is needed for relm4-icons to properly load after gtk::init() + libadwaita + + (rust-bin.stable.latest.default.override + { + extensions = [ "rust-src" ]; + }) + ]; + + shellHook = '' + export GSETTINGS_SCHEMA_DIR=${glib.getSchemaPath gtk4} + ''; + }; + } + ); +} diff --git a/src/command_line.rs b/src/command_line.rs index 539172e..023d957 100644 --- a/src/command_line.rs +++ b/src/command_line.rs @@ -3,6 +3,10 @@ use clap::{Parser, ValueEnum}; #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] pub struct CommandLine { + /// Path to the config file. Otherwise will be read from XDG_CONFIG_DIR/satty/config.toml + #[arg(short, long)] + pub config: Option, + /// Path to input image or '-' to read from stdin #[arg(short, long)] pub filename: String, @@ -11,7 +15,8 @@ pub struct CommandLine { #[arg(long)] pub fullscreen: bool, - /// Filename to use for saving action, omit to disable saving to file + /// Filename to use for saving action. Omit to disable saving to file. Might contain format + /// specifiers: . #[arg(long)] pub output_filename: Option, @@ -20,18 +25,16 @@ pub struct CommandLine { pub early_exit: bool, /// Select the tool on startup - #[arg(long, default_value_t, value_name = "TOOL")] - pub init_tool: Tools, + #[arg(long, value_name = "TOOL", visible_alias = "init-tool")] + pub initial_tool: Option, /// Configure the command to be called on copy, for example `wl-copy` #[arg(long)] pub copy_command: Option, -} -impl CommandLine { - pub fn do_parse() -> Self { - Self::parse() - } + /// Increase or decrease the size of the annotations + #[arg(long)] + pub annotation_size_factor: Option, } #[derive(Debug, Clone, Copy, Default, ValueEnum)] diff --git a/src/configuration.rs b/src/configuration.rs new file mode 100644 index 0000000..978f5d9 --- /dev/null +++ b/src/configuration.rs @@ -0,0 +1,293 @@ +use std::{ + fs, + io::{self, Write}, + path::Path, +}; + +use clap::Parser; +use hex_color::HexColor; +use relm4::SharedState; +use serde_derive::Deserialize; +use thiserror::Error; +use xdg::{BaseDirectories, BaseDirectoriesError}; + +use crate::{command_line::CommandLine, style::Color, tools::Tools}; + +pub static APP_CONFIG: SharedState = SharedState::new(); + +#[derive(Error, Debug)] +enum ConfigurationFileError { + #[error("XDG context error: {0}")] + Xdg(#[from] BaseDirectoriesError), + + #[error("Error reading file: {0}")] + ReadFile(#[from] io::Error), + + #[error("Decoding toml failed: {0}")] + TomlDecoding(#[from] toml::de::Error), +} + +pub struct Configuration { + input_filename: String, + output_filename: Option, + fullscreen: bool, + early_exit: bool, + initial_tool: Tools, + copy_command: Option, + annotation_size_factor: f64, + color_palette: ColorPalette, +} + +pub struct ColorPalette { + first: Color, + second: Color, + third: Color, + fourth: Color, + fifth: Color, + custom: Color, +} + +impl ColorPalette { + pub fn first(&self) -> Color { + self.first + } + + pub fn second(&self) -> Color { + self.second + } + + pub fn third(&self) -> Color { + self.third + } + + pub fn fourth(&self) -> Color { + self.fourth + } + + pub fn fifth(&self) -> Color { + self.fifth + } + + pub fn custom(&self) -> Color { + self.custom + } + + fn merge(&mut self, file_palette: ColorPaletteFile) { + if let Some(v) = file_palette.first { + self.first = v.into(); + } + if let Some(v) = file_palette.second { + self.second = v.into(); + } + if let Some(v) = file_palette.third { + self.third = v.into(); + } + if let Some(v) = file_palette.fourth { + self.fourth = v.into(); + } + if let Some(v) = file_palette.fifth { + self.fifth = v.into(); + } + if let Some(v) = file_palette.custom { + self.custom = v.into(); + } + } +} + +impl Configuration { + pub fn load() { + // parse commandline options and exit if error + let command_line = match CommandLine::try_parse() { + Ok(cmd) => cmd, + Err(e) => e.exit(), + }; + + // read configuration file and exit on error + let file = match ConfigurationFile::try_read(&command_line.config) { + Ok(c) => c, + Err(e) => { + eprintln!("Error reading config file: {e}"); + + // swallow broken pipes + let _ = std::io::stdout().lock().flush(); + let _ = std::io::stderr().lock().flush(); + + // exit + std::process::exit(3); + } + }; + + APP_CONFIG.write().merge(file, command_line); + } + fn merge_general(&mut self, general: ConfiguationFileGeneral) { + if let Some(v) = general.fullscreen { + self.fullscreen = v; + } + if let Some(v) = general.early_exit { + self.early_exit = v; + } + if let Some(v) = general.initial_tool { + self.initial_tool = v; + } + if let Some(v) = general.copy_command { + self.copy_command = Some(v); + } + if let Some(v) = general.output_filename { + self.output_filename = Some(v); + } + if let Some(v) = general.annotation_size_factor { + self.annotation_size_factor = v; + } + } + fn merge(&mut self, file: Option, command_line: CommandLine) { + // input_filename is required and needs to be overwritten + self.input_filename = command_line.filename; + + // overwrite with all specified values from config file + if let Some(file) = file { + if let Some(general) = file.general { + self.merge_general(general); + } + if let Some(v) = file.color_palette { + self.color_palette.merge(v); + } + } + + // overwrite with all specified values from command line + if command_line.fullscreen { + self.fullscreen = command_line.fullscreen; + } + if command_line.early_exit { + self.early_exit = command_line.early_exit; + } + if let Some(v) = command_line.initial_tool { + self.initial_tool = v.into(); + } + if let Some(v) = command_line.copy_command { + self.copy_command = Some(v); + } + if let Some(v) = command_line.output_filename { + self.output_filename = Some(v); + } + if let Some(v) = command_line.annotation_size_factor { + self.annotation_size_factor = v; + } + } + + pub fn early_exit(&self) -> bool { + self.early_exit + } + + pub fn initial_tool(&self) -> Tools { + self.initial_tool + } + + pub fn copy_command(&self) -> Option<&String> { + self.copy_command.as_ref() + } + + pub fn fullscreen(&self) -> bool { + self.fullscreen + } + + pub fn output_filename(&self) -> Option<&String> { + self.output_filename.as_ref() + } + + pub fn input_filename(&self) -> &str { + self.input_filename.as_ref() + } + + pub fn annotation_size_factor(&self) -> f64 { + self.annotation_size_factor + } + + pub fn color_palette(&self) -> &ColorPalette { + &self.color_palette + } +} + +impl Default for Configuration { + fn default() -> Self { + Self { + input_filename: String::new(), + output_filename: None, + fullscreen: false, + early_exit: false, + initial_tool: Tools::Pointer, + copy_command: None, + annotation_size_factor: 1.0f64, + color_palette: ColorPalette::default(), + } + } +} + +impl Default for ColorPalette { + fn default() -> Self { + Self { + first: Color::orange(), + second: Color::red(), + third: Color::green(), + fourth: Color::blue(), + fifth: Color::cove(), + custom: Color::pink(), + } + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +struct ConfigurationFile { + general: Option, + color_palette: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +struct ConfiguationFileGeneral { + fullscreen: Option, + early_exit: Option, + initial_tool: Option, + copy_command: Option, + annotation_size_factor: Option, + output_filename: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +struct ColorPaletteFile { + first: Option, + second: Option, + third: Option, + fourth: Option, + fifth: Option, + custom: Option, +} + +impl ConfigurationFile { + fn try_read( + specified_path: &Option, + ) -> Result, ConfigurationFileError> { + match specified_path { + None => Self::try_read_xdg(), + Some(p) => Self::try_read_path(p), + } + } + + fn try_read_xdg() -> Result, ConfigurationFileError> { + let dirs = BaseDirectories::with_prefix("satty")?; + let config_file_path = dirs.get_config_file("config.toml"); + if !config_file_path.exists() { + return Ok(None); + } + + Self::try_read_path(config_file_path) + } + + fn try_read_path>( + path: P, + ) -> Result, ConfigurationFileError> { + let content = fs::read_to_string(path)?; + Ok(Some(toml::from_str::(&content)?)) + } +} diff --git a/src/main.rs b/src/main.rs index 42e152a..ea805ca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ use std::io::Read; use std::{io, time::Duration}; -use command_line::CommandLine; +use configuration::{Configuration, APP_CONFIG}; use gdk_pixbuf::{Pixbuf, PixbufLoader}; use gtk::prelude::*; use relm4::gtk::gdk::Rectangle; @@ -18,6 +18,7 @@ use ui::toast::Toast; use ui::toolbars::{StyleToolbar, ToolsToolbar}; mod command_line; +mod configuration; mod math; mod renderer; mod sketch_board; @@ -25,21 +26,11 @@ mod style; mod tools; mod ui; -use crate::sketch_board::SketchBoardConfig; use crate::sketch_board::{KeyEventMsg, SketchBoard, SketchBoardInput}; -use crate::ui::toolbars::ToolsToolbarConfig; - -struct AppConfig { - image: Pixbuf, - args: CommandLine, -} - struct App { - original_image_width: i32, - original_image_height: i32, + image_dimensions: (i32, i32), sketch_board: Controller, - initially_fullscreen: bool, toast: Controller, tools_toolbar: Controller, style_toolbar: Controller, @@ -68,7 +59,7 @@ impl App { let monitor_size = match Self::get_monitor_size(root) { Some(s) => s, None => { - root.set_default_size(self.original_image_width, self.original_image_height); + root.set_default_size(self.image_dimensions.0, self.image_dimensions.1); return; } }; @@ -76,14 +67,14 @@ impl App { let reduced_monitor_width = monitor_size.width() as f64 * 0.8; let reduced_monitor_height = monitor_size.height() as f64 * 0.8; - let image_width = self.original_image_width as f64; - let image_height = self.original_image_height as f64; + let image_width = self.image_dimensions.0 as f64; + let image_height = self.image_dimensions.1 as f64; // create a window that uses 80% of the available space max // if necessary, scale down image if reduced_monitor_width > image_width && reduced_monitor_height > image_height { // set window to exact size - root.set_default_size(self.original_image_width, self.original_image_height); + root.set_default_size(self.image_dimensions.0, self.image_dimensions.1); } else { // scale down and use windowed mode let aspect_ratio = image_width / image_height; @@ -103,7 +94,7 @@ impl App { root.set_resizable(false); - if self.initially_fullscreen { + if APP_CONFIG.read().fullscreen() { root.fullscreen(); } @@ -145,7 +136,7 @@ impl App { #[relm4::component] impl Component for App { - type Init = AppConfig; + type Init = Pixbuf; type Input = AppInput; type Output = (); type CommandOutput = AppCommandOutput; @@ -197,39 +188,30 @@ impl Component for App { } fn init( - config: Self::Init, + image: Self::Init, root: &Self::Root, sender: ComponentSender, ) -> ComponentParts { Self::apply_style(); + let image_dimensions = (image.width(), image.height()); + // Toast let toast = Toast::builder().launch(3000).detach(); // SketchBoard - let sketch_board_config = SketchBoardConfig { - original_image: config.image.clone(), - output_filename: config.args.output_filename.clone(), - copy_command: config.args.copy_command.clone(), - early_exit: config.args.early_exit, - init_tool: config.args.init_tool.into(), - }; - - let sketch_board = SketchBoard::builder().launch(sketch_board_config).forward( - toast.sender(), - |t| match t { - SketchBoardOutput::ShowToast(msg) => ui::toast::ToastMessage::Show(msg), - }, - ); + let sketch_board = + SketchBoard::builder() + .launch(image) + .forward(toast.sender(), |t| match t { + SketchBoardOutput::ShowToast(msg) => ui::toast::ToastMessage::Show(msg), + }); let sketch_board_sender = sketch_board.sender().clone(); // Toolbars let tools_toolbar = ToolsToolbar::builder() - .launch(ToolsToolbarConfig { - show_save_button: config.args.output_filename.is_some(), - init_tool: config.args.init_tool.into(), - }) + .launch(()) .forward(sketch_board.sender(), SketchBoardInput::ToolbarEvent); let style_toolbar = StyleToolbar::builder() @@ -238,13 +220,11 @@ impl Component for App { // Model let model = App { - original_image_width: config.image.width(), - original_image_height: config.image.height(), sketch_board, - initially_fullscreen: config.args.fullscreen, toast, tools_toolbar, style_toolbar, + image_dimensions, }; let widgets = view_output!(); @@ -253,12 +233,9 @@ impl Component for App { } } -fn load_image(filename: &str) -> Result { - Pixbuf::from_file(filename).context("couldn't load image") -} - -fn run_satty(args: CommandLine) -> Result<()> { - let image = if args.filename == "-" { +fn run_satty() -> Result<()> { + let config = APP_CONFIG.read(); + let image = if config.input_filename() == "-" { let mut buf = Vec::::new(); io::stdin().lock().read_to_end(&mut buf)?; let pb_loader = PixbufLoader::new(); @@ -268,21 +245,24 @@ fn run_satty(args: CommandLine) -> Result<()> { .pixbuf() .ok_or(anyhow!("Conversion to Pixbuf failed"))? } else { - load_image(&args.filename)? + Pixbuf::from_file(config.input_filename()).context("couldn't load image")? }; let app = RelmApp::new("com.gabm.satty").with_args(vec![]); relm4_icons::initialize_icons(); - app.run::(AppConfig { args, image }); + app.run::(image); Ok(()) } fn main() -> Result<()> { - let args = CommandLine::do_parse(); + // populate the APP_CONFIG from commandline and + // config file. this might exit, if an error occured. + Configuration::load(); - match run_satty(args) { + // run the application + match run_satty() { Err(e) => { - println!("Error: {e}"); + eprintln!("Error: {e}"); Err(e) } Ok(v) => Ok(v), diff --git a/src/sketch_board.rs b/src/sketch_board.rs index 3e1524a..f15676c 100644 --- a/src/sketch_board.rs +++ b/src/sketch_board.rs @@ -1,21 +1,23 @@ use anyhow::anyhow; + +use gdk_pixbuf::Pixbuf; use std::cell::RefCell; use std::fs; use std::io::Write; use std::process::{Command, Stdio}; use std::rc::Rc; -use gdk_pixbuf::Pixbuf; use gtk::prelude::*; use relm4::drawing::DrawHandler; use relm4::gtk::gdk::{DisplayManager, Key, MemoryTexture, ModifierType}; use relm4::{gtk, Component, ComponentParts, ComponentSender}; +use crate::configuration::APP_CONFIG; use crate::math::Vec2D; use crate::renderer::Renderer; use crate::style::Style; -use crate::tools::{Tool, ToolEvent, ToolUpdateResult, Tools, ToolsManager}; +use crate::tools::{Tool, ToolEvent, ToolUpdateResult, ToolsManager}; use crate::ui::toolbars::ToolbarEvent; #[derive(Debug, Clone, Copy)] @@ -112,32 +114,23 @@ impl InputEvent { } } -pub struct SketchBoardConfig { - pub original_image: Pixbuf, - pub output_filename: Option, - pub copy_command: Option, - pub early_exit: bool, - pub init_tool: Tools, -} - pub struct SketchBoard { handler: DrawHandler, active_tool: Rc>, tools: ToolsManager, style: Style, - config: SketchBoardConfig, renderer: Renderer, + image_dimensions: Vec2D, scale_factor: f64, } impl SketchBoard { pub fn calculate_scale_factor(&mut self, new_dimensions: Vec2D) { - let aspect_ratio = - self.config.original_image.width() as f64 / self.config.original_image.height() as f64; + let aspect_ratio = self.image_dimensions.x / self.image_dimensions.y; self.scale_factor = if new_dimensions.x / aspect_ratio <= new_dimensions.y { - new_dimensions.x / aspect_ratio / self.config.original_image.height() as f64 + new_dimensions.x / aspect_ratio / self.image_dimensions.y } else { - new_dimensions.y * aspect_ratio / self.config.original_image.width() as f64 + new_dimensions.y * aspect_ratio / self.image_dimensions.x }; } fn refresh_screen(&mut self) { @@ -151,14 +144,17 @@ impl SketchBoard { } fn handle_save(&self, sender: ComponentSender) { - let output_filename = match &self.config.output_filename { + let output_filename = match APP_CONFIG.read().output_filename() { None => { println!("No Output filename specified!"); return; } - Some(o) => o, + Some(o) => o.clone(), }; + // run the output filename by "chrono date format" + let output_filename = format!("{}", chrono::Local::now().format(&output_filename)); + if !output_filename.ends_with(".png") { let msg = "The only supported format is png, but the filename does not end in png"; println!("{msg}"); @@ -178,9 +174,9 @@ impl SketchBoard { let data = texture.save_to_png_bytes(); - let msg = match fs::write(output_filename, data) { + let msg = match fs::write(&output_filename, data) { Err(e) => format!("Error while saving file: {e}"), - Ok(_) => format!("File saved to '{}'.", output_filename), + Ok(_) => format!("File saved to '{}'.", &output_filename), }; sender @@ -226,7 +222,7 @@ impl SketchBoard { } }; - let result = if let Some(command) = &self.config.copy_command { + let result = if let Some(command) = APP_CONFIG.read().copy_command() { self.save_to_external_process(&texture, command) } else { self.save_to_clipboard(&texture) @@ -308,14 +304,14 @@ impl SketchBoard { } ToolbarEvent::SaveFile => { self.handle_save(sender); - if self.config.early_exit { + if APP_CONFIG.read().early_exit() { relm4::main_application().quit(); } ToolUpdateResult::Unmodified } ToolbarEvent::CopyClipboard => { self.handle_copy_clipboard(sender); - if self.config.early_exit { + if APP_CONFIG.read().early_exit() { relm4::main_application().quit(); } ToolUpdateResult::Unmodified @@ -331,7 +327,7 @@ impl Component for SketchBoard { type CommandOutput = (); type Input = SketchBoardInput; type Output = SketchBoardOutput; - type Init = SketchBoardConfig; + type Init = Pixbuf; view! { gtk::Box { @@ -401,13 +397,13 @@ impl Component for SketchBoard { self.handle_redo() } else if ke.key == Key::s && ke.modifier == ModifierType::CONTROL_MASK { self.handle_save(sender); - if self.config.early_exit { + if APP_CONFIG.read().early_exit() { relm4::main_application().quit(); } ToolUpdateResult::Unmodified } else if ke.key == Key::c && ke.modifier == ModifierType::CONTROL_MASK { self.handle_copy_clipboard(sender); - if self.config.early_exit { + if APP_CONFIG.read().early_exit() { relm4::main_application().quit(); } ToolUpdateResult::Unmodified @@ -444,19 +440,20 @@ impl Component for SketchBoard { } fn init( - config: Self::Init, + image: Self::Init, root: &Self::Root, sender: ComponentSender, ) -> ComponentParts { + let config = APP_CONFIG.read(); let tools = ToolsManager::new(); let model = Self { + image_dimensions: Vec2D::new(image.width() as f64, image.height() as f64), handler: DrawHandler::new(), - active_tool: tools.get(&config.init_tool), + active_tool: tools.get(&config.initial_tool()), style: Style::default(), - renderer: Renderer::new(config.original_image.clone(), tools.get_crop_tool()), + renderer: Renderer::new(image, tools.get_crop_tool()), scale_factor: 1.0, - config, tools, }; diff --git a/src/style.rs b/src/style.rs index 7860cc0..ab30c9c 100644 --- a/src/style.rs +++ b/src/style.rs @@ -4,9 +4,12 @@ use gdk_pixbuf::{ glib::{FromVariant, Variant, VariantTy}, prelude::{StaticVariantType, ToVariant}, }; +use hex_color::HexColor; use pangocairo::pango::SCALE; use relm4::gtk::gdk::RGBA; +use crate::configuration::APP_CONFIG; + #[derive(Clone, Copy, Debug, Default)] pub struct Style { pub color: Color, @@ -31,7 +34,7 @@ pub enum Size { impl Default for Color { fn default() -> Self { - Self::orange() + APP_CONFIG.read().color_palette().first() } } @@ -121,6 +124,12 @@ impl From for RGBA { } } +impl From for Color { + fn from(value: HexColor) -> Self { + Self::new(value.r, value.g, value.b, value.a) + } +} + impl StaticVariantType for Size { fn static_variant_type() -> Cow<'static, VariantTy> { Cow::Borrowed(VariantTy::UINT32) @@ -146,26 +155,31 @@ impl FromVariant for Size { impl Size { pub fn to_text_size(self) -> i32 { + let size_factor = APP_CONFIG.read().annotation_size_factor(); + match self { - Size::Small => 12 * SCALE, - Size::Medium => 18 * SCALE, - Size::Large => 32 * SCALE, + Size::Small => (12.0 * SCALE as f64 * size_factor) as i32, + Size::Medium => (18.0 * SCALE as f64 * size_factor) as i32, + Size::Large => (32.0 * SCALE as f64 * size_factor) as i32, } } pub fn to_line_width(self) -> f64 { + let size_factor = APP_CONFIG.read().annotation_size_factor(); + match self { - Size::Small => 2.0, - Size::Medium => 3.0, - Size::Large => 5.0, + Size::Small => 2.0 * size_factor, + Size::Medium => 3.0 * size_factor, + Size::Large => 5.0 * size_factor, } } pub fn to_blur_factor(self) -> f64 { + let size_factor = APP_CONFIG.read().annotation_size_factor(); match self { - Size::Small => 6.0, - Size::Medium => 10.0, - Size::Large => 20.0, + Size::Small => 6.0 * size_factor, + Size::Medium => 10.0 * size_factor, + Size::Large => 20.0 * size_factor, } } } diff --git a/src/tools/mod.rs b/src/tools/mod.rs index c8ed2c1..ff511bc 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -7,6 +7,7 @@ use gdk_pixbuf::{ }; use pangocairo::cairo::ImageSurface; use relm4::gtk::cairo::Context; +use serde_derive::Deserialize; use crate::{ command_line, @@ -111,7 +112,8 @@ pub use text::TextTool; use self::{brush::BrushTool, marker::MarkerTool, pointer::PointerTool}; -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash)] +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, Deserialize)] +#[serde(rename_all = "lowercase")] pub enum Tools { Pointer = 0, Crop = 1, diff --git a/src/ui/toolbars.rs b/src/ui/toolbars.rs index b0f0fd4..10afa9d 100644 --- a/src/ui/toolbars.rs +++ b/src/ui/toolbars.rs @@ -1,6 +1,7 @@ use std::borrow::Cow; use crate::{ + configuration::APP_CONFIG, style::{Color, Size}, tools::Tools, }; @@ -12,18 +13,11 @@ use gdk_pixbuf::{ }; use relm4::{ actions::{ActionablePlus, RelmAction, RelmActionGroup}, - gtk::{prelude::*, Align, ColorChooserDialog, ResponseType}, + gtk::{gdk::RGBA, prelude::*, Align, ColorChooserDialog, ResponseType, Window}, prelude::*, }; -pub struct ToolsToolbar { - config: ToolsToolbarConfig, -} - -pub struct ToolsToolbarConfig { - pub show_save_button: bool, - pub init_tool: Tools, -} +pub struct ToolsToolbar {} pub struct StyleToolbar { custom_color: Color, @@ -60,7 +54,7 @@ fn create_icon(color: Color) -> gtk::Image { #[relm4::component(pub)] impl SimpleComponent for ToolsToolbar { - type Init = ToolsToolbarConfig; + type Init = (); type Input = (); type Output = ToolbarEvent; @@ -183,24 +177,24 @@ impl SimpleComponent for ToolsToolbar { set_tooltip: "Save (Ctrl+S)", connect_clicked[sender] => move |_| {sender.output_sender().emit(ToolbarEvent::SaveFile);}, - set_visible: model.config.show_save_button + set_visible: APP_CONFIG.read().output_filename().is_some() }, }, } fn init( - config: Self::Init, + _: Self::Init, root: &Self::Root, sender: ComponentSender, ) -> ComponentParts { - let model = ToolsToolbar { config }; + let model = ToolsToolbar {}; let widgets = view_output!(); // Tools Action for selecting tools let sender_tmp: ComponentSender = sender.clone(); let tool_action: RelmAction = RelmAction::new_stateful_with_target_value( - &model.config.init_tool, + &APP_CONFIG.read().initial_tool(), move |_, state, value| { *state = value; sender_tmp @@ -219,28 +213,33 @@ impl SimpleComponent for ToolsToolbar { #[derive(Debug, Copy, Clone)] pub enum ColorButtons { - Orange = 0, - Red = 1, - Green = 2, - Blue = 3, - Cove = 4, + First = 0, + Second = 1, + Third = 2, + Fourth = 3, + Fith = 4, Custom = 5, } impl StyleToolbar { - fn show_color_dialog(&self, sender: ComponentSender) { - let current_color = Some(self.custom_color.into()); + fn show_color_dialog(&self, sender: ComponentSender, root: Option) { + let current_color: RGBA = self.custom_color.into(); relm4::spawn_local(async move { - let dialog = ColorChooserDialog::builder() + let mut builder = ColorChooserDialog::builder() .modal(true) .title("Choose Color") .hide_on_close(true) - .build(); - dialog.set_use_alpha(true); - if let Some(color) = current_color.as_ref() { - dialog.set_rgba(color); + .rgba(¤t_color); + + if let Some(w) = root { + builder = builder.transient_for(&w); } + // build dialog and configure further + let dialog = builder.build(); + dialog.set_use_alpha(true); + + // set callback for result let dialog_copy = dialog.clone(); dialog.connect_response(move |_, r| { if r == ResponseType::Ok { @@ -255,12 +254,13 @@ impl StyleToolbar { } fn map_button_to_color(&self, button: ColorButtons) -> Color { + let config = APP_CONFIG.read(); match button { - ColorButtons::Orange => Color::orange(), - ColorButtons::Red => Color::red(), - ColorButtons::Green => Color::green(), - ColorButtons::Blue => Color::blue(), - ColorButtons::Cove => Color::cove(), + ColorButtons::First => config.color_palette().first(), + ColorButtons::Second => config.color_palette().second(), + ColorButtons::Third => config.color_palette().third(), + ColorButtons::Fourth => config.color_palette().fourth(), + ColorButtons::Fith => config.color_palette().fifth(), ColorButtons::Custom => self.custom_color, } } @@ -286,46 +286,41 @@ impl Component for StyleToolbar { set_focusable: false, set_hexpand: false, - create_icon(Color::orange()), + create_icon(APP_CONFIG.read().color_palette().first()), - set_tooltip: "Orange", - ActionablePlus::set_action::: ColorButtons::Orange, + ActionablePlus::set_action::: ColorButtons::First, }, gtk::ToggleButton { set_focusable: false, set_hexpand: false, - create_icon(Color::red()), + create_icon(APP_CONFIG.read().color_palette().second()), - set_tooltip: "Red", - ActionablePlus::set_action::: ColorButtons::Red, + ActionablePlus::set_action::: ColorButtons::Second, }, gtk::ToggleButton { set_focusable: false, set_hexpand: false, - create_icon(Color::green()), + create_icon(APP_CONFIG.read().color_palette().third()), - set_tooltip: "Green", - ActionablePlus::set_action::: ColorButtons::Green, + ActionablePlus::set_action::: ColorButtons::Third, }, gtk::ToggleButton { set_focusable: false, set_hexpand: false, - create_icon(Color::blue()), + create_icon(APP_CONFIG.read().color_palette().fourth()), - set_tooltip: "Blue", - ActionablePlus::set_action::: ColorButtons::Blue + ActionablePlus::set_action::: ColorButtons::Fourth }, gtk::ToggleButton { set_focusable: false, set_hexpand: false, - create_icon(Color::cove()), - set_tooltip: "Cove", + create_icon(APP_CONFIG.read().color_palette().fifth()), - ActionablePlus::set_action::: ColorButtons::Cove, + ActionablePlus::set_action::: ColorButtons::Fith, }, gtk::Separator {}, gtk::ToggleButton { @@ -336,7 +331,6 @@ impl Component for StyleToolbar { #[watch] set_from_pixbuf: Some(&model.custom_color_pixbuf) }, - set_tooltip: "Custom color", ActionablePlus::set_action::: ColorButtons::Custom, }, gtk::Button { @@ -378,10 +372,10 @@ impl Component for StyleToolbar { }, } - fn update(&mut self, message: Self::Input, sender: ComponentSender, _root: &Self::Root) { + fn update(&mut self, message: Self::Input, sender: ComponentSender, root: &Self::Root) { match message { StyleToolbarInput::ShowColorDialog => { - self.show_color_dialog(sender); + self.show_color_dialog(sender, root.toplevel_window()); } StyleToolbarInput::ColorDialogFinished(color) => { if let Some(color) = color { @@ -414,7 +408,7 @@ impl Component for StyleToolbar { // Color Action for selecting colors let sender_tmp: ComponentSender = sender.clone(); let color_action: RelmAction = RelmAction::new_stateful_with_target_value( - &ColorButtons::Orange, + &ColorButtons::First, move |_, state, value| { *state = value; @@ -432,7 +426,7 @@ impl Component for StyleToolbar { .emit(ToolbarEvent::SizeSelected(*state)); }); - let custom_color = Color::pink(); + let custom_color = APP_CONFIG.read().color_palette().custom(); let custom_color_pixbuf = create_icon_pixbuf(custom_color); // create model @@ -489,11 +483,11 @@ impl ToVariant for ColorButtons { impl FromVariant for ColorButtons { fn from_variant(variant: &Variant) -> Option { ::from_variant(variant).and_then(|v| match v { - 0 => Some(ColorButtons::Orange), - 1 => Some(ColorButtons::Red), - 2 => Some(ColorButtons::Green), - 3 => Some(ColorButtons::Blue), - 4 => Some(ColorButtons::Cove), + 0 => Some(ColorButtons::First), + 1 => Some(ColorButtons::Second), + 2 => Some(ColorButtons::Third), + 3 => Some(ColorButtons::Fourth), + 4 => Some(ColorButtons::Fith), 5 => Some(ColorButtons::Custom), _ => None, })