~quf/tocs

039b6387b4e978461b25c9042d7c0aaa633f8726 — Lukas Himbert 9 months ago 6495ee9
scaffolding for loading recent schemas (CS1 only so far)
5 files changed, 259 insertions(+), 2 deletions(-)

M tbled/Cargo.lock
M tbled/Cargo.toml
M tbled/src/main.rs
M tbled/src/misc.rs
A tbled/src/recent_files.rs
M tbled/Cargo.lock => tbled/Cargo.lock +28 -0
@@ 329,6 329,15 @@ dependencies = [
]

[[package]]
name = "directories"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
dependencies = [
 "dirs-sys",
]

[[package]]
name = "dirs-next"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 339,6 348,18 @@ dependencies = [
]

[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
 "libc",
 "option-ext",
 "redox_users",
 "windows-sys 0.48.0",
]

[[package]]
name = "dirs-sys-next"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 695,6 716,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"

[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"

[[package]]
name = "paste"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 874,6 901,7 @@ dependencies = [
 "camino",
 "clap",
 "csv",
 "directories",
 "ecow",
 "fltk",
 "grid",

M tbled/Cargo.toml => tbled/Cargo.toml +1 -0
@@ 13,6 13,7 @@ lto = true
camino = "1.1.6"
clap = { version = "4.0.26", features = ["derive"] }
csv = { version = "1.1.6" }
directories = { version = "5.0.1" }
indexmap = "1.9.2"
ecow = { version = "0.1.2", default-features = false }
fltk = { version = "1.4.11", features = ["no-images", "no-pango"] }

M tbled/src/main.rs => tbled/src/main.rs +57 -2
@@ 3,6 3,7 @@

mod filter;
mod misc;
mod recent_files;
mod select_cols;
mod table_widget;
mod tblstate;


@@ 37,6 38,7 @@ pub enum SchemaVersion {
    CS5,
    CustomV1,
    CustomV2,
    RecentV1(usize), // index into the "most recent schemas (v1) queue"
}

#[derive(Clone, Debug)]


@@ 99,10 101,12 @@ pub struct AppData {
    selected_attributes: std::collections::HashMap<tocs::tbl::Header, indexmap::IndexSet<tocs::tbl::Attribute>>, // which attributes should be shown in which order for a given header?
    warnings: std::vec::Vec<tocs::tbl::DeserializeError>,
    //edited: std::collections::HashSet<(i32, i32)>, // edited since last save
    recent_schemas_v1: recent_files::RecentFiles,
}

struct App {
    app: fltk::app::App,
    sender: fltk::app::Sender<Msg>,
    receiver: fltk::app::Receiver<Msg>,
    widgets: Widgets,
    state: AppData,


@@ 227,6 231,10 @@ impl App {
            Msg::SetVersion(SchemaVersion::CustomV2),
        );

        menu.add("&Schemas/Import/Recent (CS1)\t", fltk::enums::Shortcut::None, fltk::menu::MenuFlag::Submenu, |_| {});

        menu.add("&Schemas/Import/Recent (CS2 onward)\t", fltk::enums::Shortcut::None, fltk::menu::MenuFlag::Submenu, |_| {});

        menu.add_emit(
            "&File/Load tbl with current schema\t",
            fltk::enums::Shortcut::None,


@@ 348,7 356,7 @@ impl App {
        flex.fixed(&flex_row, 26);

        let mut table = table_widget::FilteredTable::new();
        table.emit(sender, Msg::TableEvent);
        table.emit(sender.clone(), Msg::TableEvent);

        flex.end();



@@ 358,6 366,7 @@ impl App {

        let mut this = Self {
            app,
            sender,
            receiver,
            widgets: Widgets {
                menu,


@@ 369,6 378,9 @@ impl App {
            state,
        };

        this.update_recent_schemas_v1_menu();
        this.update_recent_schemas_v2_menu();
        this.update_recent_tbl_menu();
        this.update_after_file_reload();

        this


@@ 397,6 409,7 @@ impl App {
                    tbl_version,
                    path,
                    warnings,
                    recent_schemas_v1: _,
                } = &mut self.state;
                tbl_history.clear_all();
                selected_attributes.clear();


@@ 450,6 463,7 @@ impl App {
                            }
                        }
                    }
                    SchemaVersion::RecentV1(_) => todo!(),
                };
                self.state.tbl_version = new_version;
                self.state.tbl_history.clear_all(); // we don't want a file incompatible with the new schema


@@ 462,7 476,7 @@ impl App {
                    SchemaVersion::CS3 => ("cs3.json", &tocs::tbl::schemas::CS3),
                    SchemaVersion::CS4 => ("cs4.json", &tocs::tbl::schemas::CS4),
                    SchemaVersion::CS5 => ("cs5.json", &tocs::tbl::schemas::CS5),
                    SchemaVersion::CustomV1 | SchemaVersion::CustomV2 => unreachable!("not present in menu"),
                    SchemaVersion::CustomV1 | SchemaVersion::CustomV2 | SchemaVersion::RecentV1(_) => unreachable!("not present in menu"),
                };
                let Some(path) = misc::save_file_dialog("json", name) else { return };
                match misc::export_schemas(&path, schemas) {


@@ 543,6 557,7 @@ impl App {
                    tbl_version,
                    path,
                    warnings,
                    recent_schemas_v1: _,
                } = &mut self.state;
                tbl_history.clear_all();
                selected_attributes.clear();


@@ 650,6 665,42 @@ impl App {
        }
    }

    fn update_recent_schemas_v1_menu(&mut self) {
        let i = self.widgets.menu.find_index("&Schemas/Import/Recent (CS1)\t");
        self.widgets.menu.clear_submenu(i).unwrap();
        let mut recent_menu = self.widgets.menu.at(i).unwrap();
        if self.state.recent_schemas_v1.is_empty() {
            recent_menu.deactivate();
        } else {
            recent_menu.activate();
        }
        drop(recent_menu);
        for (i, path) in self.state.recent_schemas_v1.iter().enumerate() {
            self.widgets.menu.add_emit(
                &format!("&Schemas/Import/Recent (CS1)\t/{} (DO NOT CLICK)", misc::QuotedFltkMenuLabel::new(path.as_str())),
                fltk::enums::Shortcut::None,
                fltk::menu::MenuFlag::Normal,
                self.sender.clone(),
                Msg::SetVersion(SchemaVersion::RecentV1(i)),
            );
        }
    }

    fn update_recent_schemas_v2_menu(&mut self) {
        let i = self.widgets.menu.find_index("&Schemas/Import/Recent (CS2 onward)\t");
        let mut recent_menu = self.widgets.menu.at(i).unwrap();
        recent_menu.deactivate();
    }

    fn update_recent_tbl_menu(&mut self) {
        // TODO
    }

    fn update_to_schemas_v1(&mut self) {
        // TODO: update menu
        // TODO: save
    }

    fn update_table(&mut self) {
        // get active entry type from header selector
        self.state.active_entry_type = self.widgets.header_selector.value().and_then(|hdr| {


@@ 687,6 738,7 @@ impl App {
            path: _,
            tbl_history,
            warnings,
            recent_schemas_v1: _,
        } = &mut self.state;

        // if there are warnings, show button to display them


@@ 789,6 841,7 @@ fn main() {
                    tbl_version: version,
                    path,
                    warnings: vec![],
                    recent_schemas_v1: recent_files::load_recent_schemas_v1(),
                },
                tocs::tbl::ReadTblResult::Warn { tbl, warnings } => AppData {
                    tbl_history: TblStateHistory::new(tbl),


@@ 797,6 850,7 @@ fn main() {
                    tbl_version: version,
                    path,
                    warnings,
                    recent_schemas_v1: recent_files::load_recent_schemas_v1(),
                },
                tocs::tbl::ReadTblResult::Err(e) => {
                    log::error!("Unable to read file: {e}");


@@ 815,6 869,7 @@ fn main() {
            tbl_version: tocs::tbl::Version::CS1En,
            path: Utf8PathBuf::new(),
            warnings: vec![],
            recent_schemas_v1: recent_files::load_recent_schemas_v1(),
        },
    };


M tbled/src/misc.rs => tbled/src/misc.rs +25 -0
@@ 139,3 139,28 @@ pub fn schema_for_header<'a>(header: &Header, schemas: &'a indexmap::IndexMap<He
    };
    schemas.get(header).unwrap_or(&GENERIC)
}

pub struct QuotedFltkMenuLabel<'a> {
    raw: &'a str,
}

impl<'a> QuotedFltkMenuLabel<'a> {
    pub fn new(s: &'a str) -> Self {
        Self { raw: s }
    }
}

impl<'a> std::fmt::Display for QuotedFltkMenuLabel<'a> {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        use std::fmt::Write as _;

        const SPECIAL: [char; 4] = ['\\', '/', '&', '_'];
        for c in self.raw.chars() {
            if SPECIAL.contains(&c) {
                f.write_char('\\')?;
            }
            f.write_char(c)?;
        }
        Ok(())
    }
}

A tbled/src/recent_files.rs => tbled/src/recent_files.rs +148 -0
@@ 0,0 1,148 @@
// cache of most recently used files
//
// Should be updated on every load/read and persisted to disk.
// Only UTF-8 paths are supported.
// The number of files is hardcoded to 10.
//
// Disk format is the concatenation of:
// - u32 (little endian): length of the path
// - UTF-8 encoded path (not null-terminated)
// for every path.
// Paths are sorted by recency of use and

use camino::{Utf8Path, Utf8PathBuf};

const MAX_SIZE: usize = 10;

pub struct RecentFiles {
    /// Queue of paths, sorted from most recent use to least recent use.
    /// Must not contain exact duplicates.
    paths: std::collections::VecDeque<Utf8PathBuf>,
}

impl RecentFiles {
    pub fn new() -> Self {
        Self {
            paths: std::collections::VecDeque::with_capacity(MAX_SIZE),
        }
    }

    pub fn iter(&self) -> impl Iterator<Item = &Utf8Path> {
        self.paths.iter().map(|path| path.as_ref())
    }

    pub fn is_empty(&self) -> bool {
        self.paths.is_empty()
    }

    pub fn write_to_file(&self, filename: &Utf8Path) -> std::io::Result<()> {
        let mut data = std::vec::Vec::<u8>::with_capacity(1024);
        for path in self.paths.iter() {
            let bytes = path.as_str().as_bytes();
            let len: u32 = bytes.len().try_into().unwrap();
            data.extend_from_slice(&len.to_le_bytes());
            data.extend_from_slice(bytes);
        }
        std::fs::write(filename, &data)
    }

    fn find_path_index(&self, path: &Utf8Path) -> Option<usize> {
        for (i, p) in self.iter().enumerate() {
            if p == path {
                return Some(i);
            }
        }
        None
    }

    pub fn mark_as_updated(&mut self, path: &Utf8Path) {
        match self.find_path_index(path) {
            None => {
                // path is new, hasn't been used recently
                if self.paths.len() >= MAX_SIZE {
                    self.paths.pop_back();
                }
                self.paths.push_front(path.to_owned());
            }
            Some(i) => {
                if i == 0 {
                    // nothing to do, path was already the most recent one before this call
                } else {
                    // remove path from its current position and move it to the front
                    let p = self.paths.remove(i).unwrap();
                    self.paths.push_front(p);
                }
            }
        }
    }

    pub fn refresh_from_file(&mut self, filename: &Utf8Path) {
        fn inner(this: &mut RecentFiles, filename: &Utf8Path) -> Option<()> {
            let data = std::fs::read(filename).ok()?;
            let mut off = 0;
            while off < data.len() && this.paths.len() < MAX_SIZE {
                let len_bytes = data.get(off..off + 4)?;
                let len_bytes: [u8; 4] = len_bytes.try_into().ok()?;
                let len: usize = u32::from_le_bytes(len_bytes) as usize;
                off += 4;
                let bytes = data.get(off..off + len)?;
                let s = std::str::from_utf8(bytes).ok()?;
                let path = Utf8Path::new(s);
                // Add path if it hasn't appeared before.
                // Note that we can't use the mark_as_updated method because we would reverse the correct order
                if this.find_path_index(path).is_none() {
                    this.paths.push_back(path.to_owned());
                }
                off += len;
            }
            Some(())
        }

        self.paths.clear();
        let result = inner(self, filename);
        if result.is_none() {
            self.paths.clear();
        }
    }
}

struct RecentFilesPaths {
    schemas_v1: Utf8PathBuf,
    schemas_v2: Utf8PathBuf,
    tbls: Utf8PathBuf,
}

fn get_files_paths() -> Option<RecentFilesPaths> {
    let project_dirs = directories::ProjectDirs::from(
        "",                // qualifier
        "huellenoperator", // organization
        "tbled",           // application
    )?;
    let base: &Utf8Path = project_dirs.state_dir()?.try_into().ok()?;
    std::fs::create_dir_all(&base).ok()?;
    Some(RecentFilesPaths {
        schemas_v1: base.join("recent_schemas_v1.dat"),
        schemas_v2: base.join("recent_schemas_v2.dat"),
        tbls: base.join("recent_tbls.dat"),
    })
}

lazy_static::lazy_static! {
    static ref PATHS: Option<RecentFilesPaths> = get_files_paths();
}

fn load_file(path: Option<&Utf8Path>) -> RecentFiles {
    let mut result = RecentFiles::new();
    if let Some(p) = path {
        result.refresh_from_file(p);
    }
    result
}

pub fn load_recent_schemas_v1() -> RecentFiles {
    load_file(PATHS.as_ref().map(|p| p.schemas_v1.as_ref()))
}

pub fn save_recent_schemas_v1(recent_files: &RecentFiles) -> Option<()> {
    PATHS.as_ref().and_then(|p| recent_files.write_to_file(&p.schemas_v1).ok())
}