use std::{
collections::HashMap,
fs,
io::{self, Write},
path::Path,
};
use clap::Parser;
use hex_color::HexColor;
use relm4::SharedState;
use serde::Deserialize;
use serde::de::Deserializer;
use thiserror::Error;
use xdg::{BaseDirectories, BaseDirectoriesError};
use crate::{
style::Color,
tools::{Highlighters, Tools},
};
use satty_cli::command_line::{
Action as CommandLineAction, CommandLine, EarlyExitTriggers, Fullscreen, NotificationThumbnail,
Resize,
};
pub static APP_CONFIG: SharedState<Configuration> = 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 {
man: bool,
license: bool,
input_filename: Option<String>,
output_filename: Option<String>,
fullscreen: Option<Fullscreen>,
resize: Option<Resize>,
floating_hack: bool,
early_exit: EarlyExit,
corner_roundness: f32,
initial_tool: Tools,
copy_command: Option<String>,
annotation_size_factor: f32,
save_after_copy: bool,
auto_copy: bool,
actions_on_enter: Vec<Action>,
actions_on_escape: Vec<Action>,
actions_on_right_click: Vec<Action>,
color_palette: ColorPalette,
default_hide_toolbars: bool,
focus_toggles_toolbars: bool,
default_fill_shapes: bool,
default_round_caps: bool,
font: FontConfiguration,
primary_highlighter: Highlighters,
disable_notifications: bool,
profile_startup: bool,
no_window_decoration: bool,
brush_smooth_history_size: usize,
keybinds: Keybinds,
zoom_factor: f32,
pan_step_size: f32,
text_move_length: f32,
input_scale: Option<f32>,
title: Option<String>,
app_id: Option<String>,
notification_thumbnail: NotificationThumbnail,
}
pub struct Keybinds {
shortcuts: HashMap<char, Tools>,
}
impl Keybinds {
pub fn get_tool(&self, key: char) -> Option<Tools> {
self.shortcuts.get(&key).copied()
}
pub fn shortcuts(&self) -> &HashMap<char, Tools> {
&self.shortcuts
}
fn update_keybind(&mut self, key: Option<String>, tool: Tools) {
if let Some(key_str) = key
&& let Some(validated_key) = Self::validate_keybind(&key_str, tool)
{
self.shortcuts.retain(|_, v| *v != tool);
self.shortcuts.insert(validated_key, tool);
}
}
fn validate_keybind(key: &str, tool: Tools) -> Option<char> {
let mut chars = key.chars();
match (chars.next(), chars.next()) {
(Some(c), None) => Some(c),
_ => {
eprintln!(
"Warning: Invalid keybind: '{} = {}'. Keybinds must be single characters. Using default keybind instead.",
tool, key
);
None
}
}
}
fn merge(&mut self, file_keybinds: KeybindsFile) {
self.update_keybind(file_keybinds.pointer, Tools::Pointer);
self.update_keybind(file_keybinds.crop, Tools::Crop);
self.update_keybind(file_keybinds.brush, Tools::Brush);
self.update_keybind(file_keybinds.line, Tools::Line);
self.update_keybind(file_keybinds.arrow, Tools::Arrow);
self.update_keybind(file_keybinds.rectangle, Tools::Rectangle);
self.update_keybind(file_keybinds.ellipse, Tools::Ellipse);
self.update_keybind(file_keybinds.text, Tools::Text);
self.update_keybind(file_keybinds.marker, Tools::Marker);
self.update_keybind(file_keybinds.blur, Tools::Blur);
self.update_keybind(file_keybinds.highlight, Tools::Highlight);
}
}
impl Default for Keybinds {
fn default() -> Self {
let mut shortcuts = HashMap::new();
shortcuts.insert('p', Tools::Pointer);
shortcuts.insert('c', Tools::Crop);
shortcuts.insert('b', Tools::Brush);
shortcuts.insert('i', Tools::Line);
shortcuts.insert('z', Tools::Arrow);
shortcuts.insert('r', Tools::Rectangle);
shortcuts.insert('e', Tools::Ellipse);
shortcuts.insert('t', Tools::Text);
shortcuts.insert('m', Tools::Marker);
shortcuts.insert('u', Tools::Blur);
shortcuts.insert('g', Tools::Highlight);
Self { shortcuts }
}
}
#[derive(Default)]
pub struct FontConfiguration {
family: Option<String>,
style: Option<String>,
fallback: Vec<String>,
}
impl FontConfiguration {
pub fn family(&self) -> Option<&str> {
self.family.as_deref()
}
pub fn style(&self) -> Option<&str> {
self.style.as_deref()
}
pub fn fallback(&self) -> &[String] {
&self.fallback
}
fn merge(&mut self, file_font: FontFile) {
if let Some(v) = file_font.family {
self.family = Some(v);
}
if let Some(v) = file_font.style {
self.style = Some(v);
}
if let Some(v) = file_font.fallback {
self.fallback = v
}
}
}
pub struct ColorPalette {
palette: Vec<Color>,
custom: Vec<Color>,
}
impl ColorPalette {
pub fn palette(&self) -> &[Color] {
&self.palette
}
pub fn custom(&self) -> &[Color] {
&self.custom
}
fn merge(&mut self, file_palette: ColorPaletteFile) {
if let Some(v) = file_palette.palette {
self.palette = v.into_iter().map(Color::from).collect();
}
if let Some(v) = file_palette.custom {
self.custom = v.into_iter().map(Color::from).collect();
}
}
}
#[derive(Deserialize)]
#[serde(untagged)]
#[serde(rename_all = "kebab-case")]
enum FullscreenCompat {
Bool(bool),
Mode(Fullscreen),
}
fn de_fullscreen_mode<'de, D>(d: D) -> Result<Option<Fullscreen>, D::Error>
where
D: Deserializer<'de>,
{
match FullscreenCompat::deserialize(d)? {
FullscreenCompat::Bool(true) => Ok(Some(Fullscreen::CurrentScreen)),
FullscreenCompat::Bool(false) => Ok(None),
FullscreenCompat::Mode(m) => Ok(Some(m)),
}
}
#[derive(Deserialize)]
#[serde(untagged)]
#[serde(rename_all = "kebab-case")]
enum EarlyExitCompat {
Bool(bool),
Triggers(Vec<EarlyExitTriggers>),
}
fn de_early_exit<'de, D>(d: D) -> Result<Option<Vec<EarlyExitTriggers>>, D::Error>
where
D: Deserializer<'de>,
{
match EarlyExitCompat::deserialize(d)? {
EarlyExitCompat::Bool(true) => Ok(Some(vec![EarlyExitTriggers::All])),
EarlyExitCompat::Bool(false) => Ok(None),
EarlyExitCompat::Triggers(m) => Ok(Some(m)),
}
}
#[derive(Debug, Clone, Copy, Default, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct EarlyExit {
copy: bool,
save: bool,
save_as: bool,
}
impl EarlyExit {
pub fn copy(&self) -> bool {
self.copy
}
pub fn save(&self) -> bool {
self.save
}
pub fn save_as(&self) -> bool {
self.save_as
}
}
impl From<Vec<EarlyExitTriggers>> for EarlyExit {
fn from(val: Vec<EarlyExitTriggers>) -> Self {
let mut ee = EarlyExit::default();
for e in val {
match e {
EarlyExitTriggers::All => {
ee.copy = true;
ee.save = true;
ee.save_as = true;
}
EarlyExitTriggers::Copy => {
ee.copy = true;
}
EarlyExitTriggers::Save => {
ee.save = true;
}
EarlyExitTriggers::SaveAs => {
ee.save_as = true;
}
}
}
ee
}
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum Action {
SaveToClipboard,
SaveToFile,
SaveToFileAs,
CopyFilepathToClipboard,
Exit,
}
impl From<CommandLineAction> for Action {
fn from(action: CommandLineAction) -> Self {
match action {
CommandLineAction::SaveToClipboard => Self::SaveToClipboard,
CommandLineAction::SaveToFile => Self::SaveToFile,
CommandLineAction::SaveToFileAs => Self::SaveToFileAs,
CommandLineAction::CopyFilepathToClipboard => Self::CopyFilepathToClipboard,
CommandLineAction::Exit => Self::Exit,
}
}
}
impl Configuration {
pub fn load() {
let command_line = match CommandLine::try_parse() {
Ok(cmd) => cmd,
Err(e) => e.exit(),
};
let file = match ConfigurationFile::try_read(&command_line.config) {
Ok(c) => c,
Err(ConfigurationFileError::ReadFile(e)) if e.kind() == io::ErrorKind::NotFound => {
eprintln!("config file not found");
None
}
Err(e) => {
eprintln!("Error reading config file: {e}");
let _ = std::io::stdout().lock().flush();
let _ = std::io::stderr().lock().flush();
std::process::exit(3);
}
};
APP_CONFIG.write().merge(file, command_line);
}
fn merge_general(&mut self, general: ConfigurationFileGeneral) {
if let Some(v) = general.fullscreen {
self.fullscreen = Some(v);
}
if let Some(v) = general.resize {
self.resize = Some(v);
}
if let Some(v) = general.floating_hack {
self.floating_hack = v;
}
if let Some(v) = general.early_exit {
self.early_exit = v.into();
}
if let Some(v) = general.corner_roundness {
self.corner_roundness = 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;
}
if let Some(v) = general.save_after_copy {
self.save_after_copy = v;
}
if let Some(v) = general.auto_copy {
self.auto_copy = v;
}
if let Some(v) = general.actions_on_enter {
self.actions_on_enter = v;
}
if let Some(v) = general.actions_on_escape {
self.actions_on_escape = v;
}
if let Some(v) = general.actions_on_right_click {
self.actions_on_right_click = v;
}
if let Some(v) = general.default_hide_toolbars {
self.default_hide_toolbars = v;
}
if let Some(v) = general.focus_toggles_toolbars {
self.focus_toggles_toolbars = v;
}
if let Some(v) = general.default_fill_shapes {
self.default_fill_shapes = v;
}
if let Some(v) = general.default_round_caps {
self.default_round_caps = v;
}
if let Some(v) = general.primary_highlighter {
self.primary_highlighter = v;
}
if let Some(v) = general.disable_notifications {
self.disable_notifications = v;
}
if let Some(v) = general.no_window_decoration {
self.no_window_decoration = v;
}
if let Some(v) = general.brush_smooth_history_size {
self.brush_smooth_history_size = v;
}
if let Some(v) = general.zoom_factor {
self.zoom_factor = v;
}
if let Some(v) = general.pan_step_size {
self.pan_step_size = v;
}
if let Some(v) = general.text_move_length {
self.text_move_length = v;
}
if let Some(v) = general.input_scale {
self.input_scale = Some(v);
}
if let Some(v) = general.title {
self.title = Some(v);
}
if let Some(v) = general.app_id {
self.app_id = Some(v);
}
if let Some(v) = general.notification_thumbnail {
self.notification_thumbnail = v;
}
if let Some(v) = general.right_click_copy
&& v
&& !self
.actions_on_right_click
.contains(&Action::SaveToClipboard)
{
self.actions_on_right_click
.insert(0, Action::SaveToClipboard);
}
if let Some(v) = general.action_on_enter {
self.actions_on_enter.insert(0, v);
}
}
fn merge(&mut self, file: Option<ConfigurationFile>, command_line: CommandLine) {
self.input_filename = command_line.filename;
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);
}
if let Some(v) = file.font {
self.font.merge(v);
}
if let Some(v) = file.keybinds {
self.keybinds.merge(v);
}
}
if let Some(v) = command_line.fullscreen {
self.fullscreen = Some(v);
}
if let Some(v) = command_line.resize {
self.resize = Some(v);
}
if command_line.floating_hack {
self.floating_hack = command_line.floating_hack;
}
if command_line.man {
self.man = command_line.man;
}
if command_line.license {
self.license = command_line.license;
}
if let Some(v) = command_line.early_exit {
self.early_exit = v.into();
}
if let Some(v) = command_line.corner_roundness {
self.corner_roundness = v;
}
if command_line.default_hide_toolbars {
self.default_hide_toolbars = command_line.default_hide_toolbars;
}
if command_line.focus_toggles_toolbars {
self.focus_toggles_toolbars = command_line.focus_toggles_toolbars
}
if command_line.default_fill_shapes {
self.default_fill_shapes = command_line.default_fill_shapes;
}
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;
}
if command_line.save_after_copy {
self.save_after_copy = command_line.save_after_copy;
}
if command_line.auto_copy {
self.auto_copy = command_line.auto_copy;
}
if let Some(v) = command_line.actions_on_enter {
self.actions_on_enter = v.iter().cloned().map(Into::into).collect();
}
if let Some(v) = command_line.actions_on_escape {
self.actions_on_escape = v.iter().cloned().map(Into::into).collect();
}
if let Some(v) = command_line.actions_on_right_click {
self.actions_on_right_click = v.iter().cloned().map(Into::into).collect();
}
if let Some(v) = command_line.font_family {
self.font.family = Some(v);
}
if let Some(v) = command_line.font_style {
self.font.style = Some(v);
}
if let Some(v) = command_line.primary_highlighter {
self.primary_highlighter = v.into();
}
if command_line.disable_notifications {
self.disable_notifications = command_line.disable_notifications;
}
if command_line.profile_startup {
self.profile_startup = command_line.profile_startup;
}
if command_line.no_window_decoration {
self.no_window_decoration = command_line.no_window_decoration;
}
if let Some(v) = command_line.brush_smooth_history_size {
self.brush_smooth_history_size = v;
}
if let Some(v) = command_line.zoom_factor {
self.zoom_factor = v;
}
if let Some(v) = command_line.pan_step_size {
self.pan_step_size = v;
}
if let Some(v) = command_line.text_move_length {
self.text_move_length = v;
}
if let Some(v) = command_line.input_scale {
self.input_scale = Some(v);
}
if let Some(v) = command_line.title {
self.title = Some(v);
}
if let Some(v) = command_line.app_id {
self.app_id = Some(v);
}
if let Some(v) = command_line.notification_thumbnail {
self.notification_thumbnail = v;
}
if command_line.right_click_copy
&& !self
.actions_on_right_click
.contains(&Action::SaveToClipboard)
{
self.actions_on_right_click
.insert(0, Action::SaveToClipboard);
}
if let Some(v) = command_line.action_on_enter {
self.actions_on_enter.insert(0, v.into());
}
}
pub fn man(&self) -> bool {
self.man
}
pub fn license(&self) -> bool {
self.license
}
pub fn early_exit_copy(&self) -> bool {
self.early_exit.copy()
}
pub fn early_exit_save(&self) -> bool {
self.early_exit.save()
}
pub fn early_exit_save_as(&self) -> bool {
self.early_exit.save_as()
}
pub fn corner_roundness(&self) -> f32 {
self.corner_roundness
}
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) -> Option<Fullscreen> {
self.fullscreen
}
pub fn resize(&self) -> Option<Resize> {
self.resize
}
pub fn floating_hack(&self) -> bool {
self.floating_hack
}
pub fn output_filename(&self) -> Option<&String> {
self.output_filename.as_ref()
}
pub fn input_filename(&self) -> &str {
match self.input_filename {
Some(ref v) => v,
None => "",
}
}
pub fn annotation_size_factor(&self) -> f32 {
self.annotation_size_factor
}
pub fn save_after_copy(&self) -> bool {
self.save_after_copy
}
pub fn auto_copy(&self) -> bool {
self.auto_copy
}
pub fn actions_on_enter(&self) -> Vec<Action> {
self.actions_on_enter.clone()
}
pub fn actions_on_escape(&self) -> Vec<Action> {
self.actions_on_escape.clone()
}
pub fn actions_on_right_click(&self) -> Vec<Action> {
self.actions_on_right_click.clone()
}
pub fn color_palette(&self) -> &ColorPalette {
&self.color_palette
}
pub fn default_hide_toolbars(&self) -> bool {
self.default_hide_toolbars
}
pub fn focus_toggles_toolbars(&self) -> bool {
self.focus_toggles_toolbars
}
pub fn default_fill_shapes(&self) -> bool {
self.default_fill_shapes
}
pub fn default_round_caps(&self) -> bool {
self.default_round_caps
}
pub fn primary_highlighter(&self) -> Highlighters {
self.primary_highlighter
}
pub fn disable_notifications(&self) -> bool {
self.disable_notifications
}
pub fn profile_startup(&self) -> bool {
self.profile_startup
}
pub fn no_window_decoration(&self) -> bool {
self.no_window_decoration
}
pub fn font(&self) -> &FontConfiguration {
&self.font
}
pub fn brush_smooth_history_size(&self) -> usize {
self.brush_smooth_history_size
}
pub fn keybinds(&self) -> &Keybinds {
&self.keybinds
}
pub fn zoom_factor(&self) -> f32 {
self.zoom_factor
}
pub fn pan_step_size(&self) -> f32 {
self.pan_step_size
}
pub fn text_move_length(&self) -> f32 {
self.text_move_length
}
pub fn input_scale(&self) -> Option<f32> {
self.input_scale
}
pub fn title(&self) -> Option<&String> {
self.title.as_ref()
}
pub fn app_id(&self) -> Option<&String> {
self.app_id.as_ref()
}
pub fn notification_thumbnail(&self) -> NotificationThumbnail {
self.notification_thumbnail
}
}
impl Default for Configuration {
fn default() -> Self {
Self {
man: false,
license: false,
input_filename: Some(String::new()),
output_filename: None,
fullscreen: None,
resize: None,
floating_hack: false,
early_exit: EarlyExit::default(),
corner_roundness: 12.0,
initial_tool: Tools::Pointer,
copy_command: None,
annotation_size_factor: 1.0,
save_after_copy: false,
auto_copy: false,
actions_on_enter: vec![],
actions_on_escape: vec![Action::Exit],
actions_on_right_click: vec![],
color_palette: ColorPalette::default(),
default_hide_toolbars: false,
focus_toggles_toolbars: false,
default_fill_shapes: false,
default_round_caps: true,
font: FontConfiguration::default(),
primary_highlighter: Highlighters::Block,
disable_notifications: false,
profile_startup: false,
no_window_decoration: false,
brush_smooth_history_size: 0, keybinds: Keybinds::default(),
zoom_factor: 1.1,
pan_step_size: 50.,
text_move_length: 50.0,
input_scale: None,
title: None,
app_id: None,
notification_thumbnail: NotificationThumbnail::default(),
}
}
}
impl Default for ColorPalette {
fn default() -> Self {
Self {
palette: vec![
Color::orange(),
Color::red(),
Color::green(),
Color::blue(),
Color::cove(),
],
custom: vec![],
}
}
}
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
struct ConfigurationFile {
general: Option<ConfigurationFileGeneral>,
color_palette: Option<ColorPaletteFile>,
font: Option<FontFile>,
keybinds: Option<KeybindsFile>,
}
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
struct KeybindsFile {
pointer: Option<String>,
crop: Option<String>,
brush: Option<String>,
line: Option<String>,
arrow: Option<String>,
rectangle: Option<String>,
ellipse: Option<String>,
text: Option<String>,
marker: Option<String>,
blur: Option<String>,
highlight: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
struct FontFile {
family: Option<String>,
style: Option<String>,
fallback: Option<Vec<String>>,
}
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
struct ConfigurationFileGeneral {
#[serde(deserialize_with = "de_fullscreen_mode", default)]
fullscreen: Option<Fullscreen>,
resize: Option<Resize>,
floating_hack: Option<bool>,
#[serde(deserialize_with = "de_early_exit", default)]
early_exit: Option<Vec<EarlyExitTriggers>>,
corner_roundness: Option<f32>,
initial_tool: Option<Tools>,
copy_command: Option<String>,
annotation_size_factor: Option<f32>,
save_after_copy: Option<bool>,
auto_copy: Option<bool>,
output_filename: Option<String>,
actions_on_enter: Option<Vec<Action>>,
actions_on_escape: Option<Vec<Action>>,
actions_on_right_click: Option<Vec<Action>>,
default_hide_toolbars: Option<bool>,
focus_toggles_toolbars: Option<bool>,
default_fill_shapes: Option<bool>,
default_round_caps: Option<bool>,
primary_highlighter: Option<Highlighters>,
disable_notifications: Option<bool>,
no_window_decoration: Option<bool>,
brush_smooth_history_size: Option<usize>,
zoom_factor: Option<f32>,
pan_step_size: Option<f32>,
text_move_length: Option<f32>,
input_scale: Option<f32>,
title: Option<String>,
app_id: Option<String>,
notification_thumbnail: Option<NotificationThumbnail>,
right_click_copy: Option<bool>,
action_on_enter: Option<Action>,
}
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
struct ColorPaletteFile {
palette: Option<Vec<HexColor>>,
custom: Option<Vec<HexColor>>,
}
impl ConfigurationFile {
fn try_read(
specified_path: &Option<String>,
) -> Result<Option<ConfigurationFile>, ConfigurationFileError> {
match specified_path {
None => Self::try_read_xdg(),
Some(p) => Self::try_read_path(p),
}
}
fn try_read_xdg() -> Result<Option<ConfigurationFile>, ConfigurationFileError> {
let dirs = BaseDirectories::with_prefix(env!("CARGO_PKG_NAME"));
match dirs.get_config_file("config.toml") {
Some(path) => Self::try_read_path(path),
None => Ok(None),
}
}
fn try_read_path<P: AsRef<Path>>(
path: P,
) -> Result<Option<ConfigurationFile>, ConfigurationFileError> {
let content = fs::read_to_string(path)?;
Ok(Some(toml::from_str::<ConfigurationFile>(&content)?))
}
}