├── .envrc ├── src ├── ui │ └── mod.rs ├── ime │ ├── mod.rs │ ├── preedit.rs │ └── pango_adapter.rs ├── assets │ └── Roboto-Regular.ttf ├── icons │ └── mod.rs ├── tools │ ├── pointer.rs │ ├── line.rs │ ├── marker.rs │ ├── rectangle.rs │ ├── ellipse.rs │ ├── brush.rs │ ├── blur.rs │ ├── arrow.rs │ ├── mod.rs │ ├── highlight.rs │ └── crop.rs ├── notification.rs ├── femtovg_area │ └── mod.rs ├── math.rs ├── style.rs ├── main.rs └── configuration.rs ├── cli ├── src │ ├── lib.rs │ └── command_line.rs └── Cargo.toml ├── assets ├── usage.gif └── satty.svg ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ └── bug.yml ├── dependabot.yml └── workflows │ ├── lint.yml │ └── release.yml ├── .vscode ├── settings.json ├── tasks.json └── launch.json ├── satty.desktop ├── .gitignore ├── icons.toml ├── flake.nix ├── flake.lock ├── Cargo.toml ├── Makefile ├── config.toml ├── CONTRIBUTING.md ├── release.nu ├── README.md └── LICENSE /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /src/ui/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod toolbars; 2 | -------------------------------------------------------------------------------- /cli/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod command_line; 2 | -------------------------------------------------------------------------------- /src/ime/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod pango_adapter; 2 | pub mod preedit; 3 | -------------------------------------------------------------------------------- /assets/usage.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Satty-org/Satty/HEAD/assets/usage.gif -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | # .github/ISSUE_TEMPLATE/config.yml 2 | blank_issues_enabled: true 3 | -------------------------------------------------------------------------------- /src/assets/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Satty-org/Satty/HEAD/src/assets/Roboto-Regular.ttf -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer.linkedProjects": ["./Cargo.toml"], 3 | "rust-analyzer.checkOnSave.command": "clippy" 4 | } 5 | -------------------------------------------------------------------------------- /src/icons/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod icon_names { 2 | #![allow(dead_code)] 3 | #![allow(unused_imports)] 4 | include!(concat!(env!("OUT_DIR"), "/icon_names.rs")); 5 | } 6 | -------------------------------------------------------------------------------- /cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "satty_cli" 3 | version.workspace = true 4 | edition.workspace = true 5 | authors.workspace = true 6 | description.workspace = true 7 | license.workspace = true 8 | homepage.workspace = true 9 | repository.workspace = true 10 | 11 | [dependencies] 12 | clap.workspace = true 13 | -------------------------------------------------------------------------------- /satty.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Satty 3 | GenericName=Modern Screenshot Annotation. 4 | TryExec=satty 5 | Exec=satty -f %f 6 | Terminal=false 7 | NoDisplay=true 8 | Type=Application 9 | Keywords=wayland;snapshot;annotation;editing; 10 | Icon=satty 11 | Categories=Utility;Graphics; 12 | StartupNotify=true 13 | MimeType=image/png;image/jpeg; 14 | StartupWMClass=com.gabm.satty 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # These are backup files generated by rustfmt 7 | **/*.rs.bk 8 | 9 | # MSVC Windows builds of rustc generate these, which store debugging information 10 | *.pdb 11 | 12 | # Completion scripts 13 | completions/ 14 | 15 | # Directory generated by direnv when using a nix shell 16 | .direnv/ 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "cargo" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /icons.toml: -------------------------------------------------------------------------------- 1 | #app_id = "com.gabm.satty" 2 | icons = [ 3 | "pen-regular", 4 | "color-regular", 5 | "cursor-regular", 6 | "number-circle-1-regular", 7 | "drop-regular", 8 | "highlight-regular", 9 | "arrow-redo-filled", 10 | "arrow-undo-filled", 11 | "recycling-bin", 12 | "save-regular", 13 | "save-multiple-regular", 14 | "copy-regular", 15 | "text-case-title-regular", 16 | "text-font-regular", 17 | "minus-large", 18 | "checkbox-unchecked-regular", 19 | "circle-regular", 20 | "crop-filled", 21 | "arrow-up-right-filled", 22 | "rectangle-landscape-regular", 23 | "paint-bucket-filled", 24 | "paint-bucket-regular", 25 | ] 26 | -------------------------------------------------------------------------------- /src/ime/preedit.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Range; 2 | 3 | use crate::style::Color; 4 | 5 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] 6 | pub enum UnderlineKind { 7 | #[default] 8 | None, 9 | Single, 10 | Double, 11 | Low, 12 | Wavy, 13 | Error, 14 | } 15 | 16 | #[derive(Clone, Debug, Default)] 17 | pub struct PreeditSpan { 18 | pub range: Range, 19 | pub selected: bool, 20 | pub foreground: Option, 21 | pub background: Option, 22 | pub underline: UnderlineKind, 23 | pub underline_color: Option, 24 | } 25 | 26 | #[derive(Clone, Debug, Default)] 27 | pub struct Preedit { 28 | pub text: String, 29 | pub cursor_chars: Option, 30 | pub spans: Vec, 31 | } 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | # .github/ISSUE_TEMPLATE/bug-report.yml 2 | name: 🐛 Bug Report 3 | description: Something isn’t working as expected. 4 | labels: [bug] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for reporting an issue! 10 | 🔎 Please check if a similar open or closed issue already exists. 11 | - type: input 12 | id: version 13 | attributes: 14 | label: Version 15 | description: Which version are you using? 16 | placeholder: e.g. 0.20.0 Debian package or commit 672bcf27b5b3d1dd33a9dfa3ffdfe3d0641773b6 17 | - type: textarea 18 | id: description 19 | attributes: 20 | label: Description 21 | description: Description of the problem, step by step instructions, what you observed, what you expected etc. 22 | -------------------------------------------------------------------------------- /src/tools/pointer.rs: -------------------------------------------------------------------------------- 1 | use super::{Tool, Tools}; 2 | use crate::sketch_board::SketchBoardInput; 3 | use relm4::Sender; 4 | 5 | #[derive(Default)] 6 | pub struct PointerTool { 7 | input_enabled: bool, 8 | sender: Option>, 9 | } 10 | 11 | impl Tool for PointerTool { 12 | fn get_tool_type(&self) -> super::Tools { 13 | Tools::Pointer 14 | } 15 | 16 | fn get_drawable(&self) -> Option<&dyn super::Drawable> { 17 | None 18 | } 19 | 20 | fn input_enabled(&self) -> bool { 21 | self.input_enabled 22 | } 23 | 24 | fn set_input_enabled(&mut self, value: bool) { 25 | self.input_enabled = value; 26 | } 27 | 28 | fn set_sender(&mut self, sender: Sender) { 29 | self.sender = Some(sender); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "shell", 6 | "command": "cargo", 7 | "args": [ 8 | "run", 9 | "--", 10 | "--filename", 11 | //"/tmp/bug.png", 12 | "/home/gabm/Pictures/Screenshots/satty-20240219-14:19:29.png", // small 13 | //"/home/gabm/Pictures/Screenshots/satty-20240109-22:19:08.png", // big 14 | //"--fullscreen", 15 | "--output-filename", 16 | "/tmp/out.png", 17 | "--copy-command", 18 | "wl-copy", 19 | ], 20 | "problemMatcher": [ 21 | "$rustc" 22 | ], 23 | "group": { 24 | "isDefault": true, 25 | "kind": "test" 26 | }, 27 | "label": "rust: run swappy" 28 | } 29 | ] 30 | } -------------------------------------------------------------------------------- /src/notification.rs: -------------------------------------------------------------------------------- 1 | use gdk_pixbuf::gio::FileIcon; 2 | use relm4::gtk::gio::{prelude::ApplicationExt, Notification}; 3 | 4 | use relm4::gtk::{IconLookupFlags, IconTheme, TextDirection}; 5 | 6 | pub fn log_result(msg: &str, notify: bool) { 7 | println!("{msg}"); 8 | if notify { 9 | show_notification(msg); 10 | } 11 | } 12 | 13 | fn show_notification(msg: &str) { 14 | // construct 15 | let notification = Notification::new("Satty"); 16 | notification.set_body(Some(msg)); 17 | 18 | // lookup sattys icon 19 | let theme = IconTheme::default(); 20 | if theme.has_icon("satty") { 21 | if let Some(icon_file) = theme 22 | .lookup_icon( 23 | "satty", 24 | &[], 25 | 96, 26 | 1, 27 | TextDirection::Ltr, 28 | IconLookupFlags::empty(), 29 | ) 30 | .file() 31 | { 32 | notification.set_icon(&FileIcon::new(&icon_file)); 33 | } 34 | } 35 | 36 | // send notification 37 | relm4::main_application().send_notification(None, ¬ification); 38 | } 39 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "A basic Rust devshell for NixOS users developing gtk/libadwaita apps"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | rust-overlay.url = "github:oxalica/rust-overlay"; 7 | }; 8 | 9 | outputs = { 10 | nixpkgs, 11 | rust-overlay, 12 | ... 13 | }: let 14 | systems = ["x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin"]; 15 | forEachSystem = nixpkgs.lib.genAttrs systems; 16 | in { 17 | devShells = forEachSystem ( 18 | system: let 19 | pkgs = nixpkgs.legacyPackages.${system}; 20 | rustPkgs = rust-overlay.packages.${system}; 21 | in rec { 22 | default = satty; 23 | satty = pkgs.mkShell { 24 | buildInputs = with pkgs; [ 25 | pkg-config 26 | libGL 27 | libepoxy 28 | gtk4 29 | wrapGAppsHook4 # this is needed for relm4-icons to properly load after gtk::init() 30 | libadwaita 31 | fontconfig 32 | 33 | (rustPkgs.rust.override { 34 | extensions = ["rust-src"]; 35 | }) 36 | ]; 37 | 38 | shellHook = '' 39 | export GSETTINGS_SCHEMA_DIR=${pkgs.glib.getSchemaPath pkgs.gtk4} 40 | ''; 41 | }; 42 | } 43 | ); 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | env: 12 | CARGO_TERM_COLOR: always 13 | 14 | jobs: 15 | fmt: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: dtolnay/rust-toolchain@stable 20 | with: 21 | targets: x86_64-unknown-linux-gnu 22 | components: rustfmt 23 | - uses: Swatinem/rust-cache@v2 24 | - run: cargo fmt --check 25 | env: 26 | RUSTFLAGS: "-Dwarnings" 27 | 28 | lint: 29 | runs-on: ubuntu-latest 30 | container: 31 | image: ghcr.io/gtk-rs/gtk4-rs/gtk4:latest 32 | steps: 33 | - name: Install dependencies 34 | run: yum install -y gtk4-devel libadwaita-devel 35 | - uses: actions/checkout@v3 36 | - uses: dtolnay/rust-toolchain@stable 37 | with: 38 | targets: x86_64-unknown-linux-gnu 39 | components: clippy 40 | - uses: Swatinem/rust-cache@v2 41 | - run: | 42 | cargo clippy --all-features --all-targets \ 43 | -- -D warnings 44 | 45 | doc: 46 | runs-on: ubuntu-latest 47 | container: 48 | image: ghcr.io/gtk-rs/gtk4-rs/gtk4:latest 49 | steps: 50 | - name: Install dependencies 51 | run: yum install -y gtk4-devel libadwaita-devel 52 | - uses: actions/checkout@v3 53 | - uses: dtolnay/rust-toolchain@stable 54 | - run: cargo install cargo-deadlinks 55 | - run: cargo deadlinks 56 | - run: cargo doc --all-features --no-deps 57 | env: 58 | RUSTDOCFLAGS: -Dwarnings 59 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1748026106, 6 | "narHash": "sha256-6m1Y3/4pVw1RWTsrkAK2VMYSzG4MMIj7sqUy7o8th1o=", 7 | "owner": "NixOS", 8 | "repo": "nixpkgs", 9 | "rev": "063f43f2dbdef86376cc29ad646c45c46e93234c", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "NixOS", 14 | "ref": "nixos-unstable", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "nixpkgs_2": { 20 | "locked": { 21 | "lastModified": 1744536153, 22 | "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=", 23 | "owner": "NixOS", 24 | "repo": "nixpkgs", 25 | "rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11", 26 | "type": "github" 27 | }, 28 | "original": { 29 | "owner": "NixOS", 30 | "ref": "nixpkgs-unstable", 31 | "repo": "nixpkgs", 32 | "type": "github" 33 | } 34 | }, 35 | "root": { 36 | "inputs": { 37 | "nixpkgs": "nixpkgs", 38 | "rust-overlay": "rust-overlay" 39 | } 40 | }, 41 | "rust-overlay": { 42 | "inputs": { 43 | "nixpkgs": "nixpkgs_2" 44 | }, 45 | "locked": { 46 | "lastModified": 1748140821, 47 | "narHash": "sha256-GZcjWLQtDifSYMd1ueLDmuVTcQQdD5mONIBTqABooOk=", 48 | "owner": "oxalica", 49 | "repo": "rust-overlay", 50 | "rev": "476b2ba7dc99ddbf70b1f45357dbbdbdbdfb4422", 51 | "type": "github" 52 | }, 53 | "original": { 54 | "owner": "oxalica", 55 | "repo": "rust-overlay", 56 | "type": "github" 57 | } 58 | } 59 | }, 60 | "root": "root", 61 | "version": 7 62 | } 63 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v[0-9]+.[0-9]+.[0-9]+" 7 | 8 | env: 9 | CARGO_TERM_COLOR: always 10 | 11 | jobs: 12 | release: 13 | strategy: 14 | matrix: 15 | include: 16 | - os: ubuntu-latest 17 | target: x86_64-unknown-linux-gnu 18 | archive: tar.gz 19 | 20 | runs-on: ${{ matrix.os }} 21 | container: 22 | image: ghcr.io/gtk-rs/gtk4-rs/gtk4:latest 23 | 24 | permissions: 25 | contents: write 26 | 27 | steps: 28 | - name: Install dependencies 29 | if: matrix.os == 'ubuntu-latest' 30 | run: yum install -y gtk4-devel libadwaita-devel 31 | 32 | - uses: actions/checkout@v3 33 | 34 | - uses: dtolnay/rust-toolchain@stable 35 | with: 36 | targets: ${{ matrix.target }} 37 | 38 | - name: Cache Dependencies 39 | uses: Swatinem/rust-cache@v2 40 | 41 | - name: Build 42 | run: cargo build --release --locked --target ${{ matrix.target }} 43 | 44 | - name: Pack Artifacts 45 | if: matrix.os == 'ubuntu-latest' 46 | env: 47 | RELEASE_NAME: satty-${{ matrix.target }} 48 | ARTIFACTS_DIR: target/${{ matrix.target }}/release 49 | run: | 50 | mkdir $RELEASE_NAME 51 | cp target/${{ matrix.target }}/release/satty -t $RELEASE_NAME 52 | cp -r completions -t $RELEASE_NAME 53 | cp -r README.md assets LICENSE satty.desktop -t $RELEASE_NAME 54 | tar -zcvf $RELEASE_NAME.${{ matrix.archive }} -C $RELEASE_NAME . 55 | 56 | - name: Release 57 | uses: softprops/action-gh-release@v1 58 | if: startsWith(github.ref, 'refs/tags/') 59 | with: 60 | files: satty-${{ matrix.target }}.${{ matrix.archive }} 61 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "lldb", 9 | "request": "launch", 10 | "name": "Debug executable 'satty'", 11 | "cargo": { 12 | "args": [ 13 | "build", 14 | "--bin=satty", 15 | "--package=satty", 16 | ], 17 | "filter": { 18 | "name": "satty", 19 | "kind": "bin" 20 | } 21 | }, 22 | "args": [ 23 | "--filename", 24 | "/tmp/bug.png", 25 | //"/home/gabm/Pictures/Screenshots/satty-20240219-14:19:29.png", 26 | //"/home/gabm/Pictures/Screenshots/satty-20240109-22:19:08.png", 27 | //"/home/gabm/Pictures/Wallpaper/torres_1.jpg" 28 | "--output-filename", 29 | "/tmp/out.png" 30 | ], 31 | "cwd": "${workspaceFolder}" 32 | }, 33 | { 34 | "type": "lldb", 35 | "request": "launch", 36 | "name": "Debug unit tests in executable 'satty'", 37 | "cargo": { 38 | "args": [ 39 | "test", 40 | "--no-run", 41 | "--bin=satty", 42 | "--package=satty" 43 | ], 44 | "filter": { 45 | "name": "satty", 46 | "kind": "bin" 47 | } 48 | }, 49 | "args": [], 50 | "cwd": "${workspaceFolder}" 51 | } 52 | ] 53 | } -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "satty" 3 | version.workspace = true 4 | edition.workspace = true 5 | authors.workspace = true 6 | description.workspace = true 7 | homepage.workspace = true 8 | repository.workspace = true 9 | license.workspace = true 10 | include = [ 11 | "src/**/*", 12 | "Cargo.toml", 13 | "Cargo.lock", 14 | "LICENSE*", 15 | "README.md", 16 | "assets/", 17 | ] 18 | 19 | [dependencies] 20 | satty_cli.workspace = true 21 | relm4 = { version = "0.10.0", features = ["macros", "libadwaita", "gnome_42"] } 22 | tokio = { version = "1.48.0", features = ["time"] } 23 | gdk-pixbuf = "0.21.2" 24 | 25 | # error handling 26 | anyhow = "1.0" 27 | thiserror = "2.0" 28 | 29 | # command line 30 | clap.workspace = true 31 | 32 | # configuration file 33 | xdg = "^3.0" 34 | toml = "0.9.8" 35 | serde = "1.0" 36 | serde_derive = "1.0" 37 | hex_color = {version = "3", features = ["serde"]} 38 | chrono = "0.4.42" 39 | 40 | # opengl rendering backend 41 | femtovg = "0.19" 42 | libloading = "0.9" 43 | epoxy = "0.1.0" 44 | glow = "0.16.0" 45 | glib-macros = "0.21.4" 46 | glib = "0.21.4" 47 | resource = "0.6.1" # font emedding 48 | fontconfig = "0.10.0" # font loading 49 | keycode = "1.0.0" 50 | pango = "0.21.3" 51 | 52 | [dependencies.relm4-icons] 53 | version = "0.10.0" 54 | 55 | 56 | [build-dependencies] 57 | clap.workspace = true 58 | clap_complete = "4.5.61" 59 | clap_complete_nushell = "4.5.10" 60 | satty_cli.workspace = true 61 | clap_complete_fig = "4.5.2" 62 | relm4-icons-build = "0.10" 63 | 64 | [workspace] 65 | members = [ "cli" ] 66 | 67 | [workspace.package] 68 | version = "0.20.0" 69 | edition = "2021" 70 | authors = ["Matthias Gabriel "] 71 | description = "Modern Screenshot Annotation." 72 | homepage = "https://github.com/gabm/satty" 73 | repository = "https://github.com/gabm/satty" 74 | license = "MPL-2.0" 75 | 76 | [workspace.dependencies] 77 | clap = { version = "4.5.53", features = ["derive"] } 78 | satty_cli = { path = "cli" } 79 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ifeq ($(PREFIX),) 2 | PREFIX := /usr/local 3 | endif 4 | 5 | SOURCEDIRS:=src $(wildcard src/*) 6 | SOURCEFILES:=$(foreach d,$(SOURCEDIRS),$(wildcard $(d)/*.rs)) 7 | 8 | build: target/debug/satty 9 | 10 | build-release: target/release/satty 11 | 12 | force-build: 13 | cargo build 14 | 15 | force-build-release: 16 | cargo build --release 17 | 18 | target/debug/satty: $(SOURCEFILES) Cargo.lock Cargo.toml 19 | cargo build 20 | 21 | target/release/satty: $(SOURCEFILES) Cargo.lock Cargo.toml 22 | cargo build --release 23 | 24 | clean: 25 | cargo clean 26 | 27 | install: target/release/satty 28 | install -s -Dm755 target/release/satty -t ${PREFIX}/bin/ 29 | install -Dm644 satty.desktop ${PREFIX}/share/applications/satty.desktop 30 | install -Dm644 assets/satty.svg ${PREFIX}/share/icons/hicolor/scalable/apps/satty.svg 31 | 32 | install -Dm644 LICENSE ${PREFIX}/share/licenses/satty/LICENSE 33 | 34 | uninstall: 35 | rm ${PREFIX}/bin/satty 36 | rmdir -p ${PREFIX}/bin || true 37 | 38 | rm ${PREFIX}/share/applications/satty.desktop 39 | rmdir -p ${PREFIX}/share/applications || true 40 | 41 | rm ${PREFIX}/share/icons/hicolor/scalable/apps/satty.svg 42 | rmdir -p ${PREFIX}/share/icons/hicolor/scalable/apps || true 43 | 44 | rm ${PREFIX}/share/licenses/satty/LICENSE 45 | rmdir -p ${PREFIX}/share/licenses/satty || true 46 | 47 | 48 | package: clean build-release 49 | $(eval TMP := $(shell mktemp -d)) 50 | echo "Temporary folder ${TMP}" 51 | 52 | # install to tmp 53 | PREFIX=${TMP} make install 54 | 55 | # create package 56 | $(eval LATEST_TAG := $(shell git describe --tags --abbrev=0)) 57 | tar -czvf satty-${LATEST_TAG}-x86_64.tar.gz -C ${TMP} . 58 | 59 | # clean up 60 | rm -rf $(TMP) 61 | 62 | fix: 63 | cargo fmt --all 64 | cargo clippy --fix --allow-dirty --all-targets --all-features -- -D warnings 65 | 66 | STARTPATTERN:=» satty --help 67 | ENDPATTERN=``` 68 | 69 | # sed command adds command line help to README.md 70 | # within startpattern and endpattern: 71 | # when startpattern is found, print it and read stdin 72 | # when endpattern is found, print it 73 | # everything else, delete 74 | # 75 | # The double -e is needed because r command cannot be terminated with semicolon. 76 | # -i is tricky to use for both BSD/busybox sed AND GNU sed at the same time, so use mv instead. 77 | update-readme: target/release/satty 78 | target/release/satty --help 2>&1 | sed -e '/${STARTPATTERN}/,/${ENDPATTERN}/{ /${STARTPATTERN}/p;r /dev/stdin' -e '/${ENDPATTERN}/p; d; }' README.md > README.md.new 79 | mv README.md.new README.md 80 | 81 | 82 | -------------------------------------------------------------------------------- /config.toml: -------------------------------------------------------------------------------- 1 | [general] 2 | # Start Satty in fullscreen mode 3 | fullscreen = true 4 | # Exit directly after copy/save action 5 | early-exit = true 6 | # Draw corners of rectangles round if the value is greater than 0 (0 disables rounded corners) 7 | corner-roundness = 12 8 | # Select the tool on startup [possible values: pointer, crop, line, arrow, rectangle, text, marker, blur, brush] 9 | initial-tool = "brush" 10 | # Configure the command to be called on copy, for example `wl-copy` 11 | copy-command = "wl-copy" 12 | # Increase or decrease the size of the annotations 13 | annotation-size-factor = 2 14 | # 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 15 | output-filename = "/tmp/test-%Y-%m-%d_%H:%M:%S.png" 16 | # After copying the screenshot, save it to a file as well 17 | save-after-copy = false 18 | # Hide toolbars by default 19 | default-hide-toolbars = false 20 | # Experimental: whether window focus shows/hides toolbars. This does not affect initial state of toolbars, see default-hide-toolbars. 21 | focus-toggles-toolbars = false 22 | # Fill shapes by default 23 | default-fill-shapes = false 24 | # The primary highlighter to use, the other is accessible by holding CTRL at the start of a highlight [possible values: block, freehand] 25 | primary-highlighter = "block" 26 | # Disable notifications 27 | disable-notifications = false 28 | # Actions to trigger on right click (order is important) 29 | # [possible values: save-to-clipboard, save-to-file, exit] 30 | actions-on-right-click = [] 31 | # Actions to trigger on Enter key (order is important) 32 | # [possible values: save-to-clipboard, save-to-file, exit] 33 | actions-on-enter = ["save-to-clipboard"] 34 | # Actions to trigger on Escape key (order is important) 35 | # [possible values: save-to-clipboard, save-to-file, exit] 36 | actions-on-escape = ["exit"] 37 | # Action to perform when the Enter key is pressed [possible values: save-to-clipboard, save-to-file] 38 | # Deprecated: use actions-on-enter instead 39 | action-on-enter = "save-to-clipboard" 40 | # Right click to copy 41 | # Deprecated: use actions-on-right-click instead 42 | right-click-copy = false 43 | # request no window decoration. Please note that the compositor has the final say in this. At this point. requires xdg-decoration-unstable-v1. 44 | no-window-decoration = true 45 | # experimental feature: adjust history size for brush input smooting (0: disabled, default: 0, try e.g. 5 or 10) 46 | brush-smooth-history-size = 10 47 | 48 | # Tool selection keyboard shortcuts 49 | [keybinds] 50 | pointer = "p" 51 | crop = "c" 52 | brush = "b" 53 | line = "i" 54 | arrow = "z" 55 | rectangle = "r" 56 | ellipse = "e" 57 | text = "t" 58 | marker = "m" 59 | blur = "u" 60 | highlight = "g" 61 | 62 | # Font to use for text annotations 63 | [font] 64 | family = "Roboto" 65 | style = "Regular" 66 | 67 | # Custom colours for the colour palette 68 | [color-palette] 69 | # These will be shown in the toolbar for quick selection 70 | palette = [ 71 | "#00ffff", 72 | "#a52a2a", 73 | "#dc143c", 74 | "#ff1493", 75 | "#ffd700", 76 | "#008000" 77 | ] 78 | 79 | # These will be available in the color picker as presets 80 | # Leave empty to use GTK's default 81 | custom = [ 82 | "#00ffff", 83 | "#a52a2a", 84 | "#dc143c", 85 | "#ff1493", 86 | "#ffd700", 87 | "#008000" 88 | ] 89 | -------------------------------------------------------------------------------- /assets/satty.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 40 | 44 | 49 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | CONTRIBUTING 2 | == 3 | 4 | Contributions are welcome. Satty is not able to evolve without relying on its contributors and their contributions. 5 | 6 | This documents tries to reduce friction when it comes to contributions by defining some guidelines, some of which may follow a rationale while others are arbitrary determinations. 7 | 8 | Please note that opening a PR or even just an issue may expose your work to pertinent discussion regarding code quality, Satty's scope and these guidelines, and possibly things we haven't yet thought of. This isn't meant as discouragement, just as a heads-up. 9 | 10 | Issue first, then PR 11 | -- 12 | 13 | The issue should state what is missing from or broken in Satty. All the discussion around whether a feature is in scope, or a behaviour is a bug can take place there. A related PR is then just about correctness of a fix or feature implementation. This ensures that a specific feature or fix is actually wanted. 14 | 15 | 3rd party crates 16 | -- 17 | 18 | We would like to keep 3rd party dependencies to a minimum. Addition of new dependencies should only be considered if 19 | - the relevant code parts are non-trivial 20 | - the functionality in question cannot be provided via existing dependencies 21 | 22 | Code comments 23 | -- 24 | 25 | Ideally, code should be written in a way that it is self-explanatory. Comments can always help make code parts more understandable. They especially make sense when a section 26 | - was tricky to figure out 27 | - is sophisticated or unintuitive or not immediately obvious 28 | - might be in jeooardy of being overwritten by future you or other contributors due to not understanding it properly 29 | 30 | Please note that we may ask for additional comments. 31 | 32 | Code formatting and hints/improvements 33 | -- 34 | 35 | Please use `cargo fmt` to apply formatting and `cargo clippy` to fix all suggestions pertinent to your changes. You can use `make fix` for both. Please note that this may apply changes unrelated to your code: 36 | - formatting if previous commits have not used `cargo fmt` 37 | - hints if previous commits have not used `cargo clippy` OR clippy is newer than the last commit and has learned new hints in the meantime 38 | 39 | Missing formatting/hints that precede your PR should be addressed via a separate issue/PR in main branch first. If in doubt how to resolve such a situation, ask. 40 | 41 | README changes 42 | -- 43 | 44 | If a PR changes Satty's behaviour and where appropriate, please adjust `README.md` as well. `make update-readme` adds the command line help (output of `satty --help`) automatically which is relevant whenever command line arguments change. While it can be tempting to add other fixes to the README while you're at it, unrelated changes to it which precede your PR should be addressed in a separate issue/PR first. If in doubt how to resolve such a situation, ask. 45 | 46 | Command line parameters changes 47 | -- 48 | 49 | Please include anticipated next version in the comment for command line arguments, especially when adding arguments or options. You can use the placeholder `NEXTRELEASE` in `command_line.rs`, `configuration.rs` and `README.md`. 50 | 51 | GenAI usage 52 | -- 53 | 54 | GenAI usage is tempting and can save time, but it's not without pitfalls. At this point in time, full vibe coding mode can and often does lead to bad quality code which we are not going to merge. 55 | 56 | When using GenAI in the context of Satty PRs, please make sure that 57 | - any generated code can actually be licensed under Satty's license, i.e. doesn't violate existing intellectual property 58 | - any generated code actually does what it claims it does 59 | - you have a technical understanding of how the generated code works and you (not the GenAI) can explain it in detail 60 | -------------------------------------------------------------------------------- /release.nu: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env nu 2 | 3 | use std assert 4 | 5 | export def main [version: string] { 6 | # make sure the working tree is clean 7 | assert_repo_is_clean 8 | 9 | let read_version = version_read_cargo_toml 10 | let requested_version = $version | version_parse 11 | let requested_version_tag = $requested_version | version_to_string 12 | 13 | $"Updating version from ($read_version | version_to_string) to ($requested_version | version_to_string)" | echo_section_headline 14 | 15 | if not (is_newer $read_version $requested_version) { 16 | print "Requested version is older than current version. Aborting." 17 | exit 1 18 | } 19 | 20 | # update the cargo toml 21 | patch_cargo_toml $requested_version 22 | 23 | # update cargo lock 24 | update_cargo_lock 25 | 26 | # replace NEXTRELEASE with version 27 | update_next_release src/command_line.rs $version 28 | update_next_release src/configuration.rs $version 29 | update_next_release README.md $version 30 | 31 | # show diff so we can review the replacements 32 | git_diff 33 | 34 | if ((input "Proceed with commit? (Y/n) " --numchar 1 --default "Y") | str downcase) == "n" { 35 | exit 1 36 | } 37 | 38 | # commit 39 | git_commit $requested_version_tag 40 | 41 | # tag a new git version 42 | git_tag $requested_version_tag 43 | 44 | ## from here on we go online! 45 | # push 46 | git_push 47 | 48 | # the rest is being handled by the github release action 49 | } 50 | 51 | def echo_section_headline []: string -> nothing { 52 | print $"\n(ansi yellow)++ ($in)(ansi reset)" 53 | } 54 | 55 | def assert_repo_is_clean [] { 56 | if (git diff --quiet | complete | get exit_code) != 0 { 57 | print "The git repository is not clean! Aborting..." 58 | exit 1 59 | } else {} 60 | } 61 | 62 | def git_diff [] { 63 | git --no-pager diff 64 | } 65 | 66 | def git_tag [tag: string] { 67 | assert_repo_is_clean 68 | 69 | $"Creating Git Tag ($tag) " | echo_section_headline 70 | git tag ($tag) 71 | } 72 | 73 | def git_push [] { 74 | "Pushing to GitHub" | echo_section_headline 75 | git push; git push --tags 76 | } 77 | 78 | def patch_cargo_toml [version: list] { 79 | "Updating Cargo.toml" | echo_section_headline 80 | let sed_string = $"/package/,/version =/{s/version.*/version = \"($version | str join '.')\"/}" 81 | 82 | sed -i $sed_string Cargo.toml 83 | } 84 | 85 | def update_cargo_lock [] { 86 | "Updating Cargo.lock" | echo_section_headline 87 | cargo generate-lockfile 88 | } 89 | 90 | def update_next_release [filename: string, version: string] { 91 | sed -i -e $'s,NEXTRELEASE,($version),g' $filename 92 | } 93 | 94 | def git_commit [tag: string] { 95 | "Committing..." | echo_section_headline 96 | git commit -am $"Updating version to ($tag)" 97 | } 98 | 99 | def version_parse []: string -> list { 100 | $in | str trim -c 'v' --left | split row '.' | each {|n| into int } 101 | } 102 | 103 | def version_to_string []: list -> string { 104 | $"v($in | str join '.')" 105 | } 106 | 107 | def version_read_cargo_toml []: nothing -> list { 108 | open Cargo.toml | get package.version | version_parse 109 | } 110 | 111 | def is_newer [ 112 | old: list, 113 | new: list 114 | ]: nothing -> bool { 115 | 116 | let length = [($old | length) ($new | length)] | math min 117 | 118 | for i in 0..<($length) { 119 | if ($new | get $i) > ($old | get $i) { 120 | return true 121 | } else {} 122 | if ($new | get $i) < ($old | get $i) { 123 | return false 124 | } else {} 125 | } 126 | 127 | return false 128 | } 129 | 130 | #[test] 131 | def test_versions [] { 132 | assert (is_newer [1 0 0] [2 0 0]) "major version" 133 | assert (is_newer [1 0 0] [1 1]) "minor version, shorter" 134 | assert not (is_newer [1 1 0] [1 1]) "minor version, shorter" 135 | assert not (is_newer [1 1 0] [0 1]) "minor version, shorter" 136 | } 137 | -------------------------------------------------------------------------------- /src/tools/line.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use femtovg::{FontId, Path}; 3 | use relm4::{ 4 | gtk::gdk::{Key, ModifierType}, 5 | Sender, 6 | }; 7 | 8 | use crate::{ 9 | math::Vec2D, 10 | sketch_board::{MouseButton, MouseEventMsg, MouseEventType, SketchBoardInput}, 11 | style::Style, 12 | }; 13 | 14 | use super::{Drawable, DrawableClone, Tool, ToolUpdateResult, Tools}; 15 | 16 | #[derive(Default)] 17 | pub struct LineTool { 18 | line: Option, 19 | style: Style, 20 | input_enabled: bool, 21 | sender: Option>, 22 | } 23 | 24 | #[derive(Clone, Copy, Debug)] 25 | pub struct Line { 26 | start: Vec2D, 27 | direction: Option, 28 | style: Style, 29 | } 30 | 31 | impl Drawable for Line { 32 | fn draw( 33 | &self, 34 | canvas: &mut femtovg::Canvas, 35 | _font: FontId, 36 | _bounds: (Vec2D, Vec2D), 37 | ) -> Result<()> { 38 | let direction = match self.direction { 39 | Some(d) => d, 40 | None => return Ok(()), // exit early if no direction 41 | }; 42 | 43 | canvas.save(); 44 | 45 | let mut path = Path::new(); 46 | path.move_to(self.start.x, self.start.y); 47 | path.line_to(self.start.x + direction.x, self.start.y + direction.y); 48 | 49 | canvas.stroke_path(&path, &self.style.into()); 50 | 51 | canvas.restore(); 52 | 53 | Ok(()) 54 | } 55 | } 56 | 57 | impl Tool for LineTool { 58 | fn input_enabled(&self) -> bool { 59 | self.input_enabled 60 | } 61 | 62 | fn set_input_enabled(&mut self, value: bool) { 63 | self.input_enabled = value; 64 | } 65 | 66 | fn handle_mouse_event(&mut self, event: MouseEventMsg) -> ToolUpdateResult { 67 | match event.type_ { 68 | MouseEventType::BeginDrag => { 69 | if event.button == MouseButton::Middle { 70 | return ToolUpdateResult::Unmodified; 71 | } 72 | 73 | // start new 74 | self.line = Some(Line { 75 | start: event.pos, 76 | direction: None, 77 | style: self.style, 78 | }); 79 | 80 | ToolUpdateResult::Redraw 81 | } 82 | MouseEventType::EndDrag => { 83 | if event.button == MouseButton::Middle { 84 | return ToolUpdateResult::Unmodified; 85 | } 86 | 87 | if let Some(a) = &mut self.line { 88 | if event.pos == Vec2D::zero() { 89 | self.line = None; 90 | 91 | ToolUpdateResult::Redraw 92 | } else { 93 | if event.modifier.intersects(ModifierType::SHIFT_MASK) { 94 | a.direction = Some(event.pos.snapped_vector_15deg()); 95 | } else { 96 | a.direction = Some(event.pos); 97 | } 98 | let result = a.clone_box(); 99 | self.line = None; 100 | 101 | ToolUpdateResult::Commit(result) 102 | } 103 | } else { 104 | ToolUpdateResult::Unmodified 105 | } 106 | } 107 | MouseEventType::UpdateDrag => { 108 | if event.button == MouseButton::Middle { 109 | return ToolUpdateResult::Unmodified; 110 | } 111 | 112 | if let Some(r) = &mut self.line { 113 | if event.modifier.intersects(ModifierType::SHIFT_MASK) { 114 | r.direction = Some(event.pos.snapped_vector_15deg()); 115 | } else { 116 | r.direction = Some(event.pos); 117 | } 118 | ToolUpdateResult::Redraw 119 | } else { 120 | ToolUpdateResult::Unmodified 121 | } 122 | } 123 | _ => ToolUpdateResult::Unmodified, 124 | } 125 | } 126 | 127 | fn handle_key_event(&mut self, event: crate::sketch_board::KeyEventMsg) -> ToolUpdateResult { 128 | if event.key == Key::Escape && self.line.is_some() { 129 | self.line = None; 130 | ToolUpdateResult::Redraw 131 | } else { 132 | ToolUpdateResult::Unmodified 133 | } 134 | } 135 | 136 | fn handle_style_event(&mut self, style: Style) -> ToolUpdateResult { 137 | self.style = style; 138 | ToolUpdateResult::Unmodified 139 | } 140 | 141 | fn get_drawable(&self) -> Option<&dyn Drawable> { 142 | match &self.line { 143 | Some(d) => Some(d), 144 | None => None, 145 | } 146 | } 147 | 148 | fn get_tool_type(&self) -> super::Tools { 149 | Tools::Line 150 | } 151 | 152 | fn set_sender(&mut self, sender: Sender) { 153 | self.sender = Some(sender); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/femtovg_area/mod.rs: -------------------------------------------------------------------------------- 1 | mod imp; 2 | 3 | use std::{cell::RefCell, rc::Rc}; 4 | 5 | use gdk_pixbuf::{glib::subclass::types::ObjectSubclassIsExt, Pixbuf}; 6 | use gtk::glib; 7 | use relm4::{ 8 | gtk::{self, prelude::WidgetExt, subclass::prelude::GLAreaImpl}, 9 | Sender, 10 | }; 11 | 12 | use crate::{ 13 | configuration::Action, 14 | math::Vec2D, 15 | sketch_board::SketchBoardInput, 16 | tools::{CropTool, Drawable, Tool}, 17 | }; 18 | 19 | glib::wrapper! { 20 | pub struct FemtoVGArea(ObjectSubclass) 21 | @extends gtk::Widget, gtk::GLArea, 22 | @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; 23 | } 24 | 25 | impl Default for FemtoVGArea { 26 | fn default() -> Self { 27 | glib::Object::new() 28 | } 29 | } 30 | 31 | impl FemtoVGArea { 32 | pub fn set_active_tool(&mut self, active_tool: Rc>) { 33 | self.imp() 34 | .inner() 35 | .as_mut() 36 | .expect("Did you call init before using FemtoVgArea?") 37 | .set_active_tool(active_tool); 38 | } 39 | 40 | pub fn commit(&mut self, drawable: Box) { 41 | self.imp() 42 | .inner() 43 | .as_mut() 44 | .expect("Did you call init before using FemtoVgArea?") 45 | .commit(drawable); 46 | } 47 | pub fn undo(&mut self) -> bool { 48 | self.imp() 49 | .inner() 50 | .as_mut() 51 | .expect("Did you call init before using FemtoVgArea?") 52 | .undo() 53 | } 54 | pub fn redo(&mut self) -> bool { 55 | self.imp() 56 | .inner() 57 | .as_mut() 58 | .expect("Did you call init before using FemtoVgArea?") 59 | .redo() 60 | } 61 | pub fn request_render(&self, actions: &[Action]) { 62 | self.imp().request_render(actions); 63 | } 64 | pub fn reset(&mut self) -> bool { 65 | self.imp() 66 | .inner() 67 | .as_mut() 68 | .expect("Did you call init before using FemtoVgArea?") 69 | .reset() 70 | } 71 | 72 | pub fn abs_canvas_to_image_coordinates(&self, input: Vec2D) -> Vec2D { 73 | self.imp() 74 | .inner() 75 | .as_mut() 76 | .expect("Did you call init before using FemtoVgArea?") 77 | .abs_canvas_to_image_coordinates(input, self.scale_factor() as f32) 78 | } 79 | 80 | pub fn rel_canvas_to_image_coordinates(&self, input: Vec2D) -> Vec2D { 81 | self.imp() 82 | .inner() 83 | .as_mut() 84 | .expect("Did you call init before using FemtoVgArea?") 85 | .rel_canvas_to_image_coordinates(input, self.scale_factor() as f32) 86 | } 87 | pub fn init( 88 | &mut self, 89 | sender: Sender, 90 | crop_tool: Rc>, 91 | active_tool: Rc>, 92 | background_image: Pixbuf, 93 | ) { 94 | self.imp() 95 | .init(sender, crop_tool, active_tool, background_image); 96 | } 97 | 98 | pub fn set_zoom_scale(&self, factor: f32) { 99 | self.imp() 100 | .inner() 101 | .as_mut() 102 | .expect("Did you call init before using FemtoVgArea?") 103 | .set_zoom_scale(factor, false); 104 | //trigger resize to recalculate zoom 105 | self.imp().resize(0, 0); 106 | } 107 | 108 | pub fn set_pointer_offset(&self, offset: Vec2D) { 109 | self.imp() 110 | .inner() 111 | .as_mut() 112 | .expect("Did you call init before using FemtoVgArea?") 113 | .set_pointer_offset(offset * self.scale_factor() as f32); 114 | } 115 | 116 | pub fn set_drag_offset(&self, offset: Vec2D) { 117 | self.imp() 118 | .inner() 119 | .as_mut() 120 | .expect("Did you call init before using FemtoVgArea?") 121 | .set_drag_offset(offset * self.scale_factor() as f32); 122 | //trigger resize to recalculate offset 123 | self.imp().resize(0, 0); 124 | } 125 | 126 | pub fn store_last_offset(&self) { 127 | self.imp() 128 | .inner() 129 | .as_mut() 130 | .expect("Did you call init before using FemtoVgArea?") 131 | .store_last_offset(); 132 | } 133 | 134 | pub fn set_is_drag(&self, is_drag: bool) { 135 | self.imp() 136 | .inner() 137 | .as_mut() 138 | .expect("Did you call init before using FemtoVgArea?") 139 | .set_is_drag(is_drag); 140 | } 141 | 142 | pub fn reset_size(&self, factor: f32) { 143 | self.imp() 144 | .inner() 145 | .as_mut() 146 | .expect("Did you call init before using FemtoVgArea?") 147 | .set_zoom_scale(factor, true); 148 | self.imp() 149 | .inner() 150 | .as_mut() 151 | .expect("Did you call init before using FemtoVgArea?") 152 | .reset_drag_offset(); 153 | //trigger resize to reset 154 | self.imp().resize(0, 0); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/tools/marker.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | use std::f64::consts::PI; 3 | use std::rc::Rc; 4 | 5 | use femtovg::{Color, Paint, Path}; 6 | 7 | use crate::sketch_board::{MouseButton, MouseEventType, SketchBoardInput}; 8 | use crate::style::Style; 9 | use crate::{math::Vec2D, sketch_board::MouseEventMsg}; 10 | 11 | use super::{Drawable, DrawableClone, Tool, ToolUpdateResult, Tools}; 12 | use relm4::Sender; 13 | 14 | pub struct MarkerTool { 15 | style: Style, 16 | next_number: Rc>, 17 | input_enabled: bool, 18 | sender: Option>, 19 | } 20 | 21 | #[derive(Clone, Debug)] 22 | pub struct Marker { 23 | pos: Vec2D, 24 | number: u16, 25 | style: Style, 26 | tool_next_number: Rc>, 27 | } 28 | 29 | impl Drawable for Marker { 30 | fn draw( 31 | &self, 32 | canvas: &mut femtovg::Canvas, 33 | font: femtovg::FontId, 34 | _bounds: (Vec2D, Vec2D), 35 | ) -> anyhow::Result<()> { 36 | let text = format!("{}", self.number); 37 | 38 | let marker_color: Color = self.style.color.into(); 39 | // https://en.wikipedia.org/wiki/Luma_(video) 40 | let luminance = 0.2126 * marker_color.r + 0.7152 * marker_color.g + 0.0722 * marker_color.b; 41 | let text_color = if luminance > 0.5 { 42 | Color::black() 43 | } else { 44 | Color::white() 45 | }; 46 | 47 | let mut paint = Paint::color(text_color); 48 | 49 | paint.set_font(&[font]); 50 | paint.set_font_size( 51 | (self 52 | .style 53 | .size 54 | .to_text_size(self.style.annotation_size_factor)) as f32, 55 | ); 56 | paint.set_text_align(femtovg::Align::Center); 57 | paint.set_text_baseline(femtovg::Baseline::Middle); 58 | 59 | let text_metrics = canvas.measure_text(self.pos.x, self.pos.y, &text, &paint)?; 60 | 61 | let circle_radius = (text_metrics.width() * text_metrics.width() 62 | + text_metrics.height() * text_metrics.height()) 63 | .sqrt(); 64 | 65 | let mut inner_circle_path = Path::new(); 66 | inner_circle_path.arc( 67 | self.pos.x, 68 | self.pos.y, 69 | circle_radius * 0.8, 70 | 0.0, 71 | 2.0 * PI as f32, 72 | femtovg::Solidity::Solid, 73 | ); 74 | 75 | let mut outer_circle_path = Path::new(); 76 | outer_circle_path.arc( 77 | self.pos.x, 78 | self.pos.y, 79 | circle_radius, 80 | 0.0, 81 | 2.0 * PI as f32, 82 | femtovg::Solidity::Solid, 83 | ); 84 | 85 | let circle_paint = Paint::color(marker_color).with_line_width( 86 | self.style 87 | .size 88 | .to_line_width(self.style.annotation_size_factor) 89 | * 2.0, 90 | ); 91 | 92 | canvas.save(); 93 | canvas.fill_path(&inner_circle_path, &circle_paint); 94 | canvas.stroke_path(&outer_circle_path, &circle_paint); 95 | canvas.fill_text(self.pos.x, self.pos.y, &text, &paint)?; 96 | canvas.restore(); 97 | Ok(()) 98 | } 99 | 100 | fn handle_undo(&mut self) { 101 | *self.tool_next_number.borrow_mut() = self.number; 102 | } 103 | 104 | fn handle_redo(&mut self) { 105 | *self.tool_next_number.borrow_mut() = self.number + 1; 106 | } 107 | } 108 | 109 | impl Tool for MarkerTool { 110 | fn input_enabled(&self) -> bool { 111 | self.input_enabled 112 | } 113 | 114 | fn set_input_enabled(&mut self, value: bool) { 115 | self.input_enabled = value; 116 | } 117 | 118 | fn get_tool_type(&self) -> super::Tools { 119 | Tools::Marker 120 | } 121 | 122 | fn get_drawable(&self) -> Option<&dyn Drawable> { 123 | None 124 | } 125 | 126 | fn handle_style_event(&mut self, style: Style) -> ToolUpdateResult { 127 | self.style = style; 128 | ToolUpdateResult::Unmodified 129 | } 130 | 131 | fn handle_mouse_event(&mut self, event: MouseEventMsg) -> ToolUpdateResult { 132 | match event.type_ { 133 | MouseEventType::Click => { 134 | if event.button == MouseButton::Primary { 135 | let marker = Marker { 136 | pos: event.pos, 137 | number: *self.next_number.borrow(), 138 | style: self.style, 139 | tool_next_number: self.next_number.clone(), 140 | }; 141 | 142 | // increment for next 143 | *self.next_number.borrow_mut() += 1; 144 | 145 | ToolUpdateResult::Commit(marker.clone_box()) 146 | } else { 147 | ToolUpdateResult::Unmodified 148 | } 149 | } 150 | _ => ToolUpdateResult::Unmodified, 151 | } 152 | } 153 | 154 | fn set_sender(&mut self, sender: Sender) { 155 | self.sender = Some(sender); 156 | } 157 | } 158 | 159 | impl Default for MarkerTool { 160 | fn default() -> Self { 161 | Self { 162 | style: Default::default(), 163 | next_number: Rc::new(RefCell::new(1)), 164 | input_enabled: true, 165 | sender: None, 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/ime/pango_adapter.rs: -------------------------------------------------------------------------------- 1 | use glib::translate::FromGlib; 2 | use pango::{AttrColor, AttrInt, AttrList, AttrType, Underline}; 3 | 4 | use crate::style::Color; 5 | 6 | use super::preedit::{PreeditSpan, UnderlineKind}; 7 | 8 | #[allow(clippy::cast_possible_truncation)] 9 | fn to_style_color(color: pango::Color, alpha: Option) -> Color { 10 | let to_u8 = |value: u16| -> u8 { (value / 257) as u8 }; 11 | let alpha = alpha.unwrap_or(u16::MAX); 12 | Color::new( 13 | to_u8(color.red()), 14 | to_u8(color.green()), 15 | to_u8(color.blue()), 16 | to_u8(alpha), 17 | ) 18 | } 19 | 20 | fn clamp_index(index: i32, len: usize) -> usize { 21 | if index < 0 { 22 | 0 23 | } else { 24 | (index as usize).min(len) 25 | } 26 | } 27 | 28 | fn underline_from_pango(value: i32) -> UnderlineKind { 29 | match unsafe { Underline::from_glib(value) } { 30 | Underline::None => UnderlineKind::None, 31 | Underline::Single => UnderlineKind::Single, 32 | Underline::Double | Underline::DoubleLine => UnderlineKind::Double, 33 | Underline::Low => UnderlineKind::Low, 34 | Underline::Error => UnderlineKind::Error, 35 | _ => UnderlineKind::Single, 36 | } 37 | } 38 | 39 | /// Convert a Pango attribute list into neutral preedit spans understood by our renderer. 40 | pub fn spans_from_pango_attrs(text: &str, attrs: Option) -> Vec { 41 | let mut spans = Vec::new(); 42 | let text_len = text.len(); 43 | 44 | let Some(attr_list) = attrs else { 45 | if !text.is_empty() { 46 | spans.push(PreeditSpan { 47 | range: 0..text_len, 48 | selected: false, 49 | underline: UnderlineKind::Single, 50 | ..Default::default() 51 | }); 52 | } 53 | return spans; 54 | }; 55 | 56 | let mut iterator = attr_list.iterator(); 57 | loop { 58 | let (start, end) = iterator.range(); 59 | let span_start = clamp_index(start, text_len); 60 | let span_end = clamp_index(end, text_len); 61 | if span_start < span_end { 62 | let mut span = PreeditSpan { 63 | range: span_start..span_end, 64 | ..Default::default() 65 | }; 66 | 67 | let mut fg_color: Option = None; 68 | let mut bg_color: Option = None; 69 | let mut underline_color: Option = None; 70 | let mut underline_kind = UnderlineKind::None; 71 | let mut fg_alpha: Option = None; 72 | let mut bg_alpha: Option = None; 73 | 74 | for attr in iterator.attrs() { 75 | match attr.attr_class().type_() { 76 | AttrType::Foreground => { 77 | if let Some(color_attr) = attr.downcast_ref::() { 78 | fg_color = Some(color_attr.color()); 79 | } 80 | } 81 | AttrType::Background => { 82 | if let Some(color_attr) = attr.downcast_ref::() { 83 | bg_color = Some(color_attr.color()); 84 | } 85 | } 86 | AttrType::Underline => { 87 | if let Some(value_attr) = attr.downcast_ref::() { 88 | underline_kind = underline_from_pango(value_attr.value()); 89 | } 90 | } 91 | AttrType::UnderlineColor => { 92 | if let Some(color_attr) = attr.downcast_ref::() { 93 | underline_color = Some(color_attr.color()); 94 | } 95 | } 96 | AttrType::ForegroundAlpha => { 97 | if let Some(alpha_attr) = attr.downcast_ref::() { 98 | fg_alpha = Some(alpha_attr.value().clamp(0, u16::MAX as i32) as u16); 99 | } 100 | } 101 | AttrType::BackgroundAlpha => { 102 | if let Some(alpha_attr) = attr.downcast_ref::() { 103 | bg_alpha = Some(alpha_attr.value().clamp(0, u16::MAX as i32) as u16); 104 | } 105 | } 106 | _ => {} 107 | } 108 | } 109 | 110 | if let Some(color) = fg_color { 111 | span.foreground = Some(to_style_color(color, fg_alpha)); 112 | } 113 | if let Some(color) = bg_color { 114 | span.background = Some(to_style_color(color, bg_alpha)); 115 | } 116 | if let Some(color) = underline_color { 117 | span.underline_color = Some(to_style_color(color, None)); 118 | } 119 | 120 | span.underline = underline_kind; 121 | span.selected = span.background.is_some() 122 | || matches!(span.underline, UnderlineKind::Double | UnderlineKind::Error); 123 | 124 | spans.push(span); 125 | } 126 | 127 | if !iterator.next_style_change() { 128 | break; 129 | } 130 | } 131 | 132 | if spans.is_empty() && !text.is_empty() { 133 | spans.push(PreeditSpan { 134 | range: 0..text_len, 135 | selected: false, 136 | underline: UnderlineKind::Single, 137 | ..Default::default() 138 | }); 139 | } 140 | 141 | spans 142 | } 143 | -------------------------------------------------------------------------------- /src/math.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | f32::consts::PI, 3 | fmt::Display, 4 | ops::{Add, AddAssign, Mul, Sub, SubAssign}, 5 | }; 6 | 7 | #[derive(Default, Debug, Copy, Clone, PartialEq)] 8 | pub struct Vec2D { 9 | pub x: f32, 10 | pub y: f32, 11 | } 12 | 13 | #[derive(Default, Debug, Copy, Clone, PartialEq)] 14 | pub struct Angle { 15 | pub radians: f32, 16 | } 17 | impl Angle { 18 | pub fn from_radians(radians: f32) -> Self { 19 | Self { radians } 20 | } 21 | 22 | pub fn from_degrees(degrees: f32) -> Self { 23 | Self { 24 | radians: degrees * PI / 180.0, 25 | } 26 | } 27 | 28 | pub fn cos(&self) -> f32 { 29 | self.radians.cos() 30 | } 31 | 32 | pub fn sin(&self) -> f32 { 33 | self.radians.sin() 34 | } 35 | } 36 | 37 | impl Mul for Angle { 38 | type Output = Angle; 39 | 40 | fn mul(self, rhs: f32) -> Self::Output { 41 | Angle::from_radians(self.radians * rhs) 42 | } 43 | } 44 | 45 | impl Vec2D { 46 | pub fn zero() -> Self { 47 | Self { x: 0.0, y: 0.0 } 48 | } 49 | 50 | pub fn new(x: f32, y: f32) -> Self { 51 | Self { x, y } 52 | } 53 | 54 | pub fn norm(&self) -> f32 { 55 | (self.x * self.x + self.y * self.y).sqrt() 56 | } 57 | 58 | pub fn norm2(&self) -> f32 { 59 | self.x * self.x + self.y * self.y 60 | } 61 | 62 | /** 63 | * Get the angle of the vector. 64 | * Angle of 0 is the positive x-axis. 65 | * Angle of PI/2 is the positive y-axis. 66 | */ 67 | pub fn angle(&self) -> Angle { 68 | Angle::from_radians(self.y.atan2(self.x)) 69 | } 70 | 71 | /** 72 | * Create a vector from an angle. 73 | * Angle of 0 is the positive x-axis. 74 | * Angle of PI/2 is the positive y-axis. 75 | */ 76 | pub fn from_angle(angle: Angle) -> Vec2D { 77 | Vec2D::new(angle.cos(), angle.sin()) 78 | } 79 | 80 | pub fn snapped_vector_15deg(&self) -> Vec2D { 81 | let current_angle = (self.y / self.x).atan(); 82 | let current_norm2 = self.norm2(); 83 | let new_angle = (current_angle / 0.261_799_4).round() * 0.261_799_4; 84 | 85 | let (a, b) = if new_angle.abs() < PI / 4.0 86 | // 45° 87 | { 88 | let b = (current_norm2 / ((PI / 2.0 - new_angle).tan().powi(2) + 1.0)).sqrt(); 89 | let a = (current_norm2 - b * b).sqrt(); 90 | (a, b) 91 | } else { 92 | let a = (current_norm2 / (new_angle.tan().powi(2) + 1.0)).sqrt(); 93 | let b = (current_norm2 - a * a).sqrt(); 94 | (a, b) 95 | }; 96 | 97 | if self.x >= 0.0 && self.y >= 0.0 { 98 | Vec2D::new(a, b) 99 | } else if self.x < 0.0 && self.y >= 0.0 { 100 | Vec2D::new(-a, b) 101 | } else if self.x >= 0.0 && self.y < 0.0 { 102 | Vec2D::new(a, -b) 103 | } else { 104 | Vec2D::new(-a, -b) 105 | } 106 | } 107 | 108 | pub fn is_zero(&self) -> bool { 109 | self.x.abs() < f32::EPSILON && self.y.abs() < f32::EPSILON 110 | } 111 | 112 | pub fn distance_to(&self, other: &Vec2D) -> f32 { 113 | let dx = self.x - other.x; 114 | let dy = self.y - other.y; 115 | (dx * dx + dy * dy).sqrt() 116 | } 117 | } 118 | 119 | impl Add for Vec2D { 120 | type Output = Vec2D; 121 | 122 | fn add(self, rhs: Self) -> Self::Output { 123 | Self::Output { 124 | x: self.x + rhs.x, 125 | y: self.y + rhs.y, 126 | } 127 | } 128 | } 129 | 130 | impl AddAssign for Vec2D { 131 | fn add_assign(&mut self, rhs: Self) { 132 | *self = *self + rhs 133 | } 134 | } 135 | 136 | impl Sub for Vec2D { 137 | type Output = Vec2D; 138 | 139 | fn sub(self, rhs: Self) -> Self::Output { 140 | Self::Output { 141 | x: self.x - rhs.x, 142 | y: self.y - rhs.y, 143 | } 144 | } 145 | } 146 | 147 | impl SubAssign for Vec2D { 148 | fn sub_assign(&mut self, rhs: Self) { 149 | *self = *self - rhs; 150 | } 151 | } 152 | 153 | impl Mul for Vec2D { 154 | type Output = Vec2D; 155 | 156 | fn mul(self, rhs: f32) -> Self::Output { 157 | Vec2D::new(self.x * rhs, self.y * rhs) 158 | } 159 | } 160 | 161 | impl Display for Vec2D { 162 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 163 | write!(f, "({},{})", self.x, self.y) 164 | } 165 | } 166 | 167 | pub fn rect_ensure_positive_size(pos: Vec2D, size: Vec2D) -> (Vec2D, Vec2D) { 168 | let (pos_x, size_x) = if size.x > 0.0 { 169 | (pos.x, size.x) 170 | } else { 171 | ((pos.x + size.x), size.x.abs()) 172 | }; 173 | 174 | let (pos_y, size_y) = if size.y > 0.0 { 175 | (pos.y, size.y) 176 | } else { 177 | ((pos.y + size.y), size.y.abs()) 178 | }; 179 | 180 | (Vec2D::new(pos_x, pos_y), Vec2D::new(size_x, size_y)) 181 | } 182 | 183 | pub fn rect_ensure_in_bounds(rect: (Vec2D, Vec2D), bounds: (Vec2D, Vec2D)) -> (Vec2D, Vec2D) { 184 | let (mut pos, mut size) = rect; 185 | 186 | if pos.x < bounds.0.x { 187 | pos.x = bounds.0.x; 188 | size.x -= bounds.0.x - pos.x; 189 | } 190 | 191 | if pos.y < bounds.0.y { 192 | pos.y = bounds.0.y; 193 | size.y -= bounds.0.y - pos.y; 194 | } 195 | 196 | if pos.x + size.x > bounds.1.x { 197 | size.x = bounds.1.x - pos.x; 198 | } 199 | 200 | if pos.y + size.y > bounds.1.y { 201 | size.y = bounds.1.y - pos.y; 202 | } 203 | 204 | (pos, size) 205 | } 206 | 207 | pub fn rect_round(rect: (Vec2D, Vec2D)) -> (Vec2D, Vec2D) { 208 | let (mut pos, mut size) = rect; 209 | 210 | pos.x = pos.x.round(); 211 | pos.y = pos.y.round(); 212 | size.x = size.x.round(); 213 | size.y = size.y.round(); 214 | 215 | (pos, size) 216 | } 217 | -------------------------------------------------------------------------------- /cli/src/command_line.rs: -------------------------------------------------------------------------------- 1 | use clap::{Parser, ValueEnum}; 2 | 3 | #[derive(Parser, Debug)] 4 | #[command(author, version, about, long_about = None)] 5 | pub struct CommandLine { 6 | /// Path to the config file. Otherwise will be read from XDG_CONFIG_DIR/satty/config.toml 7 | #[arg(short, long)] 8 | pub config: Option, 9 | 10 | /// Path to input image or '-' to read from stdin 11 | #[arg(short, long)] 12 | pub filename: String, 13 | 14 | /// Start Satty in fullscreen mode 15 | #[arg(long)] 16 | pub fullscreen: bool, 17 | 18 | /// Filename to use for saving action or '-' to print to stdout. Omit to disable saving to file. Might contain format 19 | /// specifiers: . 20 | /// Since 0.20.0, can contain tilde (~) for home dir 21 | #[arg(short, long)] 22 | pub output_filename: Option, 23 | 24 | /// Exit directly after copy/save action 25 | #[arg(long)] 26 | pub early_exit: bool, 27 | 28 | /// Draw corners of rectangles round if the value is greater than 0 29 | /// (Defaults to 12) (0 disables rounded corners) 30 | #[arg(long)] 31 | pub corner_roundness: Option, 32 | 33 | /// Select the tool on startup 34 | #[arg(long, value_name = "TOOL", visible_alias = "init-tool")] 35 | pub initial_tool: Option, 36 | 37 | /// Configure the command to be called on copy, for example `wl-copy` 38 | #[arg(long)] 39 | pub copy_command: Option, 40 | 41 | /// Increase or decrease the size of the annotations 42 | #[arg(long)] 43 | pub annotation_size_factor: Option, 44 | 45 | /// After copying the screenshot, save it to a file as well 46 | /// Preferably use the `action_on_copy` option instead. 47 | #[arg(long)] 48 | pub save_after_copy: bool, 49 | 50 | /// Actions to perform when pressing Enter 51 | #[arg(long, value_delimiter = ',')] 52 | pub actions_on_enter: Option>, 53 | 54 | /// Actions to perform when pressing Escape 55 | #[arg(long, value_delimiter = ',')] 56 | pub actions_on_escape: Option>, 57 | 58 | /// Actions to perform when hitting the copy Button. 59 | #[arg(long, value_delimiter = ',')] 60 | pub actions_on_right_click: Option>, 61 | 62 | /// Hide toolbars by default 63 | #[arg(short, long)] 64 | pub default_hide_toolbars: bool, 65 | 66 | /// Experimental (since 0.20.0): Whether to toggle toolbars based on focus. Doesn't affect initial state. 67 | #[arg(long)] 68 | pub focus_toggles_toolbars: bool, 69 | 70 | /// Experimental feature (since 0.20.0): Fill shapes by default 71 | #[arg(long)] 72 | pub default_fill_shapes: bool, 73 | 74 | /// Font family to use for text annotations 75 | #[arg(long)] 76 | pub font_family: Option, 77 | 78 | /// Font style to use for text annotations 79 | #[arg(long)] 80 | pub font_style: Option, 81 | 82 | /// The primary highlighter to use, secondary is accessible with CTRL 83 | #[arg(long)] 84 | pub primary_highlighter: Option, 85 | 86 | /// Disable notifications 87 | #[arg(long)] 88 | pub disable_notifications: bool, 89 | 90 | /// Print profiling 91 | #[arg(long)] 92 | pub profile_startup: bool, 93 | 94 | /// Disable the window decoration (title bar, borders, etc.) 95 | /// Please note that the compositor has the final say in this. 96 | /// Requires xdg-decoration-unstable-v1 97 | #[arg(long)] 98 | pub no_window_decoration: bool, 99 | 100 | /// Experimental feature: How many points to use for the brush smoothing 101 | /// algorithm. 102 | /// 0 disables smoothing. 103 | /// The default value is 0 (disabled). 104 | #[arg(long)] 105 | pub brush_smooth_history_size: Option, 106 | 107 | // --- deprecated options --- 108 | /// Right click to copy. 109 | /// Preferably use the `action_on_right_click` option instead. 110 | #[arg(long)] 111 | pub right_click_copy: bool, 112 | /// Action to perform when pressing Enter. 113 | /// Preferably use the `actions_on_enter` option instead. 114 | #[arg(long, value_delimiter = ',')] 115 | pub action_on_enter: Option, 116 | 117 | /// Experimental feature (NEXTRELEASE): The zoom factor to use for the image. 118 | /// 1.0 means no zoom. 119 | /// defaults to 1.1 120 | #[arg(long)] 121 | pub zoom_factor: Option, 122 | 123 | /// Experimental feature (NEXTRELEASE): The pan step size to use when panning with arrow keys. 124 | /// defaults to 50.0 125 | #[arg(long)] 126 | pub pan_step_size: Option, 127 | // --- 128 | } 129 | 130 | #[derive(Debug, Clone, Copy, Default, ValueEnum)] 131 | pub enum Tools { 132 | #[default] 133 | Pointer, 134 | Crop, 135 | Line, 136 | Arrow, 137 | Rectangle, 138 | Ellipse, 139 | Text, 140 | Marker, 141 | Blur, 142 | Highlight, 143 | Brush, 144 | } 145 | 146 | #[derive(Debug, Clone, Copy, ValueEnum)] 147 | pub enum Action { 148 | SaveToClipboard, 149 | SaveToFile, 150 | Exit, 151 | } 152 | 153 | #[derive(Debug, Clone, Copy, Default, ValueEnum)] 154 | pub enum Highlighters { 155 | #[default] 156 | Block, 157 | Freehand, 158 | } 159 | 160 | impl std::fmt::Display for Tools { 161 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 162 | use Tools::*; 163 | let s = match self { 164 | Pointer => "pointer", 165 | Crop => "crop", 166 | Line => "line", 167 | Arrow => "arrow", 168 | Rectangle => "rectangle", 169 | Ellipse => "ellipse", 170 | Text => "text", 171 | Marker => "marker", 172 | Blur => "blur", 173 | Highlight => "highlight", 174 | Brush => "brush", 175 | }; 176 | f.write_str(s) 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/style.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use femtovg::Paint; 4 | use gdk_pixbuf::{ 5 | glib::{Variant, VariantTy}, 6 | prelude::{StaticVariantType, ToVariant}, 7 | }; 8 | use glib::variant::FromVariant; 9 | use hex_color::HexColor; 10 | use relm4::gtk::gdk::RGBA; 11 | 12 | use crate::configuration::APP_CONFIG; 13 | 14 | #[derive(Clone, Copy, Debug)] 15 | pub struct Style { 16 | pub color: Color, 17 | pub size: Size, 18 | pub fill: bool, 19 | pub annotation_size_factor: f32, 20 | } 21 | 22 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] 23 | pub struct Color { 24 | pub r: u8, 25 | pub g: u8, 26 | pub b: u8, 27 | pub a: u8, 28 | } 29 | 30 | #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Default)] 31 | pub enum Size { 32 | Small = 0, 33 | #[default] 34 | Medium = 1, 35 | Large = 2, 36 | } 37 | 38 | impl Default for Style { 39 | fn default() -> Self { 40 | Self { 41 | color: Color::default(), 42 | size: Size::default(), 43 | fill: APP_CONFIG.read().default_fill_shapes(), 44 | annotation_size_factor: APP_CONFIG.read().annotation_size_factor(), 45 | } 46 | } 47 | } 48 | 49 | impl Default for Color { 50 | fn default() -> Self { 51 | APP_CONFIG 52 | .read() 53 | .color_palette() 54 | .palette() 55 | .first() 56 | .copied() 57 | .unwrap_or(Color::red()) 58 | } 59 | } 60 | 61 | impl StaticVariantType for Color { 62 | fn static_variant_type() -> Cow<'static, VariantTy> { 63 | Cow::Borrowed(VariantTy::TUPLE) 64 | } 65 | } 66 | impl ToVariant for Color { 67 | fn to_variant(&self) -> Variant { 68 | (self.r, self.g, self.b, self.a).to_variant() 69 | } 70 | } 71 | 72 | impl FromVariant for Color { 73 | fn from_variant(variant: &Variant) -> Option { 74 | <(u8, u8, u8, u8)>::from_variant(variant).map(|(r, g, b, a)| Self { r, g, b, a }) 75 | } 76 | } 77 | 78 | impl Color { 79 | pub fn new(r: u8, g: u8, b: u8, a: u8) -> Self { 80 | Self { r, g, b, a } 81 | } 82 | 83 | pub fn from_gdk(rgba: RGBA) -> Self { 84 | Self::new( 85 | (rgba.red() * 255.0) as u8, 86 | (rgba.green() * 255.0) as u8, 87 | (rgba.blue() * 255.0) as u8, 88 | (rgba.alpha() * 255.0) as u8, 89 | ) 90 | } 91 | 92 | pub fn orange() -> Self { 93 | Self::new(240, 147, 43, 255) 94 | } 95 | pub fn red() -> Self { 96 | Self::new(235, 77, 75, 255) 97 | } 98 | pub fn green() -> Self { 99 | Self::new(106, 176, 76, 255) 100 | } 101 | pub fn blue() -> Self { 102 | Self::new(34, 166, 179, 255) 103 | } 104 | pub fn cove() -> Self { 105 | Self::new(19, 15, 64, 255) 106 | } 107 | 108 | pub fn pink() -> Self { 109 | Self::new(200, 37, 184, 255) 110 | } 111 | 112 | pub fn to_rgba_f64(self) -> (f64, f64, f64, f64) { 113 | ( 114 | (self.r as f64) / 255.0, 115 | (self.g as f64) / 255.0, 116 | (self.b as f64) / 255.0, 117 | (self.a as f64) / 255.0, 118 | ) 119 | } 120 | pub fn to_rgba_u32(self) -> u32 { 121 | ((self.r as u32) << 24) | ((self.g as u32) << 16) | ((self.b as u32) << 8) | (self.a as u32) 122 | } 123 | } 124 | 125 | impl From for Color { 126 | fn from(value: RGBA) -> Self { 127 | Self::new( 128 | (value.red() * 255.0) as u8, 129 | (value.green() * 255.0) as u8, 130 | (value.blue() * 255.0) as u8, 131 | (value.alpha() * 255.0) as u8, 132 | ) 133 | } 134 | } 135 | 136 | impl From for RGBA { 137 | fn from(color: Color) -> Self { 138 | Self::new( 139 | color.r as f32 / 255.0, 140 | color.g as f32 / 255.0, 141 | color.b as f32 / 255.0, 142 | color.a as f32 / 255.0, 143 | ) 144 | } 145 | } 146 | 147 | impl From for femtovg::Color { 148 | fn from(value: Color) -> Self { 149 | femtovg::Color { 150 | r: value.r as f32 / 255.0, 151 | g: value.g as f32 / 255.0, 152 | b: value.b as f32 / 255.0, 153 | a: value.a as f32 / 255.0, 154 | } 155 | } 156 | } 157 | 158 | impl From for Color { 159 | fn from(value: HexColor) -> Self { 160 | Self::new(value.r, value.g, value.b, value.a) 161 | } 162 | } 163 | 164 | impl From