Add the settings catalog
Every setting is described once in FIELDS: its config.toml section and key, label, help text, whether it's an expert setting, and how to read and write it as text. The config file and the Settings screen are both generated from this table, so adding a setting is one field, one default and one table entry. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
35d55c5122
commit
cc8e6abc4f
2 files changed
+914
No files matched your search
@@ -8,6 +8,7 @@ mod analysis;
|
||||
mod domain;
|
||||
mod export;
|
||||
mod reminders;
|
||||
mod settings;
|
||||
mod time;
|
||||
|
||||
fn main() {}
|
||||
+913
@@ -0,0 +1,913 @@
|
||||
//! Every setting skyshift has, with its default value.
|
||||
//!
|
||||
//! [`FIELDS`] describes each setting once: its name in `config.toml`, its
|
||||
//! label and help in the Settings screen, and how to read and change it.
|
||||
//! The config file and the Settings screen are both built from that list, so
|
||||
//! adding a setting takes three steps: a field in [`Settings`], its default
|
||||
//! in `Settings::default`, and an entry in [`FIELDS`].
|
||||
|
||||
use jiff::SignedDuration;
|
||||
use jiff::civil::Weekday;
|
||||
use jiff::fmt::friendly::{Direction, SpanPrinter};
|
||||
use ratatui::style::Color;
|
||||
|
||||
use crate::export::ExportFormat;
|
||||
use crate::export::clipboard::ClipboardMode;
|
||||
use crate::export::timesheet::{CopyPreset, Rounding};
|
||||
use crate::reminders::PomodoroSettings;
|
||||
use crate::time::clock::parse_zone;
|
||||
use crate::time::{DecimalSeparator, DurationStyle, Formatter, PunchPrecision, TimeFormat};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Settings {
|
||||
// Display
|
||||
pub time_format: TimeFormat,
|
||||
pub duration_style: DurationStyle,
|
||||
pub week_start: Weekday,
|
||||
pub show_seconds: bool,
|
||||
pub big_timer: bool,
|
||||
pub accent_color: Color,
|
||||
pub mouse: bool,
|
||||
// Keys
|
||||
pub which_key: bool,
|
||||
pub which_key_delay: SignedDuration,
|
||||
pub adjust_step: SignedDuration,
|
||||
// Goals
|
||||
pub daily_target: SignedDuration,
|
||||
pub weekly_target: SignedDuration,
|
||||
// Clock
|
||||
pub timezone: String,
|
||||
pub clock_adjustment: SignedDuration,
|
||||
pub punch_precision: PunchPrecision,
|
||||
// Work day
|
||||
pub new_day_after_break: SignedDuration,
|
||||
// Timesheet
|
||||
pub copy_preset: CopyPreset,
|
||||
pub rounding: Rounding,
|
||||
pub rounding_step: SignedDuration,
|
||||
pub decimal_separator: DecimalSeparator,
|
||||
pub decimal_places: u8,
|
||||
pub custom_template: String,
|
||||
// Export
|
||||
pub export_format: ExportFormat,
|
||||
pub export_folder: String,
|
||||
// Reminders
|
||||
pub pomodoro: bool,
|
||||
pub focus_length: SignedDuration,
|
||||
pub short_break: SignedDuration,
|
||||
pub long_break: SignedDuration,
|
||||
pub long_break_every: u32,
|
||||
pub snooze: SignedDuration,
|
||||
pub still_working_check: SignedDuration,
|
||||
pub reminder_bell: bool,
|
||||
// Insights
|
||||
pub long_session: SignedDuration,
|
||||
// Storage and system
|
||||
pub data_file: String,
|
||||
pub backups: u32,
|
||||
pub clipboard: ClipboardMode,
|
||||
pub clipboard_command: String,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Settings {
|
||||
time_format: TimeFormat::Hour24,
|
||||
duration_style: DurationStyle::HoursMinutes,
|
||||
week_start: Weekday::Monday,
|
||||
show_seconds: true,
|
||||
big_timer: true,
|
||||
accent_color: Color::Cyan,
|
||||
mouse: true,
|
||||
which_key: true,
|
||||
which_key_delay: SignedDuration::from_millis(300),
|
||||
adjust_step: SignedDuration::from_mins(5),
|
||||
daily_target: SignedDuration::from_hours(8),
|
||||
weekly_target: SignedDuration::from_hours(40),
|
||||
timezone: "system".to_string(),
|
||||
clock_adjustment: SignedDuration::ZERO,
|
||||
punch_precision: PunchPrecision::Minute,
|
||||
new_day_after_break: SignedDuration::from_hours(8),
|
||||
copy_preset: CopyPreset::TotalClock,
|
||||
rounding: Rounding::None,
|
||||
rounding_step: SignedDuration::from_mins(15),
|
||||
decimal_separator: DecimalSeparator::Dot,
|
||||
decimal_places: 2,
|
||||
custom_template: "{date}{tab}{in}{tab}{out}{tab}{total}".to_string(),
|
||||
export_format: ExportFormat::Csv,
|
||||
export_folder: String::new(),
|
||||
pomodoro: true,
|
||||
focus_length: SignedDuration::from_mins(25),
|
||||
short_break: SignedDuration::from_mins(5),
|
||||
long_break: SignedDuration::from_mins(15),
|
||||
long_break_every: 4,
|
||||
snooze: SignedDuration::from_mins(5),
|
||||
still_working_check: SignedDuration::from_hours(10),
|
||||
reminder_bell: false,
|
||||
long_session: SignedDuration::from_hours(2),
|
||||
data_file: String::new(),
|
||||
backups: 14,
|
||||
clipboard: ClipboardMode::Auto,
|
||||
clipboard_command: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// The formatter for times and durations, following these settings.
|
||||
pub fn formatter(&self) -> Formatter {
|
||||
Formatter {
|
||||
time_format: self.time_format,
|
||||
duration_style: self.duration_style,
|
||||
decimal_places: self.decimal_places,
|
||||
decimal_separator: self.decimal_separator,
|
||||
}
|
||||
}
|
||||
|
||||
/// The pomodoro rhythm, from these settings.
|
||||
pub fn pomodoro(&self) -> PomodoroSettings {
|
||||
PomodoroSettings {
|
||||
enabled: self.pomodoro,
|
||||
focus: self.focus_length,
|
||||
short_break: self.short_break,
|
||||
long_break: self.long_break,
|
||||
long_break_every: self.long_break_every,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- How settings are described ------------------------------------------
|
||||
|
||||
/// One setting: where it lives in config.toml, how it's shown, and how to
|
||||
/// read and change it (always as text, whether it comes from the file or
|
||||
/// from the Settings screen).
|
||||
pub struct Field {
|
||||
/// The `[section]` in config.toml.
|
||||
pub section: &'static str,
|
||||
/// The name in config.toml.
|
||||
pub key: &'static str,
|
||||
/// The name in the Settings screen.
|
||||
pub label: &'static str,
|
||||
/// Shown under the settings list, and written as a comment in config.toml.
|
||||
pub help: &'static str,
|
||||
/// Hidden in the Settings screen until expert settings are shown.
|
||||
pub expert: bool,
|
||||
pub kind: Kind,
|
||||
/// The current value, as text.
|
||||
pub get: fn(&Settings) -> String,
|
||||
/// Reads text and stores it, or explains what's wrong with it.
|
||||
pub set: fn(&mut Settings, &str) -> Result<(), String>,
|
||||
}
|
||||
|
||||
/// What kind of value a setting holds. Decides how it's edited in the
|
||||
/// Settings screen and how it's written in config.toml.
|
||||
pub enum Kind {
|
||||
/// One value from a fixed list (cycled with left/right). Gives the names.
|
||||
Choice(fn() -> Vec<&'static str>),
|
||||
/// On or off.
|
||||
Toggle,
|
||||
/// A duration like "8h", "7h 30m" or "90m".
|
||||
Duration,
|
||||
/// A whole number.
|
||||
Number,
|
||||
/// Free text.
|
||||
Text,
|
||||
}
|
||||
|
||||
/// The sections of config.toml, in order, with a one-line description.
|
||||
pub const SECTIONS: [(&str, &str); 10] = [
|
||||
("display", "How things look"),
|
||||
("keys", "Keyboard"),
|
||||
("goals", "What you aim for"),
|
||||
("clock", "Where the time comes from"),
|
||||
("workday", "How work days open and close"),
|
||||
("timesheet", "What gets copied for your timesheet"),
|
||||
("export", "Report files"),
|
||||
("reminders", "Gentle nudges while you work"),
|
||||
("insights", "The Insights screen"),
|
||||
("advanced", "Storage and system"),
|
||||
];
|
||||
|
||||
pub static FIELDS: &[Field] = &[
|
||||
// ----- display -----
|
||||
Field {
|
||||
section: "display",
|
||||
key: "time_format",
|
||||
label: "Time format",
|
||||
help: "How clock times are shown and copied: 24h (22:30) or 12h (10:30 PM). When you type a time, both styles always work.",
|
||||
expert: false,
|
||||
kind: Kind::Choice(names::<TimeFormat>),
|
||||
get: |s| s.time_format.name().into(),
|
||||
set: |s, text| {
|
||||
s.time_format = choice(text)?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "display",
|
||||
key: "duration_style",
|
||||
label: "Durations",
|
||||
help: "How durations are shown: hours-minutes (7h 45m), clock (7:45) or decimal (7.75).",
|
||||
expert: false,
|
||||
kind: Kind::Choice(names::<DurationStyle>),
|
||||
get: |s| s.duration_style.name().into(),
|
||||
set: |s, text| {
|
||||
s.duration_style = choice(text)?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "display",
|
||||
key: "week_start",
|
||||
label: "Week starts on",
|
||||
help: "The first day of the week, for weekly totals and calendars.",
|
||||
expert: false,
|
||||
kind: Kind::Choice(names::<Weekday>),
|
||||
get: |s| s.week_start.name().into(),
|
||||
set: |s, text| {
|
||||
s.week_start = choice(text)?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "display",
|
||||
key: "show_seconds",
|
||||
label: "Show seconds",
|
||||
help: "Show seconds on the clock and on the running timer.",
|
||||
expert: false,
|
||||
kind: Kind::Toggle,
|
||||
get: |s| s.show_seconds.to_string(),
|
||||
set: |s, text| {
|
||||
s.show_seconds = toggle(text)?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "display",
|
||||
key: "big_timer",
|
||||
label: "Big timer",
|
||||
help: "Show the running timer in large digits on the Tracker screen. Off gives a compact view.",
|
||||
expert: false,
|
||||
kind: Kind::Toggle,
|
||||
get: |s| s.big_timer.to_string(),
|
||||
set: |s, text| {
|
||||
s.big_timer = toggle(text)?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "display",
|
||||
key: "accent_color",
|
||||
label: "Accent color",
|
||||
help: "The main highlight color: a name (cyan, green, magenta, yellow, blue, red, lightblue...) or a hex code like #5fafff.",
|
||||
expert: false,
|
||||
kind: Kind::Text,
|
||||
get: |s| s.accent_color.to_string().to_lowercase(),
|
||||
set: |s, text| {
|
||||
s.accent_color = text
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| format!("\"{text}\" isn't a color name or a #rrggbb code"))?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "display",
|
||||
key: "mouse",
|
||||
label: "Mouse",
|
||||
help: "Click buttons, tabs and rows with the mouse. Turn it off if it gets in the way of selecting text in your terminal. Takes effect after a restart.",
|
||||
expert: true,
|
||||
kind: Kind::Toggle,
|
||||
get: |s| s.mouse.to_string(),
|
||||
set: |s, text| {
|
||||
s.mouse = toggle(text)?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
// ----- keys -----
|
||||
Field {
|
||||
section: "keys",
|
||||
key: "which_key",
|
||||
label: "Key hints (which-key)",
|
||||
help: "After the first key of a sequence (like g, y or space), show a popup listing the keys that can follow. <space>w toggles it too.",
|
||||
expert: false,
|
||||
kind: Kind::Toggle,
|
||||
get: |s| s.which_key.to_string(),
|
||||
set: |s, text| {
|
||||
s.which_key = toggle(text)?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "keys",
|
||||
key: "which_key_delay",
|
||||
label: "Key hints delay",
|
||||
help: "How long to wait before showing key hints, like 300ms. 0 shows them right away.",
|
||||
expert: true,
|
||||
kind: Kind::Duration,
|
||||
get: |s| show_duration(s.which_key_delay),
|
||||
set: |s, text| {
|
||||
s.which_key_delay = duration_at_least_zero(text)?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "keys",
|
||||
key: "adjust_step",
|
||||
label: "Nudge step",
|
||||
help: "How far Ctrl-x and Ctrl-a move the start of the running session (\"I started 5 minutes earlier\"), like 5m.",
|
||||
expert: true,
|
||||
kind: Kind::Duration,
|
||||
get: |s| show_duration(s.adjust_step),
|
||||
set: |s, text| {
|
||||
let step = duration(text)?;
|
||||
if step < SignedDuration::from_mins(1) {
|
||||
return Err("the nudge step must be at least 1 minute".into());
|
||||
}
|
||||
s.adjust_step = step;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
// ----- goals -----
|
||||
Field {
|
||||
section: "goals",
|
||||
key: "daily_target",
|
||||
label: "Daily target",
|
||||
help: "How long you aim to work on a work day. Fills the progress bar and sets the balance. 0 turns it off.",
|
||||
expert: false,
|
||||
kind: Kind::Duration,
|
||||
get: |s| show_duration(s.daily_target),
|
||||
set: |s, text| {
|
||||
s.daily_target = duration_at_least_zero(text)?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "goals",
|
||||
key: "weekly_target",
|
||||
label: "Weekly target",
|
||||
help: "How long you aim to work in a week, for the weekly overview. 0 turns it off.",
|
||||
expert: false,
|
||||
kind: Kind::Duration,
|
||||
get: |s| show_duration(s.weekly_target),
|
||||
set: |s, text| {
|
||||
s.weekly_target = duration_at_least_zero(text)?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
// ----- clock -----
|
||||
Field {
|
||||
section: "clock",
|
||||
key: "timezone",
|
||||
label: "Time zone",
|
||||
help: "system (your computer's setting), a name like America/Toronto or Europe/Paris, or a fixed offset like -05:00.",
|
||||
expert: false,
|
||||
kind: Kind::Text,
|
||||
get: |s| s.timezone.clone(),
|
||||
set: |s, text| {
|
||||
parse_zone(text)?;
|
||||
s.timezone = text.trim().to_string();
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "clock",
|
||||
key: "adjustment",
|
||||
label: "Clock adjustment",
|
||||
help: "Added to the system clock, if you know it's off: like 2m 30s, or -1h. 0s means no adjustment.",
|
||||
expert: true,
|
||||
kind: Kind::Duration,
|
||||
get: |s| show_duration(s.clock_adjustment),
|
||||
set: |s, text| {
|
||||
let adjustment = duration(text)?;
|
||||
if adjustment.abs() > SignedDuration::from_hours(24) {
|
||||
return Err("the adjustment must be within 24 hours".into());
|
||||
}
|
||||
s.clock_adjustment = adjustment;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "clock",
|
||||
key: "punch_precision",
|
||||
label: "Punch precision",
|
||||
help: "minute: Start and Pause record whole minutes, so the times and totals you see always add up. second: record seconds too.",
|
||||
expert: true,
|
||||
kind: Kind::Choice(names::<PunchPrecision>),
|
||||
get: |s| s.punch_precision.name().into(),
|
||||
set: |s, text| {
|
||||
s.punch_precision = choice(text)?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
// ----- workday -----
|
||||
Field {
|
||||
section: "workday",
|
||||
key: "new_day_after_break",
|
||||
label: "New day after a break of",
|
||||
help: "Safety net for a forgotten End day: when a break lasts this long, the next Start opens a new work day. 0 turns it off.",
|
||||
expert: false,
|
||||
kind: Kind::Duration,
|
||||
get: |s| show_duration(s.new_day_after_break),
|
||||
set: |s, text| {
|
||||
s.new_day_after_break = duration_at_least_zero(text)?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
// ----- timesheet -----
|
||||
Field {
|
||||
section: "timesheet",
|
||||
key: "copy_preset",
|
||||
label: "Copy format",
|
||||
help: "What c copies for a day: total-clock (5:40), total-decimal (5.67), segments (22:00-01:30, 02:00-04:10), in-out-break (3 cells), or custom (the template below).",
|
||||
expert: false,
|
||||
kind: Kind::Choice(names::<CopyPreset>),
|
||||
get: |s| s.copy_preset.name().into(),
|
||||
set: |s, text| {
|
||||
s.copy_preset = choice(text)?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "timesheet",
|
||||
key: "rounding",
|
||||
label: "Rounding",
|
||||
help: "How copied totals are rounded to the rounding step: none, nearest, up or down.",
|
||||
expert: false,
|
||||
kind: Kind::Choice(names::<Rounding>),
|
||||
get: |s| s.rounding.name().into(),
|
||||
set: |s, text| {
|
||||
s.rounding = choice(text)?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "timesheet",
|
||||
key: "rounding_step",
|
||||
label: "Rounding step",
|
||||
help: "The unit copied totals are rounded to, like 15m, or 6m (a tenth of an hour).",
|
||||
expert: false,
|
||||
kind: Kind::Duration,
|
||||
get: |s| show_duration(s.rounding_step),
|
||||
set: |s, text| {
|
||||
let step = duration(text)?;
|
||||
if step < SignedDuration::from_mins(1) {
|
||||
return Err("the rounding step must be at least 1 minute".into());
|
||||
}
|
||||
s.rounding_step = step;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "timesheet",
|
||||
key: "decimal_separator",
|
||||
label: "Decimal separator",
|
||||
help: "The character in decimal hours: . (7.75) or , (7,75). With a comma, CSV exports put ; between columns, as spreadsheets expect.",
|
||||
expert: false,
|
||||
kind: Kind::Choice(names::<DecimalSeparator>),
|
||||
get: |s| s.decimal_separator.name().into(),
|
||||
set: |s, text| {
|
||||
s.decimal_separator = choice(text)?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "timesheet",
|
||||
key: "decimal_places",
|
||||
label: "Decimal places",
|
||||
help: "How many digits after the separator in decimal hours (0 to 4).",
|
||||
expert: true,
|
||||
kind: Kind::Number,
|
||||
get: |s| s.decimal_places.to_string(),
|
||||
set: |s, text| {
|
||||
s.decimal_places = number(text, 0, 4)? as u8;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "timesheet",
|
||||
key: "custom_template",
|
||||
label: "Custom template",
|
||||
help: "Used by the custom copy format. Placeholders: {date} {day} {in} {out} {break} {total} {decimal} {segments} {tab}.",
|
||||
expert: true,
|
||||
kind: Kind::Text,
|
||||
get: |s| s.custom_template.clone(),
|
||||
set: |s, text| {
|
||||
s.custom_template = text.to_string();
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
// ----- export -----
|
||||
Field {
|
||||
section: "export",
|
||||
key: "format",
|
||||
label: "Export format",
|
||||
help: "The file format exports start with: csv, json or markdown.",
|
||||
expert: false,
|
||||
kind: Kind::Choice(names::<ExportFormat>),
|
||||
get: |s| s.export_format.name().into(),
|
||||
set: |s, text| {
|
||||
s.export_format = choice(text)?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "export",
|
||||
key: "folder",
|
||||
label: "Export folder",
|
||||
help: "Where exports are saved. Empty means your Documents folder. ~ means your home folder.",
|
||||
expert: false,
|
||||
kind: Kind::Text,
|
||||
get: |s| s.export_folder.clone(),
|
||||
set: |s, text| {
|
||||
s.export_folder = text.trim().to_string();
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
// ----- reminders -----
|
||||
Field {
|
||||
section: "reminders",
|
||||
key: "pomodoro",
|
||||
label: "Pomodoro rhythm",
|
||||
help: "Suggest breaks in a pomodoro rhythm: focus, short break, focus... and a long break every few rounds. It only advises: you still press Start and Pause.",
|
||||
expert: false,
|
||||
kind: Kind::Toggle,
|
||||
get: |s| s.pomodoro.to_string(),
|
||||
set: |s, text| {
|
||||
s.pomodoro = toggle(text)?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "reminders",
|
||||
key: "focus_length",
|
||||
label: "Focus length",
|
||||
help: "How long to work before a break is suggested, like 25m, 50m or 90m. Interruptions shorter than a short break don't reset it.",
|
||||
expert: false,
|
||||
kind: Kind::Duration,
|
||||
get: |s| show_duration(s.focus_length),
|
||||
set: |s, text| {
|
||||
s.focus_length = duration_at_least(text, SignedDuration::from_mins(1))?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "reminders",
|
||||
key: "short_break",
|
||||
label: "Short break",
|
||||
help: "The suggested break after a focus stretch, like 5m.",
|
||||
expert: false,
|
||||
kind: Kind::Duration,
|
||||
get: |s| show_duration(s.short_break),
|
||||
set: |s, text| {
|
||||
s.short_break = duration_at_least(text, SignedDuration::from_mins(1))?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "reminders",
|
||||
key: "long_break",
|
||||
label: "Long break",
|
||||
help: "The suggested break after every few focus stretches, like 15m or 30m.",
|
||||
expert: false,
|
||||
kind: Kind::Duration,
|
||||
get: |s| show_duration(s.long_break),
|
||||
set: |s, text| {
|
||||
s.long_break = duration_at_least(text, SignedDuration::from_mins(1))?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "reminders",
|
||||
key: "long_break_every",
|
||||
label: "Long break every",
|
||||
help: "After how many focus stretches the break is a long one (1 to 12).",
|
||||
expert: false,
|
||||
kind: Kind::Number,
|
||||
get: |s| s.long_break_every.to_string(),
|
||||
set: |s, text| {
|
||||
s.long_break_every = number(text, 1, 12)?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "reminders",
|
||||
key: "snooze",
|
||||
label: "Snooze",
|
||||
help: "How long zz silences a break suggestion, like 5m.",
|
||||
expert: true,
|
||||
kind: Kind::Duration,
|
||||
get: |s| show_duration(s.snooze),
|
||||
set: |s, text| {
|
||||
s.snooze = duration_at_least(text, SignedDuration::from_mins(1))?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "reminders",
|
||||
key: "still_working_check",
|
||||
label: "Still working? check",
|
||||
help: "When a session runs this long, skyshift asks whether you forgot to pause, like 10h. 0 turns it off.",
|
||||
expert: false,
|
||||
kind: Kind::Duration,
|
||||
get: |s| show_duration(s.still_working_check),
|
||||
set: |s, text| {
|
||||
s.still_working_check = duration_at_least_zero(text)?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "reminders",
|
||||
key: "bell",
|
||||
label: "Reminder sound",
|
||||
help: "Also ring the terminal bell with reminders.",
|
||||
expert: false,
|
||||
kind: Kind::Toggle,
|
||||
get: |s| s.reminder_bell.to_string(),
|
||||
set: |s, text| {
|
||||
s.reminder_bell = toggle(text)?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
// ----- insights -----
|
||||
Field {
|
||||
section: "insights",
|
||||
key: "long_session",
|
||||
label: "Long session",
|
||||
help: "Sessions at least this long count as long stretches without a break (hyperfocus) in Insights, like 2h.",
|
||||
expert: true,
|
||||
kind: Kind::Duration,
|
||||
get: |s| show_duration(s.long_session),
|
||||
set: |s, text| {
|
||||
s.long_session = duration_at_least_zero(text)?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
// ----- advanced -----
|
||||
Field {
|
||||
section: "advanced",
|
||||
key: "data_file",
|
||||
label: "Data file",
|
||||
help: "Where your shifts are stored. Empty means the default place. A synced folder (Syncthing, Dropbox...) shares it between computers. Takes effect after a restart.",
|
||||
expert: true,
|
||||
kind: Kind::Text,
|
||||
get: |s| s.data_file.clone(),
|
||||
set: |s, text| {
|
||||
s.data_file = text.trim().to_string();
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "advanced",
|
||||
key: "backups",
|
||||
label: "Daily backups kept",
|
||||
help: "skyshift copies your data file once a day, when it starts. How many of those copies to keep (0 to 365).",
|
||||
expert: true,
|
||||
kind: Kind::Number,
|
||||
get: |s| s.backups.to_string(),
|
||||
set: |s, text| {
|
||||
s.backups = number(text, 0, 365)?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "advanced",
|
||||
key: "clipboard",
|
||||
label: "Clipboard",
|
||||
help: "auto (system, then terminal), system, terminal (OSC 52, works over SSH), or command (the command below). Takes effect after a restart.",
|
||||
expert: true,
|
||||
kind: Kind::Choice(names::<ClipboardMode>),
|
||||
get: |s| s.clipboard.name().into(),
|
||||
set: |s, text| {
|
||||
s.clipboard = choice(text)?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "advanced",
|
||||
key: "clipboard_command",
|
||||
label: "Clipboard command",
|
||||
help: "For the command clipboard: a program that reads text on its input, like wl-copy, or xclip -selection clipboard.",
|
||||
expert: true,
|
||||
kind: Kind::Text,
|
||||
get: |s| s.clipboard_command.clone(),
|
||||
set: |s, text| {
|
||||
s.clipboard_command = text.trim().to_string();
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ----- Reading values from text ---------------------------------------------
|
||||
|
||||
/// A setting that picks one value from a fixed list, written in config.toml
|
||||
/// by name (for example `time_format = "24h"`).
|
||||
pub trait Choice: Sized + Copy + PartialEq + 'static {
|
||||
/// Every value, in the order left/right cycles through them.
|
||||
const ALL: &'static [Self];
|
||||
|
||||
/// The name in config.toml.
|
||||
fn name(self) -> &'static str;
|
||||
|
||||
fn from_name(name: &str) -> Option<Self> {
|
||||
Self::ALL.iter().copied().find(|value| value.name().eq_ignore_ascii_case(name.trim()))
|
||||
}
|
||||
}
|
||||
|
||||
fn names<T: Choice>() -> Vec<&'static str> {
|
||||
T::ALL.iter().map(|value| value.name()).collect()
|
||||
}
|
||||
|
||||
fn choice<T: Choice>(text: &str) -> Result<T, String> {
|
||||
T::from_name(text).ok_or_else(|| format!("expected one of: {}", names::<T>().join(", ")))
|
||||
}
|
||||
|
||||
fn toggle(text: &str) -> Result<bool, String> {
|
||||
match text.trim().to_ascii_lowercase().as_str() {
|
||||
"true" | "on" | "yes" => Ok(true),
|
||||
"false" | "off" | "no" => Ok(false),
|
||||
_ => Err("expected true or false".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn number(text: &str, min: u32, max: u32) -> Result<u32, String> {
|
||||
let error = || format!("expected a whole number from {min} to {max}");
|
||||
let value: u32 = text.trim().parse().map_err(|_| error())?;
|
||||
if (min..=max).contains(&value) { Ok(value) } else { Err(error()) }
|
||||
}
|
||||
|
||||
/// Reads durations like "8h", "7h 30m", "90m", "-2m", "1h30m" or "0".
|
||||
fn duration(text: &str) -> Result<SignedDuration, String> {
|
||||
let text = text.trim();
|
||||
if text == "0" {
|
||||
return Ok(SignedDuration::ZERO);
|
||||
}
|
||||
text.parse().map_err(|_| format!("\"{text}\" isn't a duration; write it like 8h, 7h 30m or 90m"))
|
||||
}
|
||||
|
||||
fn duration_at_least_zero(text: &str) -> Result<SignedDuration, String> {
|
||||
duration_at_least(text, SignedDuration::ZERO)
|
||||
}
|
||||
|
||||
fn duration_at_least(text: &str, minimum: SignedDuration) -> Result<SignedDuration, String> {
|
||||
let value = duration(text)?;
|
||||
if value < minimum { Err(format!("this must be at least {}", show_duration(minimum))) } else { Ok(value) }
|
||||
}
|
||||
|
||||
/// Writes a duration the way `duration` reads it: "8h", "1h 30m", "-2m".
|
||||
pub fn show_duration(value: SignedDuration) -> String {
|
||||
static PRINTER: SpanPrinter = SpanPrinter::new().direction(Direction::Sign);
|
||||
PRINTER.duration_to_string(&value)
|
||||
}
|
||||
|
||||
// ----- The names each choice uses in config.toml ----------------------------
|
||||
|
||||
impl Choice for TimeFormat {
|
||||
const ALL: &'static [Self] = &[TimeFormat::Hour24, TimeFormat::Hour12];
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
TimeFormat::Hour24 => "24h",
|
||||
TimeFormat::Hour12 => "12h",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Choice for DurationStyle {
|
||||
const ALL: &'static [Self] = &[DurationStyle::HoursMinutes, DurationStyle::Clock, DurationStyle::Decimal];
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
DurationStyle::HoursMinutes => "hours-minutes",
|
||||
DurationStyle::Clock => "clock",
|
||||
DurationStyle::Decimal => "decimal",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Choice for Weekday {
|
||||
const ALL: &'static [Self] = &[
|
||||
Weekday::Monday,
|
||||
Weekday::Tuesday,
|
||||
Weekday::Wednesday,
|
||||
Weekday::Thursday,
|
||||
Weekday::Friday,
|
||||
Weekday::Saturday,
|
||||
Weekday::Sunday,
|
||||
];
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Weekday::Monday => "monday",
|
||||
Weekday::Tuesday => "tuesday",
|
||||
Weekday::Wednesday => "wednesday",
|
||||
Weekday::Thursday => "thursday",
|
||||
Weekday::Friday => "friday",
|
||||
Weekday::Saturday => "saturday",
|
||||
Weekday::Sunday => "sunday",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Choice for PunchPrecision {
|
||||
const ALL: &'static [Self] = &[PunchPrecision::Minute, PunchPrecision::Second];
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
PunchPrecision::Minute => "minute",
|
||||
PunchPrecision::Second => "second",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Choice for CopyPreset {
|
||||
const ALL: &'static [Self] = &CopyPreset::ALL;
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
CopyPreset::TotalClock => "total-clock",
|
||||
CopyPreset::TotalDecimal => "total-decimal",
|
||||
CopyPreset::Segments => "segments",
|
||||
CopyPreset::InOutBreak => "in-out-break",
|
||||
CopyPreset::Custom => "custom",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Choice for Rounding {
|
||||
const ALL: &'static [Self] = &[Rounding::None, Rounding::Nearest, Rounding::Up, Rounding::Down];
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Rounding::None => "none",
|
||||
Rounding::Nearest => "nearest",
|
||||
Rounding::Up => "up",
|
||||
Rounding::Down => "down",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Choice for DecimalSeparator {
|
||||
const ALL: &'static [Self] = &[DecimalSeparator::Dot, DecimalSeparator::Comma];
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
DecimalSeparator::Dot => ".",
|
||||
DecimalSeparator::Comma => ",",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Choice for ExportFormat {
|
||||
const ALL: &'static [Self] = &ExportFormat::ALL;
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
ExportFormat::Csv => "csv",
|
||||
ExportFormat::Json => "json",
|
||||
ExportFormat::Markdown => "markdown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Choice for ClipboardMode {
|
||||
const ALL: &'static [Self] =
|
||||
&[ClipboardMode::Auto, ClipboardMode::System, ClipboardMode::Terminal, ClipboardMode::Command];
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
ClipboardMode::Auto => "auto",
|
||||
ClipboardMode::System => "system",
|
||||
ClipboardMode::Terminal => "terminal",
|
||||
ClipboardMode::Command => "command",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_default_survives_a_round_trip_through_text() {
|
||||
let defaults = Settings::default();
|
||||
let mut copy = Settings::default();
|
||||
for field in FIELDS {
|
||||
let text = (field.get)(&defaults);
|
||||
(field.set)(&mut copy, &text).unwrap_or_else(|error| panic!("{}: {error}", field.key));
|
||||
}
|
||||
assert_eq!(copy, defaults);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_field_has_a_known_section_and_a_unique_key() {
|
||||
for (index, field) in FIELDS.iter().enumerate() {
|
||||
assert!(SECTIONS.iter().any(|(name, _)| *name == field.section), "{}", field.key);
|
||||
let duplicate = FIELDS[..index].iter().any(|other| other.key == field.key);
|
||||
assert!(!duplicate, "{} appears twice", field.key);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durations_read_like_people_write_them() {
|
||||
assert_eq!(duration("8h"), Ok(SignedDuration::from_hours(8)));
|
||||
assert_eq!(duration("7h 30m"), Ok(SignedDuration::from_mins(450)));
|
||||
assert_eq!(duration("90m"), Ok(SignedDuration::from_mins(90)));
|
||||
assert_eq!(duration("-2m"), Ok(SignedDuration::from_mins(-2)));
|
||||
assert_eq!(duration("0"), Ok(SignedDuration::ZERO));
|
||||
assert_eq!(show_duration(SignedDuration::from_mins(90)), "1h 30m");
|
||||
assert_eq!(show_duration(SignedDuration::from_mins(-2)), "-2m");
|
||||
assert!(duration("eight hours").is_err());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user