ravnpad 1.3.14

A simple UTF-8 notepad
use std::path::PathBuf;

use crate::i18n::{self, Lang};

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ThemePref {
    System,
    Light,
    Dark,
}

impl ThemePref {
    #[cfg_attr(test, allow(dead_code))]
    pub fn code(self) -> &'static str {
        match self {
            Self::System => "system",
            Self::Light => "light",
            Self::Dark => "dark",
        }
    }

    fn from_code(value: &str) -> Option<Self> {
        match value {
            "system" => Some(Self::System),
            "light" => Some(Self::Light),
            "dark" => Some(Self::Dark),
            _ => None,
        }
    }
}

#[derive(Clone, Debug)]
pub struct Prefs {
    pub lang: Lang,
    pub font: String,
    pub size: f32,
    pub spellcheck: bool,
    pub theme: ThemePref,
    pub line_wrap: bool,
    pub recent: Vec<PathBuf>,
    pub(crate) positions: Vec<(PathBuf, f32, u64)>,
}

impl Prefs {
    pub fn load() -> Self {
        let mut prefs = Self {
            lang: i18n::detect(),
            font: String::new(),
            size: 15.0,
            spellcheck: false,
            theme: ThemePref::System,
            line_wrap: true,
            recent: Vec::new(),
            positions: Vec::new(),
        };
        if let Some(path) = settings_path()
            && let Ok(text) = std::fs::read_to_string(path)
        {
            for line in text.lines() {
                if let Some((key, value)) = line.split_once('=') {
                    match key.trim() {
                        "lang" => {
                            if let Some(lang) = Lang::from_code(value.trim()) {
                                prefs.lang = lang;
                            }
                        }
                        "font" => prefs.font = value.trim().to_owned(),
                        "spell" => prefs.spellcheck = value.trim() == "1",
                        "theme" => {
                            if let Some(theme) = ThemePref::from_code(value.trim()) {
                                prefs.theme = theme;
                            }
                        }
                        "wrap" => prefs.line_wrap = value.trim() != "0",
                        "recent" => {
                            prefs.recent = decode_recent(value.trim());
                        }
                        "positions" => {
                            prefs.positions = decode_positions(value.trim());
                        }
                        "size" => {
                            if let Ok(size) = value.trim().parse::<f32>()
                                && size.is_finite()
                            {
                                prefs.size = size.clamp(6.0, 144.0);
                            }
                        }
                        _ => {}
                    }
                }
            }
        } else if let Some(path) = language_path()
            && let Ok(code) = std::fs::read_to_string(path)
            && let Some(lang) = Lang::from_code(code.trim())
        {
            prefs.lang = lang;
        }
        prefs.recent.truncate(10);
        prefs.positions.truncate(50);
        prefs
    }

    #[cfg_attr(test, allow(dead_code))]
    pub fn save(&self) -> Result<(), std::io::Error> {
        let Some(path) = settings_path() else {
            return Ok(());
        };
        if let Some(dir) = path.parent() {
            std::fs::create_dir_all(dir)?;
        }
        let body = format!(
            "lang={}\nfont={}\nsize={}\nspell={}\ntheme={}\nwrap={}\nrecent={}\npositions={}\n",
            self.lang.code(),
            self.font,
            self.size,
            u8::from(self.spellcheck),
            self.theme.code(),
            u8::from(self.line_wrap),
            encode_recent(&self.recent),
            encode_positions(&self.positions),
        );
        crate::storage::save(&path, body.as_bytes()).map(|_| ())
    }

    pub fn add_recent(&mut self, path: &std::path::Path) {
        self.recent.retain(|item| item != path);
        self.recent.insert(0, path.to_path_buf());
        self.recent.truncate(10);
    }

    pub fn position(&self, path: &std::path::Path) -> (f32, u64) {
        self.positions
            .iter()
            .find(|(item, _, _)| item == path)
            .map(|(_, scroll, offset)| (*scroll, *offset))
            .unwrap_or_default()
    }

    pub fn set_position(&mut self, path: &std::path::Path, scroll: f32, offset: u64) {
        self.positions.retain(|(item, _, _)| item != path);
        self.positions
            .insert(0, (path.to_path_buf(), scroll.max(0.0), offset));
        self.positions.truncate(50);
    }
}

pub fn recent_labels(paths: &[PathBuf]) -> Vec<String> {
    use std::collections::HashMap;
    let mut counts = HashMap::new();
    for path in paths {
        *counts
            .entry(path.file_name().map(|name| name.to_os_string()))
            .or_insert(0usize) += 1;
    }
    paths
        .iter()
        .map(|path| {
            let name = path
                .file_name()
                .map(|name| name.to_string_lossy().into_owned())
                .unwrap_or_else(|| path.display().to_string());
            let key = path.file_name().map(|value| value.to_os_string());
            if counts.get(&key).copied().unwrap_or(0) > 1 {
                path.parent()
                    .filter(|parent| !parent.as_os_str().is_empty())
                    .map(|parent| format!("{name}{}", parent.display()))
                    .unwrap_or(name)
            } else {
                name
            }
        })
        .collect()
}

fn encode_recent(paths: &[PathBuf]) -> String {
    let values: Vec<_> = paths
        .iter()
        .map(|path| path.to_string_lossy().into_owned())
        .collect();
    serde_json::to_string(&values).unwrap_or_else(|_| "[]".to_owned())
}

fn decode_recent(value: &str) -> Vec<PathBuf> {
    serde_json::from_str::<Vec<String>>(value)
        .unwrap_or_default()
        .into_iter()
        .map(PathBuf::from)
        .collect()
}

fn encode_positions(positions: &[(PathBuf, f32, u64)]) -> String {
    let values: Vec<_> = positions
        .iter()
        .map(|(path, scroll, offset)| (path.to_string_lossy().into_owned(), *scroll, *offset))
        .collect();
    serde_json::to_string(&values).unwrap_or_else(|_| "[]".to_owned())
}

fn decode_positions(value: &str) -> Vec<(PathBuf, f32, u64)> {
    serde_json::from_str::<Vec<(String, f32, u64)>>(value)
        .unwrap_or_default()
        .into_iter()
        .filter(|(_, scroll, _)| scroll.is_finite())
        .map(|(path, scroll, offset)| (PathBuf::from(path), scroll, offset))
        .collect()
}

pub(crate) fn config_dir() -> Option<PathBuf> {
    #[cfg(windows)]
    {
        Some(PathBuf::from(std::env::var_os("APPDATA")?).join("RavnPad"))
    }
    #[cfg(target_os = "macos")]
    {
        Some(PathBuf::from(std::env::var_os("HOME")?).join("Library/Application Support/RavnPad"))
    }
    #[cfg(all(unix, not(target_os = "macos")))]
    {
        let base = std::env::var_os("XDG_CONFIG_HOME")
            .map(PathBuf::from)
            .or_else(|| Some(PathBuf::from(std::env::var_os("HOME")?).join(".config")))?;
        Some(base.join("ravnpad"))
    }
}

fn settings_path() -> Option<PathBuf> {
    Some(config_dir()?.join("settings"))
}

fn language_path() -> Option<PathBuf> {
    Some(config_dir()?.join("language"))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::Path;

    fn test_prefs() -> Prefs {
        Prefs {
            lang: Lang::English,
            font: String::new(),
            size: 15.0,
            spellcheck: false,
            theme: ThemePref::System,
            line_wrap: true,
            recent: Vec::new(),
            positions: Vec::new(),
        }
    }

    #[test]
    fn recent_files_are_deduplicated_and_most_recent_first() {
        let mut prefs = test_prefs();
        prefs.add_recent(Path::new("first.txt"));
        prefs.add_recent(Path::new("second.txt"));
        prefs.add_recent(Path::new("first.txt"));
        assert_eq!(
            prefs.recent,
            vec![PathBuf::from("first.txt"), PathBuf::from("second.txt")]
        );
    }

    #[test]
    fn recent_and_positions_roundtrip_as_json() {
        let paths = vec![PathBuf::from("a file.txt"), PathBuf::from("æøå.txt")];
        assert_eq!(decode_recent(&encode_recent(&paths)), paths);

        let positions = vec![(PathBuf::from("log.txt"), 123.5, 9876)];
        assert_eq!(decode_positions(&encode_positions(&positions)), positions);
    }

    #[test]
    fn duplicate_recent_names_include_their_folder() {
        let paths = vec![
            PathBuf::from("one/notater.txt"),
            PathBuf::from("two/notater.txt"),
            PathBuf::from("unique.txt"),
        ];
        assert_eq!(
            recent_labels(&paths),
            ["notater.txt — one", "notater.txt — two", "unique.txt"]
        );
    }

    #[test]
    fn position_updates_without_duplicates() {
        let mut prefs = test_prefs();
        let path = Path::new("note.txt");
        prefs.set_position(path, 10.0, 20);
        prefs.set_position(path, 30.0, 40);
        assert_eq!(prefs.position(path), (30.0, 40));
        assert_eq!(prefs.positions.len(), 1);
    }
}