diff --git a/Cargo.lock b/Cargo.lock index 9b20e45..00cfba6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -482,6 +482,18 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "colony-ui" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31984ffb00f64c0fd0b998103ef11e1f4255e8f26c784a3776caf4b9ea28d60b" +dependencies = [ + "dirs", + "iced", + "serde", + "serde_json", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -650,6 +662,27 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -2225,6 +2258,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "orbclient" version = "0.3.53" @@ -2545,6 +2584,16 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -2685,9 +2734,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -2695,22 +2744,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -2719,6 +2768,7 @@ version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -2741,9 +2791,13 @@ dependencies = [ name = "shift" version = "0.1.0" dependencies = [ + "colony-ui", "iced", "image", "rfd", + "serde", + "serde_json", + "sys-locale", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7e36fe4..1d2cbb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,14 @@ repository = "https://github.com/Project-Colony/Shift" iced = { version = "0.14.0", features = ["image-without-codecs", "tokio"] } rfd = "0.17.2" image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] } +# The Colony theme, fonts, shared labels and Preferences kit. It is built on +# iced 0.14, so iced stays on 0.14 with it. +colony-ui = "0.1.6" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +# Reads the system language on Linux, Windows and macOS for the Automatic +# language setting. Already in the tree through iced. +sys-locale = "0.3.2" [profile.release] lto = "thin" diff --git a/README.md b/README.md index fee5fcc..9c2d66e 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,12 @@ no library and never changes your files. > **Status:** early and deliberately small. The viewer logic is covered by unit > tests that drive the real `update()` function: opening a file or a folder, -> unreadable images and empty folders, navigation, zoom, natural sort order and -> the status line. CI runs them on Linux, Windows and macOS and on the Rust 1.88 -> floor, with clippy and rustfmt. Drawing, the keyboard and mouse bindings, -> fullscreen and the file pickers have no automated tests. The interface is in -> French only for now. +> unreadable images and empty folders, navigation, zoom, natural sort order, the +> status line, opening and closing Preferences, and saving and reloading the +> preferences file, corrupt files included. CI runs them on Linux, Windows and +> macOS and on the Rust 1.88 floor, with clippy and rustfmt. Drawing, most of +> the keyboard and mouse bindings, fullscreen and the file pickers have no +> automated tests. The interface ships in English and French. ## Why Shift @@ -44,6 +45,10 @@ no library and never changes your files. - A fullscreen mode that hides the surrounding chrome. - A status line with the position in the folder, file name, dimensions, file size and last modified time. - Supported formats: PNG, JPEG, WebP, GIF and BMP. +- Preferences, behind the **Shift** button in the top-left corner: the Colony + themes and accent colours, text size, high contrast, a dyslexia-friendly + font, reduced motion and the interface language (English or French, or the + system's). A change applies and is saved at once. ### Keyboard shortcuts @@ -59,6 +64,9 @@ no library and never changes your files. | `0` / `F` | Toggle fit and manual zoom | | `1` / `R` | Reset zoom | | `M` | Toggle fullscreen | +| `Esc` | Close Preferences | + +While Preferences is open, the viewer keys are off and only `Esc` works. ## Installation @@ -141,11 +149,24 @@ Shift sends no telemetry, no analytics and no crash reports. |---|---|---| | The image you open | read | to show it, with its size and last modified time in the status line | | The folder it is in | read | listed to find the other supported images, so you can move through them | -| Settings and history | not kept | Shift writes no files: nothing is remembered between runs | +| Your preferences | stored on this computer | written when you change one, read at launch, in `preferences.json` (paths below) | +| Images and folders you opened | not kept | Shift remembers no file or folder between runs | + +The preferences file is the only file Shift writes: + +| Platform | Path | +|---|---| +| Linux | `~/.config/Colony/Shift/preferences/preferences.json` (or under `$XDG_CONFIG_HOME`) | +| Windows | `%LOCALAPPDATA%\Colony\Shift\preferences\preferences.json` | +| macOS | `~/Library/Application Support/Colony/Shift/preferences/preferences.json` | + +The About category of Preferences shows the path in use. Delete the file to go +back to the defaults. This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating -it. Shift has no network code at all: it connects to no server. +it. Shift has no network code at all: it connects to no server, and the +preferences file never leaves your computer. ## License diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..9f2f11d --- /dev/null +++ b/src/app.rs @@ -0,0 +1,1100 @@ +//! The viewer: its state, what each message does, and how it is drawn. + +use crate::format::{ + folder_loaded_status, image_details_label, image_loaded_status, image_name_label, + no_images_status, unreadable_status, zoom_label, +}; +use crate::gallery::{ + ImageInfo, SUPPORTED_EXTENSIONS, collect_images_in_dir, collect_images_in_same_dir, + read_image_info, +}; +use crate::i18n::{self, t, t_with}; +use crate::preferences::{self, Language, Prefs}; +use crate::style; +use colony_ui::preferences::Change; +use colony_ui::theme::Palette; +use colony_ui::widgets::{self, icons}; +use colony_ui::{Typography, fonts}; +use iced::{ + Alignment, Color, ContentFit, Element, Event, Length, Subscription, Task, event, keyboard, + mouse, + widget::{button, column, container, image, row, scrollable, stack, text, tooltip}, + window, +}; +use rfd::FileDialog; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +const MIN_ZOOM: f32 = 0.25; +const MAX_ZOOM: f32 = 6.0; +const ZOOM_STEP: f32 = 0.25; +const VIEWER_BASE_WIDTH: f32 = 960.0; +const VIEWER_BASE_HEIGHT: f32 = 640.0; +const STATUS_PLACEHOLDER: &str = ""; + +/// The program's name: a proper noun, the same in every language. +pub const NAME: &str = "Shift"; + +/// Font Awesome's chevron-left, in the Nerd Font's range. Its right-hand +/// twin ships with colony-ui. +const CHEVRON_LEFT: &str = "\u{f053}"; + +#[derive(Debug)] +pub struct Shift { + status: String, + current_path: Option, + current_folder: Option, + images_in_dir: Vec, + current_index: Option, + zoom: f32, + fit_to_view: bool, + fullscreen: bool, + image_info: Option, + prefs: Prefs, + /// Where preferences are saved. `None` keeps them in memory: in the tests, + /// and on a system without a configuration folder. + prefs_path: Option, + preferences_open: bool, + /// The selected Preferences category, an index into the category list. + category: usize, + /// The open sections of the Preferences page, by key. + expanded: HashSet, + /// The toast over the Preferences page. + notice: Option, +} + +impl Default for Shift { + fn default() -> Self { + Self { + status: STATUS_PLACEHOLDER.to_string(), + current_path: None, + current_folder: None, + images_in_dir: Vec::new(), + current_index: None, + zoom: 1.0, + fit_to_view: true, + fullscreen: false, + image_info: None, + prefs: Prefs::default(), + prefs_path: None, + preferences_open: false, + category: 0, + expanded: HashSet::new(), + notice: None, + } + } +} + +impl Shift { + /// The state at launch, with the saved preferences in effect before the + /// first frame is drawn. + pub fn boot() -> Self { + let prefs_path = preferences::file_path(); + let (prefs, load_error) = match prefs_path.as_deref().map(preferences::load) { + Some(Err(error)) => (Prefs::default(), Some(error)), + Some(Ok(prefs)) => (prefs, None), + None => (Prefs::default(), None), + }; + // Before the status below, so it is written in the chosen language. + prefs.apply(); + + Self { + status: load_error + .map(|error| t_with("preferences_load_failed", &[("error", &error)])) + .unwrap_or_default(), + prefs, + prefs_path, + ..Self::default() + } + } +} + +#[derive(Debug, Clone)] +pub enum Message { + OpenFile, + OpenFolder, + PreviousImage, + NextImage, + FirstImage, + LastImage, + ZoomIn, + ZoomOut, + ZoomBy(f32), + ResetZoom, + ToggleFit, + ToggleFullscreen, + EventOccurred(Event), + FilePicked(Option), + FolderPicked(Option), + /// Sent by the Shift button and by the page's Close button alike. + TogglePreferences, + Category(usize), + ToggleSection(&'static str), + Preference(Change), + Language(Language), + DismissNotice, +} + +pub fn update(state: &mut Shift, message: Message) -> Task { + if returns_to_viewer(&message) { + state.preferences_open = false; + } + + match message { + Message::EventOccurred(event) => { + if let Some(action) = keyboard_action(state, &event) { + return update(state, action); + } + + if let Some(action) = mouse_action(state, &event) { + return update(state, action); + } + + Task::none() + } + Message::OpenFile => Task::perform(pick_file(), Message::FilePicked), + Message::OpenFolder => Task::perform(pick_folder(), Message::FolderPicked), + Message::PreviousImage => { + if let Some(index) = state.current_index + && index > 0 + { + set_current_image(state, index - 1); + } + Task::none() + } + Message::NextImage => { + if let Some(index) = state.current_index + && index + 1 < state.images_in_dir.len() + { + set_current_image(state, index + 1); + } + Task::none() + } + Message::FirstImage => { + if !state.images_in_dir.is_empty() { + set_current_image(state, 0); + } + Task::none() + } + Message::LastImage => { + if let Some(last_index) = state.images_in_dir.len().checked_sub(1) { + set_current_image(state, last_index); + } + Task::none() + } + Message::ZoomIn => { + state.fit_to_view = false; + state.zoom = (state.zoom + ZOOM_STEP).min(MAX_ZOOM); + state.status = zoom_label(state.zoom); + Task::none() + } + Message::ZoomOut => { + state.fit_to_view = false; + state.zoom = (state.zoom - ZOOM_STEP).max(MIN_ZOOM); + state.status = zoom_label(state.zoom); + Task::none() + } + Message::ZoomBy(delta) => { + state.fit_to_view = false; + state.zoom = (state.zoom + delta).clamp(MIN_ZOOM, MAX_ZOOM); + state.status = zoom_label(state.zoom); + Task::none() + } + Message::ResetZoom => { + state.fit_to_view = false; + state.zoom = 1.0; + state.status = zoom_label(state.zoom); + Task::none() + } + Message::ToggleFit => { + state.fit_to_view = !state.fit_to_view; + state.status = if state.fit_to_view { + t("status_fit").to_string() + } else { + zoom_label(state.zoom) + }; + Task::none() + } + Message::ToggleFullscreen => { + state.fullscreen = !state.fullscreen; + state.status = if state.fullscreen { + t("status_fullscreen").to_string() + } else { + current_image_status(state) + }; + + let fullscreen = state.fullscreen; + + window::latest().then(move |window_id| { + window_id + .map(|id| { + window::set_mode( + id, + if fullscreen { + window::Mode::Fullscreen + } else { + window::Mode::Windowed + }, + ) + }) + .unwrap_or_else(Task::none) + }) + } + Message::FilePicked(Some(path)) => { + let images = collect_images_in_same_dir(&path); + let index = images + .iter() + .position(|candidate| candidate == &path) + .unwrap_or(0); + + state.images_in_dir = images; + state.current_folder = path.parent().map(Path::to_path_buf); + set_current_image(state, index); + Task::none() + } + Message::FilePicked(None) => { + if state.current_path.is_none() { + state.status = t("status_no_image_open").to_string(); + } else { + state.status = t("status_open_cancelled").to_string(); + } + Task::none() + } + Message::FolderPicked(Some(folder)) => { + let images = collect_images_in_dir(&folder); + + if images.is_empty() { + state.status = no_images_status(&folder); + state.current_folder = Some(folder); + state.current_path = None; + state.current_index = None; + state.images_in_dir.clear(); + state.image_info = None; + state.fit_to_view = true; + state.zoom = 1.0; + } else { + let total_images = images.len(); + state.images_in_dir = images; + state.current_folder = Some(folder.clone()); + + if set_current_image(state, 0) { + state.status = folder_loaded_status(&folder, total_images); + } + } + + Task::none() + } + Message::FolderPicked(None) => { + state.status = if state.current_path.is_some() { + t("status_folder_cancelled").to_string() + } else { + STATUS_PLACEHOLDER.to_string() + }; + Task::none() + } + Message::TogglePreferences => { + state.preferences_open = !state.preferences_open; + state.category = 0; + state.expanded.clear(); + state.notice = None; + Task::none() + } + Message::Category(index) => { + state.category = index; + Task::none() + } + Message::ToggleSection(key) => { + if !state.expanded.remove(key) { + state.expanded.insert(key.to_string()); + } + Task::none() + } + Message::Preference(change) => { + state.prefs.standard.update(change); + if matches!(change, Change::Theme { .. }) { + state.notice = Some(t("theme_applied").to_string()); + } + save_preferences(state); + Task::none() + } + Message::Language(language) => { + state.prefs.language = language; + i18n::set_locale(language.locale()); + save_preferences(state); + Task::none() + } + Message::DismissNotice => { + state.notice = None; + Task::none() + } + } +} + +/// The control-bar buttons act on the viewer, so pressing one while +/// Preferences is open goes back to it. Their keys are off while it is open. +fn returns_to_viewer(message: &Message) -> bool { + matches!( + message, + Message::OpenFile + | Message::OpenFolder + | Message::PreviousImage + | Message::NextImage + | Message::ResetZoom + | Message::ToggleFit + | Message::ToggleFullscreen + ) +} + +/// Writes the preferences after a change; there is no Save button. A failure +/// is shown on the page, and the change still applies until Shift closes. +fn save_preferences(state: &mut Shift) { + let Some(path) = &state.prefs_path else { + return; + }; + + if let Err(error) = preferences::save(path, &state.prefs) { + state.notice = Some(t_with( + "preferences_save_failed", + &[("error", &error.to_string())], + )); + } +} + +pub fn view(state: &Shift) -> Element<'_, Message> { + // Read once per frame: both text-size preferences and, with the dyslexia + // font on, OpenDyslexic in every weight. + let typo = Typography::current(); + let has_image = state.current_path.is_some(); + let show_controls_bar = state.preferences_open || !state.fullscreen || !has_image; + let framed = state.preferences_open || (has_image && !state.fullscreen); + + let controls_bar = container( + row![ + widgets::identity_button( + &typo, + NAME, + 15, + state.preferences_open, + Message::TogglePreferences + ), + open_buttons(&typo), + row![ + arrow_button( + &typo, + CHEVRON_LEFT, + t("previous_image"), + state + .current_index + .and_then(|index| (index > 0).then_some(Message::PreviousImage)), + ), + arrow_button( + &typo, + icons::CHEVRON_RIGHT, + t("next_image"), + state.current_index.and_then(|index| { + (index + 1 < state.images_in_dir.len()).then_some(Message::NextImage) + }), + ), + action_button( + &typo, + if state.fit_to_view { + zoom_label(1.0) + } else { + t("fit").to_string() + }, + has_image.then_some(if state.fit_to_view { + Message::ResetZoom + } else { + Message::ToggleFit + }), + ), + action_button( + &typo, + t(if state.fullscreen { + "window" + } else { + "fullscreen" + }) + .to_string(), + has_image.then_some(Message::ToggleFullscreen), + ), + ] + .spacing(10) + .align_y(Alignment::Center) + .wrap(), + ] + .width(Length::Fill) + .spacing(18) + .align_y(Alignment::Center) + .wrap(), + ) + .width(Length::Fill) + .padding([10, 12]) + .style(style::bar); + + let viewer: Element<'_, Message> = match &state.current_path { + Some(path) => { + let image_widget = image(path.clone()) + .width(viewer_width(state)) + .height(viewer_height(state)) + .content_fit(if state.fit_to_view { + ContentFit::ScaleDown + } else { + ContentFit::Contain + }); + + let framed_viewer: Element<'_, Message> = if state.fit_to_view { + container(image_widget) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .center_y(Length::Fill) + .padding(12) + .into() + } else { + scrollable( + container(image_widget) + .padding(12) + .width(Length::Shrink) + .height(Length::Shrink), + ) + .width(Length::Fill) + .height(Length::Fill) + .direction(scrollable::Direction::Both { + vertical: scrollable::Scrollbar::default(), + horizontal: scrollable::Scrollbar::default(), + }) + .into() + }; + + container(framed_viewer) + .width(Length::Fill) + .height(Length::Fill) + .style(style::viewer) + .into() + } + None => container( + column![ + text(NAME) + .size(typo.sz(34)) + .font(typo.bold) + .color(Palette::TEXT_PRIMARY()), + text(t("tagline")) + .size(typo.sz(16)) + .font(typo.regular) + .color(Palette::TEXT_MUTED()), + open_buttons(&typo), + ] + .spacing(14) + .align_x(Alignment::Center), + ) + .width(Length::Fill) + .height(360) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into(), + }; + + let meta_bar = state + .current_path + .as_deref() + .filter(|_| !state.fullscreen) + .map(|path| { + status_bar( + &typo, + viewer_meta_line(state, path), + Palette::TEXT_SECONDARY(), + ) + }); + + let footer = footer_visible(state) + .then(|| status_bar(&typo, state.status.clone(), Palette::TEXT_MUTED())); + + // Preferences replaces the viewer, its details and its status line; the + // control bar stays. + let content = if state.preferences_open { + column![controls_bar, preferences_view(state, &typo)] + } else { + column![ + show_controls_bar.then_some(controls_bar), + viewer, + meta_bar, + footer + ] + } + .spacing(if framed { 10 } else { 0 }) + .padding(if framed { 14 } else { 0 }) + // An open image and Preferences get the whole window, so fit mode can use + // all of it; the cap only keeps the empty start screen compact. + .max_width(if framed || has_image || state.fullscreen { + f32::INFINITY + } else { + 1280.0 + }); + + container(content) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .into() +} + +fn preferences_view<'a>(state: &Shift, typo: &Typography) -> Element<'a, Message> { + let page = preferences::page( + typo, + &state.prefs, + &state.expanded, + state.category, + state.prefs_path.as_deref(), + ); + + match &state.notice { + Some(notice) => stack![ + page, + container(widgets::toast(typo, notice, Message::DismissNotice)) + .padding(16) + .align_bottom(Length::Fill), + ] + .into(), + None => page, + } +} + +/// Open and Folder: on the control bar and on the start screen. +fn open_buttons<'a>(typo: &Typography) -> Element<'a, Message> { + row![ + action_button(typo, t("open").to_string(), Some(Message::OpenFile)), + action_button( + typo, + t("open_folder").to_string(), + Some(Message::OpenFolder) + ), + ] + .spacing(10) + .wrap() + .into() +} + +/// A labelled button, disabled when `on_press` is `None`. +fn action_button<'a>( + typo: &Typography, + label: String, + on_press: Option, +) -> Element<'a, Message> { + button(text(label).size(typo.sz(14)).font(typo.regular)) + .padding([6, 12]) + .style(style::action) + .on_press_maybe(on_press) + .into() +} + +/// A previous or next chevron. It has no words of its own, so its tooltip +/// says what it does. +fn arrow_button<'a>( + typo: &Typography, + glyph: &'static str, + hint: &'static str, + on_press: Option, +) -> Element<'a, Message> { + tooltip( + button(text(glyph).size(typo.sz(14)).font(fonts::glyph_font())) + .padding([6, 12]) + .style(style::action) + .on_press_maybe(on_press), + container(text(hint).size(typo.sz(12)).font(typo.regular)) + .padding([4, 8]) + .style(style::tooltip), + tooltip::Position::Bottom, + ) + .into() +} + +/// The details bar and the status line. +fn status_bar<'a>(typo: &Typography, line: String, color: Color) -> Element<'a, Message> { + container(text(line).size(typo.sz(13)).font(typo.regular).color(color)) + .width(Length::Fill) + .padding([6, 10]) + .style(style::bar) + .into() +} + +fn footer_visible(state: &Shift) -> bool { + state.status != STATUS_PLACEHOLDER && !state.fullscreen +} + +/// Position in the folder, file name, then the image details. +fn viewer_meta_line(state: &Shift, path: &Path) -> String { + let mut parts = Vec::with_capacity(3); + if let Some(index) = state.current_index { + parts.push(format!("{} / {}", index + 1, state.images_in_dir.len())); + } + parts.push(image_name_label(path, 28)); + parts.push(image_details_label(path, state.image_info.as_ref())); + parts.join(" • ") +} + +fn viewer_width(state: &Shift) -> Length { + if state.fit_to_view { + Length::Fill + } else { + Length::Fixed(manual_base_width(state) * state.zoom) + } +} + +fn viewer_height(state: &Shift) -> Length { + if state.fit_to_view { + Length::Fill + } else { + Length::Fixed(manual_base_height(state) * state.zoom) + } +} + +fn manual_base_width(state: &Shift) -> f32 { + state + .image_info + .as_ref() + .map(|info| info.width as f32) + .unwrap_or(VIEWER_BASE_WIDTH) +} + +fn manual_base_height(state: &Shift) -> f32 { + state + .image_info + .as_ref() + .map(|info| info.height as f32) + .unwrap_or(VIEWER_BASE_HEIGHT) +} + +pub fn subscription(_state: &Shift) -> Subscription { + event::listen().map(Message::EventOccurred) +} + +fn keyboard_action(state: &Shift, event: &Event) -> Option { + let Event::Keyboard(keyboard::Event::KeyPressed { key, .. }) = event else { + return None; + }; + + use iced::keyboard::Key; + use iced::keyboard::key::Named; + + // The viewer keys would act on an image the page hides. Escape closes it. + if state.preferences_open { + return matches!(key.as_ref(), Key::Named(Named::Escape)) + .then_some(Message::TogglePreferences); + } + + match key.as_ref() { + Key::Named(Named::ArrowLeft) if state.current_index.is_some() => { + Some(Message::PreviousImage) + } + Key::Named(Named::ArrowRight) if state.current_index.is_some() => Some(Message::NextImage), + Key::Character(character) + if matches_char(character, &["p", "["]) && state.current_index.is_some() => + { + Some(Message::PreviousImage) + } + Key::Character(character) + if matches_char(character, &["n", "]"]) && state.current_index.is_some() => + { + Some(Message::NextImage) + } + Key::Named(Named::Home) if state.current_index.is_some() => Some(Message::FirstImage), + Key::Named(Named::End) if state.current_index.is_some() => Some(Message::LastImage), + Key::Character(character) + if matches_char(character, &["+", "=", "."]) && state.current_path.is_some() => + { + Some(Message::ZoomIn) + } + Key::Character(character) + if matches_char(character, &["-", ","]) && state.current_path.is_some() => + { + Some(Message::ZoomOut) + } + Key::Character(character) + if matches_char(character, &["0", "f"]) && state.current_path.is_some() => + { + Some(Message::ToggleFit) + } + Key::Character(character) + if matches_char(character, &["1", "r"]) && state.current_path.is_some() => + { + Some(Message::ResetZoom) + } + Key::Character(character) + if matches_char(character, &["m"]) && state.current_path.is_some() => + { + Some(Message::ToggleFullscreen) + } + Key::Character(character) if matches_char(character, &["o"]) => Some(Message::OpenFile), + Key::Character(character) if matches_char(character, &["d"]) => Some(Message::OpenFolder), + _ => None, + } +} + +fn matches_char(character: &str, candidates: &[&str]) -> bool { + let lowered = character.to_lowercase(); + candidates.iter().any(|candidate| lowered == *candidate) +} + +fn mouse_action(state: &Shift, event: &Event) -> Option { + if state.preferences_open || state.current_path.is_none() { + return None; + } + + let Event::Mouse(mouse::Event::WheelScrolled { delta }) = event else { + return None; + }; + + let vertical = match delta { + mouse::ScrollDelta::Lines { y, .. } => *y, + mouse::ScrollDelta::Pixels { y, .. } => *y, + }; + + if vertical.abs() < f32::EPSILON { + return None; + } + + let step = zoom_step_for_delta(vertical); + + if vertical > 0.0 { + Some(Message::ZoomBy(step)) + } else { + Some(Message::ZoomBy(-step)) + } +} + +async fn pick_file() -> Option { + FileDialog::new() + .add_filter(t("file_filter_images"), SUPPORTED_EXTENSIONS) + .pick_file() +} + +async fn pick_folder() -> Option { + FileDialog::new().pick_folder() +} + +/// Moves to the image at `index` and returns whether it could be read. +/// +/// An unreadable image still becomes the current one, so navigation can step +/// past it, but the status keeps the read error. +fn set_current_image(state: &mut Shift, index: usize) -> bool { + let Some(path) = state.images_in_dir.get(index).cloned() else { + return false; + }; + + let keep_manual_zoom = state.current_path.is_some() && !state.fit_to_view; + let current_zoom = state.zoom; + + state.current_path = Some(path.clone()); + state.current_index = Some(index); + + let image_info = read_image_info(&path); + + if image_info.is_none() { + state.image_info = None; + state.status = unreadable_status(&path); + return false; + } + + state.image_info = image_info; + + if keep_manual_zoom { + state.fit_to_view = false; + state.zoom = current_zoom; + } else { + state.fit_to_view = true; + state.zoom = 1.0; + } + + state.status = image_loaded_status(&path, index, state.images_in_dir.len()); + true +} + +fn current_image_status(state: &Shift) -> String { + match (state.current_path.as_ref(), state.current_index) { + (Some(path), Some(index)) if !state.images_in_dir.is_empty() => { + image_loaded_status(path, index, state.images_in_dir.len()) + } + (Some(path), _) => image_name_label(path, 36), + (None, _) => STATUS_PLACEHOLDER.to_string(), + } +} + +fn zoom_step_for_delta(vertical_delta: f32) -> f32 { + if vertical_delta.abs() >= 8.0 { + ZOOM_STEP * 2.0 + } else if vertical_delta.abs() >= 2.0 { + ZOOM_STEP * 1.5 + } else { + ZOOM_STEP + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::gallery::Fixture; + use std::fs; + + fn open_folder(state: &mut Shift, folder: &Path) { + let _ = update(state, Message::FolderPicked(Some(folder.to_path_buf()))); + } + + #[test] + fn manual_zoom_is_preserved_when_switching_images() { + let fixture = Fixture::new("manual-zoom"); + fixture.png("image1.png"); + fixture.png("image2.png"); + let mut state = Shift::default(); + open_folder(&mut state, &fixture.0); + + let _ = update(&mut state, Message::ZoomBy(1.5)); + let _ = update(&mut state, Message::NextImage); + + assert_eq!(state.current_index, Some(1)); + assert_eq!( + state.image_info, + Some(ImageInfo { + width: 4, + height: 3 + }) + ); + assert!(!state.fit_to_view); + assert_eq!(state.zoom, 2.5); + assert_eq!(state.status, "2 / 2 • image2.png"); + } + + #[test] + fn zoom_step_scales_with_scroll_strength() { + assert_eq!(zoom_step_for_delta(0.5), ZOOM_STEP); + assert_eq!(zoom_step_for_delta(3.0), ZOOM_STEP * 1.5); + assert_eq!(zoom_step_for_delta(10.0), ZOOM_STEP * 2.0); + } + + #[test] + fn empty_folder_resets_viewer_state() { + let fixture = Fixture::new("empty-folder"); + fixture.png("image1.png"); + let empty = fixture.0.join("empty"); + fs::create_dir(&empty).unwrap(); + let mut state = Shift::default(); + open_folder(&mut state, &fixture.0); + let _ = update(&mut state, Message::ZoomBy(2.0)); + assert!(state.current_path.is_some()); + + open_folder(&mut state, &empty); + + assert_eq!(state.current_folder, Some(empty)); + assert!(state.status.starts_with("No images found in")); + assert!(state.current_path.is_none()); + assert!(state.current_index.is_none()); + assert!(state.images_in_dir.is_empty()); + assert!(state.image_info.is_none()); + assert!(state.fit_to_view); + assert_eq!(state.zoom, 1.0); + } + + #[test] + fn cancelling_file_pick_keeps_empty_state_message() { + let mut state = Shift::default(); + + let _ = update(&mut state, Message::FilePicked(None)); + + assert_eq!(state.status, "No image open."); + } + + #[test] + fn default_status_is_neutral() { + let state = Shift::default(); + assert_eq!(state.status, STATUS_PLACEHOLDER); + } + + #[test] + fn cancelling_folder_pick_keeps_neutral_status_when_empty() { + let mut state = Shift::default(); + + let _ = update(&mut state, Message::FolderPicked(None)); + + assert_eq!(state.status, STATUS_PLACEHOLDER); + } + + #[test] + fn neutral_status_keeps_footer_hidden() { + let mut state = Shift::default(); + assert!(!footer_visible(&state)); + + let _ = update(&mut state, Message::FolderPicked(None)); + assert!(!footer_visible(&state)); + + let _ = update(&mut state, Message::FilePicked(None)); + assert!(footer_visible(&state)); + } + + #[test] + fn unreadable_image_status_is_not_overwritten_by_folder_status() { + let fixture = Fixture::new("unreadable-first"); + fixture.garbage("cassée.png"); + fixture.png("image2.png"); + let mut state = Shift::default(); + + open_folder(&mut state, &fixture.0); + + assert_eq!(state.status, "Cannot read cassée.png"); + assert_eq!(state.current_index, Some(0)); + assert!(state.image_info.is_none()); + } + + #[test] + fn unreadable_first_image_does_not_block_navigation() { + let fixture = Fixture::new("unreadable-navigation"); + fixture.garbage("cassée.png"); + fixture.png("image2.png"); + let mut state = Shift::default(); + open_folder(&mut state, &fixture.0); + + let _ = update(&mut state, Message::NextImage); + + assert_eq!(state.current_index, Some(1)); + assert_eq!(state.status, "2 / 2 • image2.png"); + } + + #[test] + fn readable_folder_shows_folder_summary() { + let fixture = Fixture::new("readable-folder"); + fixture.png("image1.png"); + fixture.png("image2.png"); + let mut state = Shift::default(); + + open_folder(&mut state, &fixture.0); + + assert_eq!(state.current_index, Some(0)); + assert!(state.status.ends_with("• 2 images"), "{}", state.status); + } + + #[test] + fn picked_file_opens_at_its_position_in_the_folder() { + let fixture = Fixture::new("picked-sibling"); + fixture.png("image1.png"); + let picked = fixture.png("image2.png"); + let mut state = Shift::default(); + + let _ = update(&mut state, Message::FilePicked(Some(picked.clone()))); + + assert_eq!(state.current_path, Some(picked)); + assert_eq!(state.current_index, Some(1)); + assert_eq!(state.images_in_dir.len(), 2); + } + + #[test] + fn picked_file_outside_the_folder_scan_opens_itself() { + let fixture = Fixture::new("picked-unlisted"); + fixture.png("image1.png"); + let picked = fixture.garbage("scan.dat"); + let mut state = Shift::default(); + + let _ = update(&mut state, Message::FilePicked(Some(picked.clone()))); + + assert_eq!(state.current_path, Some(picked.clone())); + assert_eq!(state.images_in_dir, vec![picked]); + assert_eq!(state.status, "Cannot read scan.dat"); + } + + fn key_press(key: keyboard::Key) -> Event { + Event::Keyboard(keyboard::Event::KeyPressed { + modified_key: key.clone(), + key, + physical_key: keyboard::key::Physical::Unidentified( + keyboard::key::NativeCode::Unidentified, + ), + location: keyboard::Location::Standard, + modifiers: keyboard::Modifiers::empty(), + text: None, + repeat: false, + }) + } + + #[test] + fn the_same_message_opens_and_closes_preferences() { + let mut state = Shift::default(); + + let _ = update(&mut state, Message::TogglePreferences); + assert!(state.preferences_open); + let _ = update(&mut state, Message::Category(2)); + let _ = update(&mut state, Message::ToggleSection("vision")); + + // Close sends the message of the Shift button that opened the page. + let _ = update(&mut state, Message::TogglePreferences); + assert!(!state.preferences_open); + + let _ = update(&mut state, Message::TogglePreferences); + assert_eq!(state.category, 0, "the page reopens on General"); + assert!(state.expanded.is_empty(), "its sections reopen collapsed"); + } + + #[test] + fn viewer_keys_are_off_while_preferences_are_open_and_escape_closes_it() { + let fixture = Fixture::new("preferences-keys"); + fixture.png("image1.png"); + fixture.png("image2.png"); + let mut state = Shift::default(); + open_folder(&mut state, &fixture.0); + let _ = update(&mut state, Message::TogglePreferences); + + let next = key_press(keyboard::Key::Character("n".into())); + let wheel = Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Lines { x: 0.0, y: 1.0 }, + }); + let _ = update(&mut state, Message::EventOccurred(next.clone())); + let _ = update(&mut state, Message::EventOccurred(wheel)); + assert_eq!(state.current_index, Some(0)); + assert!(state.fit_to_view, "the wheel did not zoom the hidden image"); + + let escape = key_press(keyboard::Key::Named(keyboard::key::Named::Escape)); + let _ = update(&mut state, Message::EventOccurred(escape)); + assert!(!state.preferences_open); + + let _ = update(&mut state, Message::EventOccurred(next)); + assert_eq!(state.current_index, Some(1)); + } + + #[test] + fn a_control_bar_button_goes_back_to_the_viewer() { + let fixture = Fixture::new("preferences-bar"); + fixture.png("image1.png"); + let mut state = Shift::default(); + open_folder(&mut state, &fixture.0); + let _ = update(&mut state, Message::TogglePreferences); + + let _ = update(&mut state, Message::ResetZoom); + + assert!(!state.preferences_open); + assert!(!state.fit_to_view); + } + + #[test] + fn a_preference_change_applies_and_is_saved_at_once() { + let fixture = Fixture::new("preferences-save"); + let path = fixture.0.join("preferences").join("preferences.json"); + let mut state = Shift { + prefs_path: Some(path.clone()), + ..Shift::default() + }; + + let _ = update( + &mut state, + Message::Preference(Change::FontSize(colony_ui::FontSize::Large)), + ); + let _ = update( + &mut state, + Message::Preference(Change::Theme { + family: "gruvbox", + variant: "dark", + }), + ); + + let saved = preferences::load(&path).unwrap(); + assert_eq!(saved, state.prefs); + assert_eq!(saved.standard.font_size, colony_ui::FontSize::Large); + assert_eq!(state.notice.as_deref(), Some(t("theme_applied"))); + } +} diff --git a/src/format.rs b/src/format.rs new file mode 100644 index 0000000..6c3f870 --- /dev/null +++ b/src/format.rs @@ -0,0 +1,215 @@ +//! The text of the status line and the details bar, in the active locale. + +use crate::gallery::ImageInfo; +use crate::i18n::{self, Locale, fill, t_in, t_with}; +use std::fs; +use std::path::Path; +use std::time::{Duration, SystemTime}; + +/// From this many images on, the folder summary flags the folder as large. +pub const LARGE_FOLDER_THRESHOLD: usize = 500; + +pub fn zoom_label(zoom: f32) -> String { + t_with( + "zoom_percent", + &[("percent", &format!("{:.0}", zoom * 100.0))], + ) +} + +pub fn image_loaded_status(path: &Path, index: usize, total: usize) -> String { + format!("{} / {} • {}", index + 1, total, image_name_label(path, 36)) +} + +pub fn unreadable_status(path: &Path) -> String { + t_with( + "status_unreadable", + &[("name", &image_name_label(path, 36))], + ) +} + +pub fn no_images_status(folder: &Path) -> String { + t_with( + "status_no_images_in", + &[("folder", &folder.display().to_string())], + ) +} + +pub fn image_name_label(path: &Path, max_chars: usize) -> String { + path.file_name() + .map(|name| truncate_middle(&name.to_string_lossy(), max_chars)) + .unwrap_or_else(|| path.display().to_string()) +} + +pub fn folder_loaded_status(folder: &Path, total_images: usize) -> String { + let folder_name = folder + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| folder.display().to_string()); + + let key = match total_images { + 1 => "status_folder_one", + total if total >= LARGE_FOLDER_THRESHOLD => "status_folder_large", + _ => "status_folder", + }; + + t_with( + key, + &[ + ("folder", &truncate_middle(&folder_name, 28)), + ("count", &total_images.to_string()), + ], + ) +} + +/// Dimensions, file size and age of the image at `path`. +pub fn image_details_label(path: &Path, image_info: Option<&ImageInfo>) -> String { + let locale = i18n::locale(); + let metadata = fs::metadata(path).ok(); + + let dimensions_label = image_info + .map(|info| format!("{}×{}", info.width, info.height)) + .unwrap_or_else(|| t_in(locale, "unknown_dimensions").to_string()); + + let size_label = metadata + .as_ref() + .map(|meta| human_size(meta.len(), locale)) + .unwrap_or_else(|| t_in(locale, "unknown_size").to_string()); + + let modified_label = metadata + .and_then(|meta| meta.modified().ok()) + .and_then(|time| SystemTime::now().duration_since(time).ok()) + .map(|elapsed| modified_label(elapsed, locale)) + .unwrap_or_else(|| t_in(locale, "unknown_date").to_string()); + + format!("{dimensions_label} • {size_label} • {modified_label}") +} + +pub fn truncate_middle(value: &str, max_chars: usize) -> String { + let char_count = value.chars().count(); + + if char_count <= max_chars { + return value.to_string(); + } + + if max_chars <= 3 { + return value.chars().take(max_chars).collect(); + } + + let keep_each_side = (max_chars - 1) / 2; + let start: String = value.chars().take(keep_each_side).collect(); + let end: String = value + .chars() + .rev() + .take(keep_each_side) + .collect::() + .chars() + .rev() + .collect(); + + format!("{}…{}", start, end) +} + +fn human_size(bytes: u64, locale: Locale) -> String { + const KB: f64 = 1024.0; + const MB: f64 = KB * 1024.0; + + let (key, size) = match bytes as f64 { + value if value >= MB => ("size_mb", format!("{:.1}", value / MB)), + value if value >= KB => ("size_kb", format!("{:.0}", value / KB)), + _ => ("size_bytes", bytes.to_string()), + }; + let size = size.replace('.', t_in(locale, "decimal_separator")); + + fill(t_in(locale, key), &[("size", &size)]) +} + +fn modified_label(elapsed: Duration, locale: Locale) -> String { + let seconds = elapsed.as_secs(); + let (key, count) = match seconds { + 0..60 => return t_in(locale, "modified_now").to_string(), + 60..3_600 => ("modified_minutes", seconds / 60), + 3_600..86_400 => ("modified_hours", seconds / 3_600), + _ => ("modified_days", seconds / 86_400), + }; + + fill(t_in(locale, key), &[("count", &count.to_string())]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_middle_keeps_short_values_intact() { + assert_eq!(truncate_middle("eve.png", 16), "eve.png"); + } + + #[test] + fn truncate_middle_adds_ellipsis_for_long_values() { + let truncated = truncate_middle("very-long-image-file-name.png", 12); + + assert!(truncated.contains('…')); + assert!(truncated.chars().count() <= 12); + } + + #[test] + fn folder_status_flags_a_large_folder() { + let label = folder_loaded_status(Path::new("/tmp/gallery"), LARGE_FOLDER_THRESHOLD); + + assert_eq!(label, "gallery • 500 images • large folder"); + } + + #[test] + fn folder_status_counts_a_single_image() { + let label = folder_loaded_status(Path::new("/tmp/gallery"), 1); + + assert_eq!(label, "gallery • 1 image"); + } + + #[test] + fn folder_status_truncates_long_folder_name() { + let label = folder_loaded_status( + Path::new("/tmp/a_folder_with_a_name_far_too_long_for_the_status_line"), + 12, + ); + + assert!(label.contains('…')); + } + + #[test] + fn image_name_label_truncates_long_file_names() { + let label = image_name_label( + Path::new("/tmp/an_image_name_far_too_long_for_the_status_line.png"), + 20, + ); + + assert!(label.contains('…')); + } + + #[test] + fn human_size_uses_the_units_of_each_locale() { + assert_eq!(human_size(512, Locale::En), "512 B"); + assert_eq!(human_size(2048, Locale::En), "2 KB"); + assert_eq!(human_size(1_572_864, Locale::En), "1.5 MB"); + + assert_eq!(human_size(512, Locale::Fr), "512 o"); + assert_eq!(human_size(2048, Locale::Fr), "2 Ko"); + assert_eq!(human_size(1_572_864, Locale::Fr), "1,5 Mo"); + } + + #[test] + fn modified_label_reads_naturally_in_each_locale() { + let cases = [ + (59, "modified just now", "modifié à l’instant"), + (60, "modified 1 min ago", "modifié il y a 1 min"), + (7_200, "modified 2 h ago", "modifié il y a 2 h"), + (259_200, "modified 3 d ago", "modifié il y a 3 j"), + ]; + + for (seconds, english, french) in cases { + let elapsed = Duration::from_secs(seconds); + assert_eq!(modified_label(elapsed, Locale::En), english); + assert_eq!(modified_label(elapsed, Locale::Fr), french); + } + } +} diff --git a/src/gallery.rs b/src/gallery.rs new file mode 100644 index 0000000..d15be17 --- /dev/null +++ b/src/gallery.rs @@ -0,0 +1,185 @@ +//! Finding the images of a folder, in natural order, and reading their size. + +use image::image_dimensions; +use std::cmp::Ordering; +use std::fs; +use std::path::{Path, PathBuf}; + +/// The formats Shift opens. The `image` codecs in Cargo.toml must match. +pub const SUPPORTED_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "webp", "gif", "bmp"]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImageInfo { + pub width: u32, + pub height: u32, +} + +pub fn read_image_info(path: &Path) -> Option { + image_dimensions(path) + .ok() + .map(|(width, height)| ImageInfo { width, height }) +} + +/// Lists the supported images next to `path`, or only `path` itself when it +/// is not one of them (for example a file the folder scan skips). +pub fn collect_images_in_same_dir(path: &Path) -> Vec { + let images = path.parent().map(collect_images_in_dir).unwrap_or_default(); + + if images.iter().any(|candidate| candidate == path) { + images + } else { + vec![path.to_path_buf()] + } +} + +pub fn collect_images_in_dir(path: &Path) -> Vec { + let mut images: Vec = fs::read_dir(path) + .ok() + .into_iter() + .flat_map(|entries| entries.filter_map(Result::ok)) + .map(|entry| entry.path()) + .filter(|candidate| candidate.is_file() && is_supported_image(candidate)) + .collect(); + + images.sort_by(|left, right| compare_image_paths(left, right)); + images +} + +pub fn is_supported_image(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| { + SUPPORTED_EXTENSIONS + .iter() + .any(|supported| supported.eq_ignore_ascii_case(ext)) + }) +} + +fn compare_image_paths(left: &Path, right: &Path) -> Ordering { + natural_path_key(left).cmp(&natural_path_key(right)) +} + +fn natural_path_key(path: &Path) -> Vec { + path.file_name() + .and_then(|name| name.to_str()) + .map(split_natural_chunks) + .unwrap_or_default() +} + +fn split_natural_chunks(value: &str) -> Vec { + let mut chunks = Vec::new(); + let mut current = String::new(); + let mut current_is_digit = None; + + for character in value.chars() { + let is_digit = character.is_ascii_digit(); + + match current_is_digit { + Some(previous) if previous == is_digit => current.push(character), + Some(previous) => { + chunks.push(NaturalChunk::from_part(¤t, previous)); + current.clear(); + current.push(character); + current_is_digit = Some(is_digit); + } + None => { + current.push(character); + current_is_digit = Some(is_digit); + } + } + } + + if let Some(is_digit) = current_is_digit { + chunks.push(NaturalChunk::from_part(¤t, is_digit)); + } + + chunks +} + +#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)] +enum NaturalChunk { + Text(String), + Number(u64), +} + +impl NaturalChunk { + fn from_part(value: &str, is_digit: bool) -> Self { + if is_digit { + Self::Number(value.parse::().unwrap_or(0)) + } else { + Self::Text(value.to_ascii_lowercase()) + } + } +} + +/// A scratch folder under the system temp dir, removed when dropped. +#[cfg(test)] +pub struct Fixture(pub PathBuf); + +#[cfg(test)] +impl Fixture { + pub fn new(name: &str) -> Self { + let dir = std::env::temp_dir().join(format!("shift-{}-{name}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + Self(dir) + } + + pub fn png(&self, name: &str) -> PathBuf { + let path = self.0.join(name); + image::RgbImage::new(4, 3).save(&path).unwrap(); + path + } + + pub fn garbage(&self, name: &str) -> PathBuf { + let path = self.0.join(name); + fs::write(&path, b"not an image").unwrap(); + path + } +} + +#[cfg(test)] +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The codecs are opt-in features of the `image` crate, so a format missing + /// from Cargo.toml would list files that then fail to open. + #[test] + fn every_supported_extension_decodes() { + let fixture = Fixture::new("codecs"); + + for extension in SUPPORTED_EXTENSIONS { + let path = fixture.0.join(format!("sample.{extension}")); + image::RgbImage::new(1, 1) + .save(&path) + .unwrap_or_else(|error| panic!("cannot write {extension}: {error}")); + + let decoded = image::open(&path) + .unwrap_or_else(|error| panic!("cannot decode {extension}: {error}")); + assert_eq!((decoded.width(), decoded.height()), (1, 1), "{extension}"); + } + } + + #[test] + fn natural_sort_orders_numeric_suffixes() { + let left = PathBuf::from("image2.png"); + let right = PathBuf::from("image10.png"); + + assert_eq!(compare_image_paths(&left, &right), Ordering::Less); + } + + #[test] + fn supported_extensions_are_case_insensitive() { + assert!(is_supported_image(Path::new("photo.PNG"))); + assert!(is_supported_image(Path::new("frame.WeBp"))); + assert!(!is_supported_image(Path::new("notes.txt"))); + assert!(!is_supported_image(Path::new("png"))); + } +} diff --git a/src/i18n/en.rs b/src/i18n/en.rs new file mode 100644 index 0000000..3198ea1 --- /dev/null +++ b/src/i18n/en.rs @@ -0,0 +1,75 @@ +//! English, the canonical strings. A new key starts here, then goes to fr.rs. + +pub(super) const STRINGS: &[(&str, &str)] = &[ + // Control bar and start screen + ("open", "Open"), + ("open_folder", "Folder"), + ("previous_image", "Previous image"), + ("next_image", "Next image"), + ("fit", "Fit"), + ("zoom_percent", "{percent}%"), + ("window", "Window"), + ("fullscreen", "Fullscreen"), + ("tagline", "A calm, fast, local image viewer."), + ("file_filter_images", "Images"), + // Status line + ("status_fit", "Fit to window."), + ("status_fullscreen", "Fullscreen."), + ("status_no_image_open", "No image open."), + ("status_open_cancelled", "Selection cancelled."), + ("status_folder_cancelled", "Folder selection cancelled."), + ("status_no_images_in", "No images found in {folder}"), + ("status_unreadable", "Cannot read {name}"), + ("status_folder_one", "{folder} • 1 image"), + ("status_folder", "{folder} • {count} images"), + ( + "status_folder_large", + "{folder} • {count} images • large folder", + ), + // Details bar + ("unknown_dimensions", "unknown dimensions"), + ("unknown_size", "unknown size"), + ("unknown_date", "unknown date"), + ("decimal_separator", "."), + ("size_bytes", "{size} B"), + ("size_kb", "{size} KB"), + ("size_mb", "{size} MB"), + ("modified_now", "modified just now"), + ("modified_minutes", "modified {count} min ago"), + ("modified_hours", "modified {count} h ago"), + ("modified_days", "modified {count} d ago"), + // Preferences, beyond the shared Colony labels + ("preferences_section_language", "Language"), + ( + "preferences_language_desc", + "The language of the interface. A change applies at once.", + ), + ("preferences_language", "Interface language"), + ( + "preferences_language_auto_desc", + "Automatic follows the language of your system.", + ), + ("preferences_language_auto", "Automatic"), + ("preferences_language_en", "English"), + ("preferences_language_fr", "Français"), + ( + "preferences_about_desc", + "A fast, local, keyboard-friendly image viewer.", + ), + ("preferences_about_version", "Version {version}"), + ("preferences_about_license", "License: GPL-3.0-or-later"), + ("preferences_about_source", "Source code: {url}"), + ("preferences_about_file", "Preferences file: {path}"), + ( + "preferences_about_file_none", + "This system has no configuration folder, so preferences last until Shift closes.", + ), + ( + "preferences_save_failed", + "Cannot save preferences: {error}", + ), + ( + "preferences_load_failed", + "Cannot read the saved preferences, so the defaults are in use: {error}", + ), +]; diff --git a/src/i18n/fr.rs b/src/i18n/fr.rs new file mode 100644 index 0000000..94d5f62 --- /dev/null +++ b/src/i18n/fr.rs @@ -0,0 +1,87 @@ +//! French. Same keys as en.rs, written as French rather than translated word +//! for word. + +pub(super) const STRINGS: &[(&str, &str)] = &[ + // Control bar and start screen + ("open", "Ouvrir"), + ("open_folder", "Dossier"), + ("previous_image", "Image précédente"), + ("next_image", "Image suivante"), + ("fit", "Ajuster"), + // French puts a narrow no-break space before the percent sign. + ("zoom_percent", "{percent}\u{202f}%"), + ("window", "Fenêtre"), + ("fullscreen", "Plein écran"), + ( + "tagline", + "Une visionneuse d’images calme, rapide et locale.", + ), + ("file_filter_images", "Images"), + // Status line + ("status_fit", "Ajusté à la fenêtre."), + ("status_fullscreen", "Plein écran."), + ("status_no_image_open", "Aucune image ouverte."), + ("status_open_cancelled", "Sélection annulée."), + ("status_folder_cancelled", "Ouverture du dossier annulée."), + ("status_no_images_in", "Aucune image trouvée dans {folder}"), + ("status_unreadable", "Impossible de lire {name}"), + ("status_folder_one", "{folder} • 1 image"), + ("status_folder", "{folder} • {count} images"), + ( + "status_folder_large", + "{folder} • {count} images • dossier volumineux", + ), + // Details bar + ("unknown_dimensions", "dimensions inconnues"), + ("unknown_size", "taille inconnue"), + ("unknown_date", "date inconnue"), + ("decimal_separator", ","), + ("size_bytes", "{size} o"), + ("size_kb", "{size} Ko"), + ("size_mb", "{size} Mo"), + ("modified_now", "modifié à l’instant"), + ("modified_minutes", "modifié il y a {count} min"), + ("modified_hours", "modifié il y a {count} h"), + ("modified_days", "modifié il y a {count} j"), + // Preferences, beyond the shared Colony labels. A no-break space keeps + // each colon on the line of the word before it. + ("preferences_section_language", "Langue"), + ( + "preferences_language_desc", + "La langue de l’interface. Un changement s’applique aussitôt.", + ), + ("preferences_language", "Langue de l’interface"), + ( + "preferences_language_auto_desc", + "Automatique suit la langue du système.", + ), + ("preferences_language_auto", "Automatique"), + ("preferences_language_en", "English"), + ("preferences_language_fr", "Français"), + ( + "preferences_about_desc", + "Une visionneuse d’images rapide, locale et pensée pour le clavier.", + ), + ("preferences_about_version", "Version {version}"), + ( + "preferences_about_license", + "Licence\u{a0}: GPL-3.0-or-later", + ), + ("preferences_about_source", "Code source\u{a0}: {url}"), + ( + "preferences_about_file", + "Fichier de préférences\u{a0}: {path}", + ), + ( + "preferences_about_file_none", + "Ce système n’a pas de dossier de configuration\u{a0}: les préférences durent jusqu’à la fermeture de Shift.", + ), + ( + "preferences_save_failed", + "Impossible d’enregistrer les préférences\u{a0}: {error}", + ), + ( + "preferences_load_failed", + "Impossible de lire les préférences enregistrées, les valeurs par défaut sont utilisées\u{a0}: {error}", + ), +]; diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs new file mode 100644 index 0000000..b65181c --- /dev/null +++ b/src/i18n/mod.rs @@ -0,0 +1,155 @@ +//! The interface strings, in English and French. +//! +//! English is canonical: a key is named after its English text and is written +//! in `en.rs` first. `fr.rs` holds the same keys, written as French. The tests +//! below fail when the two disagree on a key or on a placeholder. +//! +//! The labels every Colony program shares (the Preferences page skeleton, +//! theme and accent names) live in `colony_ui::i18n` and are not copied here: +//! [`t`] falls back to them. The active locale is colony-ui's, so the shared +//! labels and Shift's own always switch together. + +mod en; +mod fr; + +pub use colony_ui::i18n::{Locale, locale, set_locale}; + +/// The string for `key` in the active locale. +pub fn t(key: &str) -> &'static str { + t_in(locale(), key) +} + +/// The string for `key` in `locale`: Shift's own table first, then the shared +/// Colony labels, which fall back to English and then to the key itself. +pub fn t_in(locale: Locale, key: &str) -> &'static str { + let table = match locale { + Locale::En => en::STRINGS, + Locale::Fr => fr::STRINGS, + }; + table + .iter() + .find(|(candidate, _)| *candidate == key) + .map(|(_, value)| *value) + .unwrap_or_else(|| colony_ui::i18n::t_in(locale, key)) +} + +/// The string for `key` in the active locale, its `{name}` placeholders filled. +pub fn t_with(key: &str, vars: &[(&str, &str)]) -> String { + fill(t(key), vars) +} + +/// Replaces each `{name}` in `template` with its value from `vars`, in one +/// pass, so a value that itself contains braces (a folder named `{count}`) is +/// never substituted again. An unknown placeholder is left as it is. +pub fn fill(template: &str, vars: &[(&str, &str)]) -> String { + let mut filled = String::with_capacity(template.len()); + let mut rest = template; + + while let Some(open) = rest.find('{') { + filled.push_str(&rest[..open]); + let after = &rest[open + 1..]; + let value = after.find('}').and_then(|close| { + vars.iter() + .find(|(name, _)| *name == &after[..close]) + .map(|(_, value)| (close, *value)) + }); + + match value { + Some((close, value)) => { + filled.push_str(value); + rest = &after[close + 1..]; + } + None => { + filled.push('{'); + rest = after; + } + } + } + + filled.push_str(rest); + filled +} + +/// The system's language: `LANGUAGE`, `LC_ALL`, `LC_MESSAGES` or `LANG` on +/// Linux, the user's display language on Windows and macOS. English unless it +/// is French. +pub fn system_locale() -> Locale { + sys_locale::get_locale().map_or(Locale::En, |tag| Locale::from_tag(&tag)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + fn keys(table: &[(&str, &str)]) -> BTreeSet { + table.iter().map(|(key, _)| key.to_string()).collect() + } + + fn placeholders(value: &str) -> BTreeSet { + value + .split('{') + .skip(1) + .filter_map(|part| part.split_once('}').map(|(name, _)| name.to_string())) + .collect() + } + + #[test] + fn fr_and_en_have_identical_key_sets() { + let en = keys(en::STRINGS); + let fr = keys(fr::STRINGS); + + assert_eq!(en.len(), en::STRINGS.len(), "a key is repeated in en.rs"); + assert_eq!(fr.len(), fr::STRINGS.len(), "a key is repeated in fr.rs"); + assert_eq!( + en.difference(&fr).collect::>(), + Vec::<&String>::new(), + "keys missing from fr.rs" + ); + assert_eq!( + fr.difference(&en).collect::>(), + Vec::<&String>::new(), + "keys missing from en.rs" + ); + } + + #[test] + fn no_string_is_empty_and_placeholders_match() { + for (key, english) in en::STRINGS { + let french = t_in(Locale::Fr, key); + assert!(!english.trim().is_empty(), "{key} is empty in en.rs"); + assert!(!french.trim().is_empty(), "{key} is empty in fr.rs"); + assert_eq!( + placeholders(english), + placeholders(french), + "{key} has different placeholders in en.rs and fr.rs" + ); + } + } + + /// The shared labels are not copied into Shift's tables, yet they must + /// still resolve through `t_in`, in the requested locale. + #[test] + fn shared_labels_come_from_colony_ui() { + assert_eq!(t_in(Locale::En, "settings_title"), "Preferences"); + assert_eq!(t_in(Locale::Fr, "settings_title"), "Préférences"); + assert!( + !keys(en::STRINGS) + .iter() + .any(|key| key.starts_with("settings_")) + ); + } + + #[test] + fn fill_substitutes_once_and_keeps_unknown_placeholders() { + assert_eq!( + fill( + "{folder} • {count} images", + &[("folder", "{count}"), ("count", "3")] + ), + "{count} • 3 images" + ); + assert_eq!(fill("{missing} {", &[]), "{missing} {"); + assert_eq!(fill("no placeholder", &[("a", "b")]), "no placeholder"); + } +} diff --git a/src/main.rs b/src/main.rs index 59a4d07..8b72589 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,26 +1,13 @@ #![cfg_attr(target_os = "windows", windows_subsystem = "windows")] -use ::image::image_dimensions; -use iced::{ - Alignment, ContentFit, Element, Event, Length, Subscription, Task, Theme, event, keyboard, - mouse, - widget::{button, column, container, image, row, scrollable, text}, - window, -}; -use rfd::FileDialog; -use std::ffi::OsStr; -use std::fs; -use std::path::{Path, PathBuf}; -use std::time::SystemTime; +mod app; +mod format; +mod gallery; +mod i18n; +mod preferences; +mod style; -const SUPPORTED_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "webp", "gif", "bmp"]; -const MIN_ZOOM: f32 = 0.25; -const MAX_ZOOM: f32 = 6.0; -const ZOOM_STEP: f32 = 0.25; -const VIEWER_BASE_WIDTH: f32 = 960.0; -const VIEWER_BASE_HEIGHT: f32 = 640.0; -const LARGE_FOLDER_THRESHOLD: usize = 500; -const STATUS_PLACEHOLDER: &str = ""; +use std::ffi::OsStr; fn main() -> iced::Result { // Answered before iced starts, so it works without a display: the release @@ -30,10 +17,18 @@ fn main() -> iced::Result { return Ok(()); } - iced::application(Shift::default, update, view) - .title("Shift") - .theme(theme) - .subscription(subscription) + // The Colony fonts: JetBrainsMono Nerd Font for the interface and its + // glyphs, OpenDyslexic for the accessibility swap, Font Awesome. + colony_ui::fonts::BYTES + .into_iter() + .fold( + iced::application(app::Shift::boot, app::update, app::view), + |application, font| application.font(font), + ) + .title(app::NAME) + .theme(style::theme) + .subscription(app::subscription) + .default_font(colony_ui::ui_font()) .run() } @@ -44,836 +39,9 @@ fn wants_version(args: impl IntoIterator>) -> bool { .is_some_and(|arg| matches!(arg.as_ref().to_str(), Some("--version" | "-V"))) } -#[derive(Debug)] -struct Shift { - status: String, - current_path: Option, - current_folder: Option, - images_in_dir: Vec, - current_index: Option, - zoom: f32, - fit_to_view: bool, - fullscreen: bool, - image_info: Option, -} - -impl Default for Shift { - fn default() -> Self { - Self { - status: STATUS_PLACEHOLDER.to_string(), - current_path: None, - current_folder: None, - images_in_dir: Vec::new(), - current_index: None, - zoom: 1.0, - fit_to_view: true, - fullscreen: false, - image_info: None, - } - } -} - -#[derive(Debug, Clone)] -enum Message { - OpenFile, - OpenFolder, - PreviousImage, - NextImage, - FirstImage, - LastImage, - ZoomIn, - ZoomOut, - ZoomBy(f32), - ResetZoom, - ToggleFit, - ToggleFullscreen, - EventOccurred(Event), - FilePicked(Option), - FolderPicked(Option), -} - -fn update(state: &mut Shift, message: Message) -> Task { - match message { - Message::EventOccurred(event) => { - if let Some(action) = keyboard_action(state, &event) { - return update(state, action); - } - - if let Some(action) = mouse_action(state, &event) { - return update(state, action); - } - - Task::none() - } - Message::OpenFile => Task::perform(pick_file(), Message::FilePicked), - Message::OpenFolder => Task::perform(pick_folder(), Message::FolderPicked), - Message::PreviousImage => { - if let Some(index) = state.current_index - && index > 0 - { - set_current_image(state, index - 1); - } - Task::none() - } - Message::NextImage => { - if let Some(index) = state.current_index - && index + 1 < state.images_in_dir.len() - { - set_current_image(state, index + 1); - } - Task::none() - } - Message::FirstImage => { - if !state.images_in_dir.is_empty() { - set_current_image(state, 0); - } - Task::none() - } - Message::LastImage => { - if let Some(last_index) = state.images_in_dir.len().checked_sub(1) { - set_current_image(state, last_index); - } - Task::none() - } - Message::ZoomIn => { - state.fit_to_view = false; - state.zoom = (state.zoom + ZOOM_STEP).min(MAX_ZOOM); - state.status = zoom_status(state.zoom); - Task::none() - } - Message::ZoomOut => { - state.fit_to_view = false; - state.zoom = (state.zoom - ZOOM_STEP).max(MIN_ZOOM); - state.status = zoom_status(state.zoom); - Task::none() - } - Message::ZoomBy(delta) => { - state.fit_to_view = false; - state.zoom = (state.zoom + delta).clamp(MIN_ZOOM, MAX_ZOOM); - state.status = zoom_status(state.zoom); - Task::none() - } - Message::ResetZoom => { - state.fit_to_view = false; - state.zoom = 1.0; - state.status = zoom_status(state.zoom); - Task::none() - } - Message::ToggleFit => { - state.fit_to_view = !state.fit_to_view; - state.status = if state.fit_to_view { - "Mode ajusté.".to_string() - } else { - zoom_status(state.zoom) - }; - Task::none() - } - Message::ToggleFullscreen => { - state.fullscreen = !state.fullscreen; - state.status = if state.fullscreen { - "Plein écran.".to_string() - } else { - current_image_status(state) - }; - - let fullscreen = state.fullscreen; - - window::latest().then(move |window_id| { - window_id - .map(|id| { - window::set_mode( - id, - if fullscreen { - window::Mode::Fullscreen - } else { - window::Mode::Windowed - }, - ) - }) - .unwrap_or_else(Task::none) - }) - } - Message::FilePicked(Some(path)) => { - let images = collect_images_in_same_dir(&path); - let index = images - .iter() - .position(|candidate| candidate == &path) - .unwrap_or(0); - - state.images_in_dir = images; - state.current_folder = path.parent().map(Path::to_path_buf); - set_current_image(state, index); - Task::none() - } - Message::FilePicked(None) => { - if state.current_path.is_none() { - state.status = "Aucune image ouverte.".to_string(); - } else { - state.status = "Sélection annulée.".to_string(); - } - Task::none() - } - Message::FolderPicked(Some(folder)) => { - let images = collect_images_in_dir(&folder); - - if images.is_empty() { - state.status = format!("Aucune image trouvée dans {}", folder.display()); - state.current_folder = Some(folder); - state.current_path = None; - state.current_index = None; - state.images_in_dir.clear(); - state.image_info = None; - state.fit_to_view = true; - state.zoom = 1.0; - } else { - let total_images = images.len(); - state.images_in_dir = images; - state.current_folder = Some(folder.clone()); - - if set_current_image(state, 0) { - state.status = folder_loaded_status(&folder, total_images); - } - } - - Task::none() - } - Message::FolderPicked(None) => { - state.status = if state.current_path.is_some() { - "Ouverture de dossier annulée.".to_string() - } else { - STATUS_PLACEHOLDER.to_string() - }; - Task::none() - } - } -} - -fn view(state: &Shift) -> Element<'_, Message> { - let has_image = state.current_path.is_some(); - let show_controls_bar = !state.fullscreen || !has_image; - let show_meta_bar = has_image && !state.fullscreen; - let show_footer = footer_visible(state); - - let controls_bar = container( - row![ - row![ - button("Ouvrir").on_press(Message::OpenFile), - button("Dossier").on_press(Message::OpenFolder), - ] - .spacing(10) - .wrap(), - row![ - button("←").on_press_maybe( - state - .current_index - .and_then(|index| (index > 0).then_some(Message::PreviousImage)) - ), - button("→").on_press_maybe(state.current_index.and_then(|index| { - (index + 1 < state.images_in_dir.len()).then_some(Message::NextImage) - })), - button(if state.fit_to_view { "100%" } else { "Ajuster" }).on_press_maybe( - state.current_path.as_ref().and(Some(if state.fit_to_view { - Message::ResetZoom - } else { - Message::ToggleFit - })) - ), - button(if state.fullscreen { - "Fenêtre" - } else { - "Plein écran" - }) - .on_press_maybe( - state - .current_path - .as_ref() - .map(|_| Message::ToggleFullscreen) - ), - ] - .spacing(10) - .wrap(), - ] - .width(Length::Fill) - .spacing(18) - .align_y(Alignment::Center) - .wrap(), - ) - .width(Length::Fill) - .padding([10, 12]) - .style(container::bordered_box); - - let viewer: Element<'_, Message> = match &state.current_path { - Some(path) => { - let image_widget = image(path.clone()) - .width(viewer_width(state)) - .height(viewer_height(state)) - .content_fit(if state.fit_to_view { - ContentFit::ScaleDown - } else { - ContentFit::Contain - }); - - let framed_viewer: Element<'_, Message> = if state.fit_to_view { - container(image_widget) - .width(Length::Fill) - .height(Length::Fill) - .center_x(Length::Fill) - .center_y(Length::Fill) - .padding(12) - .into() - } else { - scrollable( - container(image_widget) - .padding(12) - .width(Length::Shrink) - .height(Length::Shrink), - ) - .width(Length::Fill) - .height(Length::Fill) - .direction(scrollable::Direction::Both { - vertical: scrollable::Scrollbar::default(), - horizontal: scrollable::Scrollbar::default(), - }) - .into() - }; - - container(framed_viewer) - .width(Length::Fill) - .height(Length::Fill) - .style(container::dark) - .into() - } - None => container( - column![ - text("Shift").size(34), - text("Un viewer calme, local, rapide.").size(16), - row![ - button("Ouvrir").on_press(Message::OpenFile), - button("Dossier").on_press(Message::OpenFolder), - ] - .spacing(10) - .wrap(), - ] - .spacing(14) - .align_x(Alignment::Center), - ) - .width(Length::Fill) - .height(360) - .center_x(Length::Fill) - .center_y(Length::Fill) - .into(), - }; - - let meta_bar = show_meta_bar.then(|| { - container(text(viewer_meta_line(state)).size(13)) - .width(Length::Fill) - .padding([6, 10]) - .style(container::bordered_box) - }); - - let footer = show_footer.then(|| { - container(text(&state.status).size(13)) - .width(Length::Fill) - .padding([6, 10]) - .style(container::bordered_box) - }); - - let content = column![ - show_controls_bar.then_some(controls_bar), - viewer, - meta_bar, - footer - ] - .spacing(if has_image && !state.fullscreen { - 10 - } else { - 0 - }) - .padding(if has_image && !state.fullscreen { - 14 - } else { - 0 - }) - // An open image gets the whole window so fit mode can use all of it; the - // cap only keeps the empty start screen compact. - .max_width(if has_image || state.fullscreen { - f32::INFINITY - } else { - 1280.0 - }); - - container(content) - .width(Length::Fill) - .height(Length::Fill) - .center_x(Length::Fill) - .into() -} - -fn footer_visible(state: &Shift) -> bool { - state.status != STATUS_PLACEHOLDER && !state.fullscreen -} - -fn viewer_meta_line(state: &Shift) -> String { - let position_label = match (state.current_index, state.images_in_dir.is_empty()) { - (Some(index), false) => format!("{} / {}", index + 1, state.images_in_dir.len()), - _ => "Aucune image".to_string(), - }; - - let file_label = state - .current_path - .as_ref() - .and_then(|path| path.file_name()) - .map(|name| truncate_middle(&name.to_string_lossy(), 28)) - .unwrap_or_else(|| "Aucun fichier".to_string()); - - let details_label = state - .current_path - .as_ref() - .and_then(|path| image_details_label(path, state.image_info.as_ref())) - .unwrap_or_else(|| "détails indisponibles".to_string()); - - format!("{} • {} • {}", position_label, file_label, details_label) -} - -fn viewer_width(state: &Shift) -> Length { - if state.fit_to_view { - Length::Fill - } else { - Length::Fixed(manual_base_width(state) * state.zoom) - } -} - -fn viewer_height(state: &Shift) -> Length { - if state.fit_to_view { - Length::Fill - } else { - Length::Fixed(manual_base_height(state) * state.zoom) - } -} - -fn manual_base_width(state: &Shift) -> f32 { - state - .image_info - .as_ref() - .map(|info| info.width as f32) - .unwrap_or(VIEWER_BASE_WIDTH) -} - -fn manual_base_height(state: &Shift) -> f32 { - state - .image_info - .as_ref() - .map(|info| info.height as f32) - .unwrap_or(VIEWER_BASE_HEIGHT) -} - -fn theme(_state: &Shift) -> Theme { - Theme::TokyoNight -} - -fn subscription(_state: &Shift) -> Subscription { - event::listen().map(Message::EventOccurred) -} - -fn keyboard_action(state: &Shift, event: &Event) -> Option { - let Event::Keyboard(keyboard::Event::KeyPressed { key, .. }) = event else { - return None; - }; - - use iced::keyboard::Key; - use iced::keyboard::key::Named; - - match key.as_ref() { - Key::Named(Named::ArrowLeft) if state.current_index.is_some() => { - Some(Message::PreviousImage) - } - Key::Named(Named::ArrowRight) if state.current_index.is_some() => Some(Message::NextImage), - Key::Character(character) - if matches_char(character, &["p", "["]) && state.current_index.is_some() => - { - Some(Message::PreviousImage) - } - Key::Character(character) - if matches_char(character, &["n", "]"]) && state.current_index.is_some() => - { - Some(Message::NextImage) - } - Key::Named(Named::Home) if state.current_index.is_some() => Some(Message::FirstImage), - Key::Named(Named::End) if state.current_index.is_some() => Some(Message::LastImage), - Key::Character(character) - if matches_char(character, &["+", "=", "."]) && state.current_path.is_some() => - { - Some(Message::ZoomIn) - } - Key::Character(character) - if matches_char(character, &["-", ","]) && state.current_path.is_some() => - { - Some(Message::ZoomOut) - } - Key::Character(character) - if matches_char(character, &["0", "f"]) && state.current_path.is_some() => - { - Some(Message::ToggleFit) - } - Key::Character(character) - if matches_char(character, &["1", "r"]) && state.current_path.is_some() => - { - Some(Message::ResetZoom) - } - Key::Character(character) - if matches_char(character, &["m"]) && state.current_path.is_some() => - { - Some(Message::ToggleFullscreen) - } - Key::Character(character) if matches_char(character, &["o"]) => Some(Message::OpenFile), - Key::Character(character) if matches_char(character, &["d"]) => Some(Message::OpenFolder), - _ => None, - } -} - -fn matches_char(character: &str, candidates: &[&str]) -> bool { - let lowered = character.to_lowercase(); - candidates.iter().any(|candidate| lowered == *candidate) -} - -fn mouse_action(state: &Shift, event: &Event) -> Option { - state.current_path.as_ref()?; - - let Event::Mouse(mouse::Event::WheelScrolled { delta }) = event else { - return None; - }; - - let vertical = match delta { - mouse::ScrollDelta::Lines { y, .. } => *y, - mouse::ScrollDelta::Pixels { y, .. } => *y, - }; - - if vertical.abs() < f32::EPSILON { - return None; - } - - let step = zoom_step_for_delta(vertical); - - if vertical > 0.0 { - Some(Message::ZoomBy(step)) - } else { - Some(Message::ZoomBy(-step)) - } -} - -async fn pick_file() -> Option { - FileDialog::new() - .add_filter("Images", SUPPORTED_EXTENSIONS) - .pick_file() -} - -async fn pick_folder() -> Option { - FileDialog::new().pick_folder() -} - -/// Moves to the image at `index` and returns whether it could be read. -/// -/// An unreadable image still becomes the current one, so navigation can step -/// past it, but the status keeps the read error. -fn set_current_image(state: &mut Shift, index: usize) -> bool { - let Some(path) = state.images_in_dir.get(index).cloned() else { - return false; - }; - - let keep_manual_zoom = state.current_path.is_some() && !state.fit_to_view; - let current_zoom = state.zoom; - - state.current_path = Some(path.clone()); - state.current_index = Some(index); - - let image_info = read_image_info(&path); - - if image_info.is_none() { - state.image_info = None; - state.status = format!("Impossible de lire {}", image_name_label(&path, 36)); - return false; - } - - state.image_info = image_info; - - if keep_manual_zoom { - state.fit_to_view = false; - state.zoom = current_zoom; - } else { - state.fit_to_view = true; - state.zoom = 1.0; - } - - state.status = image_loaded_status(&path, index, state.images_in_dir.len()); - true -} - -fn zoom_status(zoom: f32) -> String { - format!("{:.0}%", zoom * 100.0) -} - -fn current_image_status(state: &Shift) -> String { - match (state.current_path.as_ref(), state.current_index) { - (Some(path), Some(index)) if !state.images_in_dir.is_empty() => { - image_loaded_status(path, index, state.images_in_dir.len()) - } - (Some(path), _) => image_name_label(path, 36), - (None, _) => STATUS_PLACEHOLDER.to_string(), - } -} - -fn image_loaded_status(path: &Path, index: usize, total: usize) -> String { - format!("{} / {} • {}", index + 1, total, image_name_label(path, 36)) -} - -fn image_name_label(path: &Path, max_chars: usize) -> String { - path.file_name() - .map(|name| truncate_middle(&name.to_string_lossy(), max_chars)) - .unwrap_or_else(|| path.display().to_string()) -} - -fn folder_loaded_status(folder: &Path, total_images: usize) -> String { - let folder_name = folder - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| folder.display().to_string()); - - let folder_label = truncate_middle(&folder_name, 28); - - if total_images >= LARGE_FOLDER_THRESHOLD { - format!("{} • {} images • dossier dense", folder_label, total_images) - } else { - format!("{} • {} images", folder_label, total_images) - } -} - -fn image_details_label(path: &Path, image_info: Option<&ImageInfo>) -> Option { - let metadata = fs::metadata(path).ok(); - - let dimensions_label = image_info - .map(|info| format!("{}×{}", info.width, info.height)) - .unwrap_or_else(|| "dimensions inconnues".to_string()); - - let size_label = metadata - .as_ref() - .map(|meta| human_size(meta.len())) - .unwrap_or_else(|| "taille inconnue".to_string()); - - let modified_label = metadata - .and_then(|meta| meta.modified().ok()) - .and_then(system_time_label) - .unwrap_or_else(|| "date inconnue".to_string()); - - Some(format!( - "{} • {} • {}", - dimensions_label, size_label, modified_label - )) -} - -fn truncate_middle(value: &str, max_chars: usize) -> String { - let char_count = value.chars().count(); - - if char_count <= max_chars { - return value.to_string(); - } - - if max_chars <= 3 { - return value.chars().take(max_chars).collect(); - } - - let keep_each_side = (max_chars - 1) / 2; - let start: String = value.chars().take(keep_each_side).collect(); - let end: String = value - .chars() - .rev() - .take(keep_each_side) - .collect::() - .chars() - .rev() - .collect(); - - format!("{}…{}", start, end) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ImageInfo { - width: u32, - height: u32, -} - -fn read_image_info(path: &Path) -> Option { - image_dimensions(path) - .ok() - .map(|(width, height)| ImageInfo { width, height }) -} - -fn zoom_step_for_delta(vertical_delta: f32) -> f32 { - if vertical_delta.abs() >= 8.0 { - ZOOM_STEP * 2.0 - } else if vertical_delta.abs() >= 2.0 { - ZOOM_STEP * 1.5 - } else { - ZOOM_STEP - } -} - -fn human_size(bytes: u64) -> String { - const KB: f64 = 1024.0; - const MB: f64 = KB * 1024.0; - - match bytes as f64 { - value if value >= MB => format!("{:.1} Mo", value / MB), - value if value >= KB => format!("{:.0} Ko", value / KB), - _ => format!("{} o", bytes), - } -} - -fn system_time_label(time: SystemTime) -> Option { - let elapsed = SystemTime::now().duration_since(time).ok()?; - - let label = if elapsed.as_secs() < 60 { - "modifié à l’instant".to_string() - } else if elapsed.as_secs() < 3_600 { - format!("modifié il y a {} min", elapsed.as_secs() / 60) - } else if elapsed.as_secs() < 86_400 { - format!("modifié il y a {} h", elapsed.as_secs() / 3_600) - } else { - format!("modifié il y a {} j", elapsed.as_secs() / 86_400) - }; - - Some(label) -} - -/// Lists the supported images next to `path`, or only `path` itself when it -/// is not one of them (for example a file the folder scan skips). -fn collect_images_in_same_dir(path: &Path) -> Vec { - let images = path.parent().map(collect_images_in_dir).unwrap_or_default(); - - if images.iter().any(|candidate| candidate == path) { - images - } else { - vec![path.to_path_buf()] - } -} - -fn collect_images_in_dir(path: &Path) -> Vec { - let mut images: Vec = fs::read_dir(path) - .ok() - .into_iter() - .flat_map(|entries| entries.filter_map(Result::ok)) - .map(|entry| entry.path()) - .filter(|candidate| candidate.is_file() && is_supported_image(candidate)) - .collect(); - - images.sort_by(|left, right| compare_image_paths(left, right)); - images -} - -fn compare_image_paths(left: &Path, right: &Path) -> std::cmp::Ordering { - natural_path_key(left).cmp(&natural_path_key(right)) -} - -fn natural_path_key(path: &Path) -> Vec { - path.file_name() - .and_then(|name| name.to_str()) - .map(split_natural_chunks) - .unwrap_or_default() -} - -fn split_natural_chunks(value: &str) -> Vec { - let mut chunks = Vec::new(); - let mut current = String::new(); - let mut current_is_digit = None; - - for character in value.chars() { - let is_digit = character.is_ascii_digit(); - - match current_is_digit { - Some(previous) if previous == is_digit => current.push(character), - Some(previous) => { - chunks.push(NaturalChunk::from_part(¤t, previous)); - current.clear(); - current.push(character); - current_is_digit = Some(is_digit); - } - None => { - current.push(character); - current_is_digit = Some(is_digit); - } - } - } - - if let Some(is_digit) = current_is_digit { - chunks.push(NaturalChunk::from_part(¤t, is_digit)); - } - - chunks -} - -#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)] -enum NaturalChunk { - Text(String), - Number(u64), -} - -impl NaturalChunk { - fn from_part(value: &str, is_digit: bool) -> Self { - if is_digit { - Self::Number(value.parse::().unwrap_or(0)) - } else { - Self::Text(value.to_ascii_lowercase()) - } - } -} - -fn is_supported_image(path: &Path) -> bool { - matches!( - path.extension() - .and_then(|ext| ext.to_str()) - .map(|ext| ext.to_ascii_lowercase()) - .as_deref(), - Some("png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp") - ) -} - #[cfg(test)] mod tests { use super::*; - use std::cmp::Ordering; - - /// A scratch folder under the system temp dir, removed when dropped. - struct Fixture(PathBuf); - - impl Fixture { - fn new(name: &str) -> Self { - let dir = std::env::temp_dir().join(format!("shift-{}-{name}", std::process::id())); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); - Self(dir) - } - - fn png(&self, name: &str) -> PathBuf { - let path = self.0.join(name); - ::image::RgbImage::new(4, 3).save(&path).unwrap(); - path - } - - fn garbage(&self, name: &str) -> PathBuf { - let path = self.0.join(name); - fs::write(&path, b"not an image").unwrap(); - path - } - } - - impl Drop for Fixture { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } - } - - fn open_folder(state: &mut Shift, folder: &Path) { - let _ = update(state, Message::FolderPicked(Some(folder.to_path_buf()))); - } #[test] fn version_flag_is_only_read_from_the_first_argument() { @@ -884,237 +52,4 @@ mod tests { assert!(!wants_version(["photo.png", "--version"])); assert!(!wants_version(["-v"])); } - - /// The codecs are opt-in features of the `image` crate, so a format missing - /// from Cargo.toml would list files that then fail to open. - #[test] - fn every_supported_extension_decodes() { - let fixture = Fixture::new("codecs"); - - for extension in SUPPORTED_EXTENSIONS { - let path = fixture.0.join(format!("sample.{extension}")); - ::image::RgbImage::new(1, 1) - .save(&path) - .unwrap_or_else(|error| panic!("cannot write {extension}: {error}")); - - let decoded = ::image::open(&path) - .unwrap_or_else(|error| panic!("cannot decode {extension}: {error}")); - assert_eq!((decoded.width(), decoded.height()), (1, 1), "{extension}"); - } - } - - #[test] - fn natural_sort_orders_numeric_suffixes() { - let left = PathBuf::from("image2.png"); - let right = PathBuf::from("image10.png"); - - assert_eq!(compare_image_paths(&left, &right), Ordering::Less); - } - - #[test] - fn supported_extensions_are_case_insensitive() { - assert!(is_supported_image(Path::new("photo.PNG"))); - assert!(is_supported_image(Path::new("frame.WeBp"))); - assert!(!is_supported_image(Path::new("notes.txt"))); - } - - #[test] - fn truncate_middle_keeps_short_values_intact() { - assert_eq!(truncate_middle("eve.png", 16), "eve.png"); - } - - #[test] - fn truncate_middle_adds_ellipsis_for_long_values() { - let truncated = truncate_middle("very-long-image-file-name.png", 12); - - assert!(truncated.contains('…')); - assert!(truncated.chars().count() <= 12); - } - - #[test] - fn folder_status_mentions_dense_folder() { - let label = folder_loaded_status(Path::new("/tmp/gallery"), LARGE_FOLDER_THRESHOLD); - - assert!(label.contains("gallery")); - assert!(label.contains("dossier dense")); - } - - #[test] - fn folder_status_truncates_long_folder_name() { - let label = folder_loaded_status( - Path::new("/tmp/un_dossier_avec_un_nom_vraiment_beaucoup_trop_long_pour_le_footer"), - 12, - ); - - assert!(label.contains('…')); - } - - #[test] - fn manual_zoom_is_preserved_when_switching_images() { - let fixture = Fixture::new("manual-zoom"); - fixture.png("image1.png"); - fixture.png("image2.png"); - let mut state = Shift::default(); - open_folder(&mut state, &fixture.0); - - let _ = update(&mut state, Message::ZoomBy(1.5)); - let _ = update(&mut state, Message::NextImage); - - assert_eq!(state.current_index, Some(1)); - assert_eq!( - state.image_info, - Some(ImageInfo { - width: 4, - height: 3 - }) - ); - assert!(!state.fit_to_view); - assert_eq!(state.zoom, 2.5); - assert_eq!(state.status, "2 / 2 • image2.png"); - } - - #[test] - fn zoom_step_scales_with_scroll_strength() { - assert_eq!(zoom_step_for_delta(0.5), ZOOM_STEP); - assert_eq!(zoom_step_for_delta(3.0), ZOOM_STEP * 1.5); - assert_eq!(zoom_step_for_delta(10.0), ZOOM_STEP * 2.0); - } - - #[test] - fn empty_folder_resets_viewer_state() { - let fixture = Fixture::new("empty-folder"); - fixture.png("image1.png"); - let empty = fixture.0.join("empty"); - fs::create_dir(&empty).unwrap(); - let mut state = Shift::default(); - open_folder(&mut state, &fixture.0); - let _ = update(&mut state, Message::ZoomBy(2.0)); - assert!(state.current_path.is_some()); - - open_folder(&mut state, &empty); - - assert_eq!(state.current_folder, Some(empty)); - assert!(state.status.starts_with("Aucune image trouvée")); - assert!(state.current_path.is_none()); - assert!(state.current_index.is_none()); - assert!(state.images_in_dir.is_empty()); - assert!(state.image_info.is_none()); - assert!(state.fit_to_view); - assert_eq!(state.zoom, 1.0); - } - - #[test] - fn cancelling_file_pick_keeps_empty_state_message() { - let mut state = Shift::default(); - - let _ = update(&mut state, Message::FilePicked(None)); - - assert_eq!(state.status, "Aucune image ouverte."); - } - - #[test] - fn default_status_is_neutral() { - let state = Shift::default(); - assert_eq!(state.status, STATUS_PLACEHOLDER); - } - - #[test] - fn cancelling_folder_pick_keeps_neutral_status_when_empty() { - let mut state = Shift::default(); - - let _ = update(&mut state, Message::FolderPicked(None)); - - assert_eq!(state.status, STATUS_PLACEHOLDER); - } - - #[test] - fn neutral_status_keeps_footer_hidden() { - let mut state = Shift::default(); - assert!(!footer_visible(&state)); - - let _ = update(&mut state, Message::FolderPicked(None)); - assert!(!footer_visible(&state)); - - let _ = update(&mut state, Message::FilePicked(None)); - assert!(footer_visible(&state)); - } - - #[test] - fn image_name_label_truncates_long_file_names() { - let label = image_name_label( - Path::new("/tmp/un_nom_d_image_vraiment_beaucoup_trop_long_pour_le_footer.png"), - 20, - ); - - assert!(label.contains('…')); - } - - #[test] - fn unreadable_image_status_is_not_overwritten_by_folder_status() { - let fixture = Fixture::new("unreadable-first"); - fixture.garbage("cassée.png"); - fixture.png("image2.png"); - let mut state = Shift::default(); - - open_folder(&mut state, &fixture.0); - - assert_eq!(state.status, "Impossible de lire cassée.png"); - assert_eq!(state.current_index, Some(0)); - assert!(state.image_info.is_none()); - } - - #[test] - fn unreadable_first_image_does_not_block_navigation() { - let fixture = Fixture::new("unreadable-navigation"); - fixture.garbage("cassée.png"); - fixture.png("image2.png"); - let mut state = Shift::default(); - open_folder(&mut state, &fixture.0); - - let _ = update(&mut state, Message::NextImage); - - assert_eq!(state.current_index, Some(1)); - assert_eq!(state.status, "2 / 2 • image2.png"); - } - - #[test] - fn readable_folder_shows_folder_summary() { - let fixture = Fixture::new("readable-folder"); - fixture.png("image1.png"); - fixture.png("image2.png"); - let mut state = Shift::default(); - - open_folder(&mut state, &fixture.0); - - assert_eq!(state.current_index, Some(0)); - assert!(state.status.ends_with("• 2 images"), "{}", state.status); - } - - #[test] - fn picked_file_opens_at_its_position_in_the_folder() { - let fixture = Fixture::new("picked-sibling"); - fixture.png("image1.png"); - let picked = fixture.png("image2.png"); - let mut state = Shift::default(); - - let _ = update(&mut state, Message::FilePicked(Some(picked.clone()))); - - assert_eq!(state.current_path, Some(picked)); - assert_eq!(state.current_index, Some(1)); - assert_eq!(state.images_in_dir.len(), 2); - } - - #[test] - fn picked_file_outside_the_folder_scan_opens_itself() { - let fixture = Fixture::new("picked-unlisted"); - fixture.png("image1.png"); - let picked = fixture.garbage("scan.dat"); - let mut state = Shift::default(); - - let _ = update(&mut state, Message::FilePicked(Some(picked.clone()))); - - assert_eq!(state.current_path, Some(picked.clone())); - assert_eq!(state.images_in_dir, vec![picked]); - assert_eq!(state.status, "Impossible de lire scan.dat"); - } } diff --git a/src/preferences.rs b/src/preferences.rs new file mode 100644 index 0000000..0751adc --- /dev/null +++ b/src/preferences.rs @@ -0,0 +1,307 @@ +//! What the user chose in Preferences, where it is kept, and the page itself. + +use crate::app::Message; +use crate::i18n::{self, Locale, t, t_with}; +use colony_ui::preferences::StandardPreferences; +use colony_ui::theme::Palette; +use colony_ui::widgets::{self, ABOUT_CATEGORY, PreferencesView, STANDARD_CATEGORIES}; +use colony_ui::{Typography, paths}; +use iced::Element; +use iced::widget::{Column, column, text}; +use serde::{Deserialize, Deserializer, Serialize}; +use std::collections::HashSet; +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +/// The key of the Language section, Shift's own section in General. +const LANGUAGE_SECTION: &str = "language"; + +/// Everything Preferences can change. The Colony keys sit at the top level of +/// the file, under the names every Colony program writes. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Prefs { + #[serde(flatten)] + pub standard: StandardPreferences, + #[serde(default, deserialize_with = "lenient_language")] + pub language: Language, +} + +impl Prefs { + /// Puts every preference into effect: theme, accent, both text sizes, + /// fonts, motion, then the language. + pub fn apply(&self) { + self.standard.apply(); + i18n::set_locale(self.language.locale()); + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Language { + /// The system's language, read at each launch. + #[default] + Auto, + En, + Fr, +} + +impl Language { + const ALL: [Self; 3] = [Self::Auto, Self::En, Self::Fr]; + + pub fn locale(self) -> Locale { + match self { + Self::Auto => i18n::system_locale(), + Self::En => Locale::En, + Self::Fr => Locale::Fr, + } + } + + fn label_key(self) -> &'static str { + match self { + Self::Auto => "preferences_language_auto", + Self::En => "preferences_language_en", + Self::Fr => "preferences_language_fr", + } + } +} + +/// A language this build does not know, or a value of the wrong type, falls +/// back to Automatic instead of discarding the whole file, as the Colony keys +/// next to it do. +fn lenient_language<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let value = serde_json::Value::deserialize(deserializer)?; + Ok(serde_json::from_value(value).unwrap_or_default()) +} + +/// `/Colony/Shift/preferences/preferences.json`, found without +/// creating anything. `None` on a system with no configuration folder. +pub fn file_path() -> Option { + paths::locate::config_dir(crate::app::NAME) + .ok() + .map(|dir| dir.join("preferences").join("preferences.json")) +} + +/// The preferences saved at `path`. A missing file is the defaults. A file +/// that cannot be read or parsed is an error for the caller to report; the +/// file itself is left alone until the user changes something. +pub fn load(path: &Path) -> Result { + match fs::read_to_string(path) { + Ok(json) => serde_json::from_str(&json).map_err(|error| error.to_string()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Prefs::default()), + Err(error) => Err(error.to_string()), + } +} + +/// Saves `prefs` to `path` without ever leaving a truncated file: the JSON +/// goes to a temporary file in the same folder, reaches the disk, then +/// replaces the old file in one rename. +pub fn save(path: &Path, prefs: &Prefs) -> io::Result<()> { + let folder = path + .parent() + .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidInput))?; + fs::create_dir_all(folder)?; + + let temporary = path.with_extension("json.tmp"); + let written = (|| { + let mut file = fs::File::create(&temporary)?; + file.write_all(&serde_json::to_vec_pretty(prefs)?)?; + file.sync_all() + })() + .and_then(|()| fs::rename(&temporary, path)); + + if written.is_err() { + let _ = fs::remove_file(&temporary); + } + written +} + +/// The Preferences page: General (with Language), Appearance and +/// Accessibility from the Colony kit, then About. +pub fn page<'a>( + typo: &Typography, + prefs: &Prefs, + expanded: &HashSet, + category: usize, + file: Option<&Path>, +) -> Element<'a, Message> { + let kit = PreferencesView::new( + *typo, + &prefs.standard, + expanded, + Message::Preference, + Message::ToggleSection, + ); + + let content: Element<'a, Message> = match category { + 0 => kit + .general() + .push(language_section( + typo, + prefs.language, + expanded.contains(LANGUAGE_SECTION), + )) + .into(), + 1 => kit.appearance().into(), + 2 => kit.accessibility().into(), + _ => about(typo, file).into(), + }; + + let labels = STANDARD_CATEGORIES + .iter() + .map(|key| t(key)) + .chain([t(ABOUT_CATEGORY)]); + + widgets::preferences_page( + typo, + widgets::category_list(typo, labels, category, Message::Category), + content, + Message::TogglePreferences, + ) +} + +fn language_section<'a>( + typo: &Typography, + language: Language, + expanded: bool, +) -> Element<'a, Message> { + let options = Language::ALL.map(|choice| (choice, t(choice.label_key()).to_string())); + + widgets::collapsible_section( + typo, + t("preferences_section_language"), + expanded, + Message::ToggleSection(LANGUAGE_SECTION), + column![ + widgets::section_description(typo, t("preferences_language_desc")), + widgets::setting_row( + typo, + t("preferences_language"), + t("preferences_language_auto_desc"), + widgets::dropdown(typo, options, &language, Message::Language), + ), + ] + .into(), + ) +} + +fn about<'a>(typo: &Typography, file: Option<&Path>) -> Column<'a, Message> { + let line = |value: String| { + text(value) + .size(typo.sz(13)) + .font(typo.regular) + .color(Palette::TEXT_SECONDARY()) + }; + let file_line = match file { + Some(path) => t_with( + "preferences_about_file", + &[("path", &path.display().to_string())], + ), + None => t("preferences_about_file_none").to_string(), + }; + + widgets::category_heading(typo, t(ABOUT_CATEGORY), t("preferences_about_desc")) + .push(line(t_with( + "preferences_about_version", + &[("version", env!("CARGO_PKG_VERSION"))], + ))) + .push(line(t("preferences_about_license").to_string())) + .push(line(t_with( + "preferences_about_source", + &[("url", env!("CARGO_PKG_REPOSITORY"))], + ))) + .push(line(file_line)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::gallery::Fixture; + use colony_ui::{FontSize, TextSize}; + + fn custom() -> Prefs { + let mut prefs = Prefs { + language: Language::Fr, + ..Prefs::default() + }; + prefs.standard.selected_theme = "nord".to_string(); + prefs.standard.selected_variant = "dark".to_string(); + prefs.standard.selected_accent = Some("violet".to_string()); + prefs.standard.font_size = FontSize::Large; + prefs.standard.text_size_a11y = TextSize::XLarge; + prefs.standard.dyslexia_font = true; + prefs + } + + #[test] + fn preferences_round_trip_and_leave_no_temporary_file() { + let fixture = Fixture::new("prefs-round-trip"); + let path = fixture.0.join("preferences").join("preferences.json"); + + save(&path, &custom()).unwrap(); + + assert_eq!(load(&path), Ok(custom())); + let files: Vec<_> = fs::read_dir(path.parent().unwrap()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!(files, ["preferences.json"]); + } + + #[test] + fn saving_again_replaces_the_file() { + let fixture = Fixture::new("prefs-replace"); + let path = fixture.0.join("preferences.json"); + + save(&path, &custom()).unwrap(); + save(&path, &Prefs::default()).unwrap(); + + assert_eq!(load(&path), Ok(Prefs::default())); + } + + #[test] + fn a_missing_file_is_the_defaults() { + let fixture = Fixture::new("prefs-missing"); + + assert_eq!( + load(&fixture.0.join("preferences.json")), + Ok(Prefs::default()) + ); + } + + #[test] + fn a_corrupt_file_is_an_error_and_is_kept() { + let fixture = Fixture::new("prefs-corrupt"); + let path = fixture.0.join("preferences.json"); + fs::write(&path, "{ not json").unwrap(); + + assert!(load(&path).is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), "{ not json"); + } + + #[test] + fn a_bad_value_falls_back_and_keeps_the_rest() { + let fixture = Fixture::new("prefs-lenient"); + let path = fixture.0.join("preferences.json"); + fs::write( + &path, + r#"{"language": 42, "selected_theme": "nord", "font_size": "huge", "unknown": true}"#, + ) + .unwrap(); + + let prefs = load(&path).unwrap(); + + assert_eq!(prefs.language, Language::Auto); + assert_eq!(prefs.standard.selected_theme, "nord"); + assert_eq!(prefs.standard.font_size, FontSize::Default); + } + + #[test] + fn the_file_uses_the_colony_names() { + let json = serde_json::to_value(custom()).unwrap(); + + assert_eq!(json["selected_theme"], "nord"); + assert_eq!(json["text_size_a11y"], "xlarge"); + assert_eq!(json["language"], "fr"); + } +} diff --git a/src/style.rs b/src/style.rs new file mode 100644 index 0000000..8bb765b --- /dev/null +++ b/src/style.rs @@ -0,0 +1,108 @@ +//! Shift's look, taken entirely from the active Colony palette, so a theme, +//! an accent or high contrast picked in Preferences reaches every widget. + +use crate::app::Shift; +use colony_ui::theme::{active_palette, effective_accent}; +use iced::widget::{button, container}; +use iced::{Border, Theme}; + +/// The iced theme built from the active palette, so the widgets Shift does +/// not style itself (scrollbars, the drop-down list) match the rest. +pub fn theme(_state: &Shift) -> Theme { + let palette = active_palette(); + Theme::custom( + "Colony", + iced::theme::Palette { + background: palette.bg_primary, + text: palette.text_primary, + primary: effective_accent(), + success: palette.success, + warning: palette.warning, + danger: palette.error, + }, + ) +} + +/// The buttons of the control bar and of the start screen. +pub fn action(_theme: &Theme, status: button::Status) -> button::Style { + let palette = active_palette(); + let (background, text_color) = match status { + button::Status::Active => (palette.btn_default, palette.text_primary), + button::Status::Hovered => (palette.btn_hover, palette.text_primary), + button::Status::Pressed => (palette.btn_pressed, palette.text_primary), + button::Status::Disabled => (palette.btn_default, palette.text_dimmest), + }; + + button::Style { + background: Some(background.into()), + text_color, + border: Border::default().rounded(6), + ..Default::default() + } +} + +/// The control bar, the details bar and the status line. +pub fn bar(_theme: &Theme) -> container::Style { + let palette = active_palette(); + container::Style { + background: Some(palette.bg_card.into()), + border: Border { + color: palette.border_subtle, + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + } +} + +/// Behind the image: the deepest surface of the palette. +pub fn viewer(_theme: &Theme) -> container::Style { + container::Style { + background: Some(active_palette().bg_sidebar.into()), + ..Default::default() + } +} + +pub fn tooltip(_theme: &Theme) -> container::Style { + let palette = active_palette(); + container::Style { + background: Some(palette.bg_card_hover.into()), + text_color: Some(palette.text_primary), + border: Border { + color: palette.border_subtle, + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + /// Every text size goes through `sz`, so both text-size preferences reach + /// it, and every colour comes from the palette, so it is right on every + /// theme (design/typography.md, design/settings-page.md). Reading the + /// sources is the only way to catch a literal before it reaches a screen. + #[test] + fn no_raw_text_size_and_no_literal_colour() { + let sources = [ + ("app.rs", include_str!("app.rs")), + ("preferences.rs", include_str!("preferences.rs")), + ("style.rs", include_str!("style.rs")), + ]; + let literal_colour = concat!("Color::", "from_rgb"); + + for (file, source) in sources { + for (number, line) in source.lines().enumerate() { + let raw_size = line.match_indices(".size(").any(|(at, call)| { + line[at + call.len()..].starts_with(|c: char| c.is_ascii_digit()) + }); + assert!( + !raw_size && !line.contains(literal_colour), + "{file}:{}: {line}", + number + 1 + ); + } + } + } +}