From d32d2a9ad38383e2e398af69f890318ba2e5b632 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 30 Sep 2026 21:02:22 -0300 Subject: [PATCH 01/22] fix(panel): a closed card shows content where the indentation was --- crates/cp-panel/src/view.rs | 21 +++++++++++++++++++++ crates/cp-panel/src/view_test.rs | 28 ++++++++++++++++++++++++++++ crates/cp-panel/ui/cards.slint | 2 +- crates/cp-panel/ui/types.slint | 1 + 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/crates/cp-panel/src/view.rs b/crates/cp-panel/src/view.rs index 4bebdf1b..94f4b0da 100644 --- a/crates/cp-panel/src/view.rs +++ b/crates/cp-panel/src/view.rs @@ -94,6 +94,7 @@ pub fn card_of(row: &Listed, now: i64) -> Card { }, age: age_text(now, row.modified_at).into(), lines: lines_of(&body), + squeezed: squeezed_of(&body).into(), body: body.into(), found: !hit.is_empty(), badge: badge_of(claims.as_ref()).into(), @@ -109,6 +110,26 @@ pub fn card_of(row: &Listed, now: i64) -> Card { } } +pub fn squeezed_of(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut spaced = true; + for one in text.chars() { + if one.is_whitespace() { + if !spaced { + out.push(' '); + spaced = true; + } + continue; + } + out.push(one); + spaced = false; + } + if out.ends_with(' ') { + out.pop(); + } + out +} + pub fn body_of(row: &Listed) -> String { match &row.snippet { Some(snippet) => snippet.excerpt.plain(), diff --git a/crates/cp-panel/src/view_test.rs b/crates/cp-panel/src/view_test.rs index 3b90719d..2d7e2235 100644 --- a/crates/cp-panel/src/view_test.rs +++ b/crates/cp-panel/src/view_test.rs @@ -395,3 +395,31 @@ fn big_counts_are_shortened_so_the_pill_stays_a_pill() { assert_eq!(count_text(1), "1 elemento"); assert_eq!(count_text(50_000), "50000 elementos"); } + +#[test] +fn a_closed_card_shows_content_where_the_indentation_was() { + let pretty = "{\n \"annotations\": [\n {\n \"id\": 1,\n \"note\": \"revisar\"\n }\n ]\n}"; + let squeezed = squeezed_of(pretty); + assert!( + squeezed.starts_with("{ \"annotations\": [ { \"id\": 1, \"note\": \"revisar\" }"), + "«{squeezed}»" + ); + assert!(!squeezed.contains('\n')); + assert!(!squeezed.contains(" "), "no run of spaces survives"); +} + +#[test] +fn squeezing_leaves_a_single_line_alone() { + assert_eq!( + squeezed_of("ya viene en una linea"), + "ya viene en una linea" + ); + assert_eq!(squeezed_of(""), ""); + assert_eq!(squeezed_of(" \n\t "), ""); +} + +#[test] +fn squeezing_keeps_the_words_and_their_order() { + let said = "primero\n\n\tsegundo tercero\r\ncuarto "; + assert_eq!(squeezed_of(said), "primero segundo tercero cuarto"); +} diff --git a/crates/cp-panel/ui/cards.slint b/crates/cp-panel/ui/cards.slint index fc472005..967583ce 100644 --- a/crates/cp-panel/ui/cards.slint +++ b/crates/cp-panel/ui/cards.slint @@ -208,7 +208,7 @@ export component CardView inherits Rectangle { } if !card.has-thumb && (!card.found || open) : Text { - text: card.body; + text: open ? card.body : card.squeezed; color: card.mono ? Theme.mono : Theme.body; font-size: card.mono ? 12px : 13px; font-family: card.mono ? "Cascadia Mono" : "Segoe UI"; diff --git a/crates/cp-panel/ui/types.slint b/crates/cp-panel/ui/types.slint index 4cf3fe0b..0f6b9f43 100644 --- a/crates/cp-panel/ui/types.slint +++ b/crates/cp-panel/ui/types.slint @@ -6,6 +6,7 @@ export struct Card { times: string, age: string, body: string, + squeezed: string, lead: string, hit: string, tail: string, From d4880fb67d2880011d2696b0a3bcdbb46f827099 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 30 Sep 2026 21:29:28 -0300 Subject: [PATCH 02/22] feat(panel): the filter decides the layout, and the compiler decides the rest --- crates/cp-panel/src/app.rs | 2 + crates/cp-panel/src/layout.rs | 76 ++++++++++++++++++++++++ crates/cp-panel/src/layout_test.rs | 94 ++++++++++++++++++++++++++++++ crates/cp-panel/src/main.rs | 1 + crates/cp-panel/ui/panel.slint | 1 + 5 files changed, 174 insertions(+) create mode 100644 crates/cp-panel/src/layout.rs create mode 100644 crates/cp-panel/src/layout_test.rs diff --git a/crates/cp-panel/src/app.rs b/crates/cp-panel/src/app.rs index 5a61ada8..4695d842 100644 --- a/crates/cp-panel/src/app.rs +++ b/crates/cp-panel/src/app.rs @@ -680,6 +680,7 @@ fn spawn_counter( let footer = count_text(shown); let anchored = compact(pinned); let only_anchored = request.full.pinned_only; + let asked = crate::layout::layout_for(&request.full.kinds); let mine = request.generation; let clock = generation.clone(); let _ = ui.upgrade_in_event_loop(move |panel| { @@ -690,6 +691,7 @@ fn spawn_counter( panel.set_count_text(footer.into()); panel.set_pinned_count(anchored.into()); panel.set_pinned_on(only_anchored); + panel.set_layout(asked.as_str().into()); }); } }); diff --git a/crates/cp-panel/src/layout.rs b/crates/cp-panel/src/layout.rs new file mode 100644 index 00000000..cfb9640c --- /dev/null +++ b/crates/cp-panel/src/layout.rs @@ -0,0 +1,76 @@ +use cp_core::kind::Kind; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Layout { + Everything, + Json, + Image, + Link, + Video, + Audio, + Folder, + Papers, +} + +impl Layout { + #[cfg(test)] + pub const ALL: [Layout; 8] = [ + Layout::Everything, + Layout::Json, + Layout::Image, + Layout::Link, + Layout::Video, + Layout::Audio, + Layout::Folder, + Layout::Papers, + ]; + + pub fn as_str(self) -> &'static str { + match self { + Layout::Everything => "everything", + Layout::Json => "json", + Layout::Image => "image", + Layout::Link => "link", + Layout::Video => "video", + Layout::Audio => "audio", + Layout::Folder => "folder", + Layout::Papers => "papers", + } + } +} + +pub fn layout_for(kinds: &[Kind]) -> Layout { + let mut only = None; + for kind in kinds { + match only { + None => only = Some(*kind), + Some(seen) if seen == *kind => {} + Some(_) => return Layout::Everything, + } + } + only.map_or(Layout::Everything, of_kind) +} + +fn of_kind(kind: Kind) -> Layout { + match kind { + Kind::Json => Layout::Json, + Kind::Image => Layout::Image, + Kind::Link => Layout::Link, + Kind::Video => Layout::Video, + Kind::Audio => Layout::Audio, + Kind::Folder => Layout::Folder, + Kind::File => Layout::Papers, + Kind::Text + | Kind::Code + | Kind::Email + | Kind::Phone + | Kind::Color + | Kind::Ip + | Kind::Uuid + | Kind::Token => Layout::Everything, + } +} + +#[cfg(test)] +#[path = "layout_test.rs"] +mod tests; diff --git a/crates/cp-panel/src/layout_test.rs b/crates/cp-panel/src/layout_test.rs new file mode 100644 index 00000000..f674da86 --- /dev/null +++ b/crates/cp-panel/src/layout_test.rs @@ -0,0 +1,94 @@ +use super::*; + +#[test] +fn no_filter_at_all_is_the_general_layout() { + assert_eq!(layout_for(&[]), Layout::Everything); +} + +#[test] +fn one_kind_gets_the_layout_that_kind_asked_for() { + assert_eq!(layout_for(&[Kind::Json]), Layout::Json); + assert_eq!(layout_for(&[Kind::Image]), Layout::Image); + assert_eq!(layout_for(&[Kind::Link]), Layout::Link); + assert_eq!(layout_for(&[Kind::Video]), Layout::Video); + assert_eq!(layout_for(&[Kind::Audio]), Layout::Audio); + assert_eq!(layout_for(&[Kind::Folder]), Layout::Folder); + assert_eq!(layout_for(&[Kind::File]), Layout::Papers); +} + +#[test] +fn more_than_one_kind_goes_back_to_the_general_layout() { + assert_eq!(layout_for(&[Kind::Json, Kind::Image]), Layout::Everything); + assert_eq!( + layout_for(&[Kind::Video, Kind::Audio, Kind::Folder]), + Layout::Everything + ); +} + +#[test] +fn the_same_kind_twice_is_still_one_kind() { + assert_eq!(layout_for(&[Kind::Json, Kind::Json]), Layout::Json); + assert_eq!( + layout_for(&[Kind::Image, Kind::Image, Kind::Image]), + Layout::Image + ); +} + +#[test] +fn a_kind_with_nothing_designed_for_it_stays_general() { + for kind in [ + Kind::Text, + Kind::Code, + Kind::Email, + Kind::Phone, + Kind::Color, + Kind::Ip, + Kind::Uuid, + Kind::Token, + ] { + assert_eq!( + layout_for(&[kind]), + Layout::Everything, + "{kind:?} has no layout of its own yet" + ); + } +} + +#[test] +fn every_kind_the_store_knows_answers_something() { + for kind in Kind::ALL { + let _ = layout_for(&[kind]); + } + assert_eq!(Kind::ALL.len(), 15, "a new kind needs a decision here"); +} + +#[test] +fn the_seven_kinds_with_a_layout_are_the_seven_that_were_drawn() { + let with_one: Vec = Kind::ALL + .into_iter() + .filter(|kind| layout_for(&[*kind]) != Layout::Everything) + .collect(); + assert_eq!( + with_one, + vec![ + Kind::Json, + Kind::Link, + Kind::Image, + Kind::File, + Kind::Folder, + Kind::Audio, + Kind::Video + ], + "the maquette drew these seven and no others" + ); +} + +#[test] +fn no_two_layouts_answer_to_the_same_name() { + for (at, one) in Layout::ALL.iter().enumerate() { + for other in &Layout::ALL[at + 1..] { + assert_ne!(one.as_str(), other.as_str(), "{one:?} and {other:?}"); + } + assert!(!one.as_str().is_empty()); + } +} diff --git a/crates/cp-panel/src/main.rs b/crates/cp-panel/src/main.rs index 754d22a3..7a1ec819 100644 --- a/crates/cp-panel/src/main.rs +++ b/crates/cp-panel/src/main.rs @@ -2,6 +2,7 @@ mod age; mod app; mod engine; mod here; +mod layout; mod measure; mod model; mod note; diff --git a/crates/cp-panel/ui/panel.slint b/crates/cp-panel/ui/panel.slint index eabf5d68..e84ab7ff 100644 --- a/crates/cp-panel/ui/panel.slint +++ b/crates/cp-panel/ui/panel.slint @@ -23,6 +23,7 @@ export component Panel inherits Window { in-out property sheet-span; in property sheet-subject; in property sheet-kind; + in property layout: "everything"; in property count-text; in property pinned-count; in property empty-title; From b0efdcec3d80052c25c38ff2e22fe457465fec60 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 30 Sep 2026 21:49:37 -0300 Subject: [PATCH 03/22] feat(panel): a json row can say what it is without parsing what it cannot see --- crates/cp-panel/src/main.rs | 1 + crates/cp-panel/src/shape.rs | 154 ++++++++++++++++++++++++++++ crates/cp-panel/src/shape_test.rs | 165 ++++++++++++++++++++++++++++++ crates/cp-panel/src/view.rs | 11 ++ crates/cp-panel/ui/types.slint | 3 + 5 files changed, 334 insertions(+) create mode 100644 crates/cp-panel/src/shape.rs create mode 100644 crates/cp-panel/src/shape_test.rs diff --git a/crates/cp-panel/src/main.rs b/crates/cp-panel/src/main.rs index 7a1ec819..5e385d0b 100644 --- a/crates/cp-panel/src/main.rs +++ b/crates/cp-panel/src/main.rs @@ -7,6 +7,7 @@ mod measure; mod model; mod note; mod say; +mod shape; mod view; slint::include_modules!(); diff --git a/crates/cp-panel/src/shape.rs b/crates/cp-panel/src/shape.rs new file mode 100644 index 00000000..6545eef2 --- /dev/null +++ b/crates/cp-panel/src/shape.rs @@ -0,0 +1,154 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Root { + Object, + Array, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Shape { + pub root: Root, + pub named: Vec, + pub counted: usize, + pub deep: usize, + pub whole: bool, +} + +const NAMES_UP_TO: usize = 8; + +pub fn shape_of(text: &str) -> Option { + let mut chars = text + .char_indices() + .skip_while(|(_, one)| one.is_whitespace()); + let (_, opened) = chars.next()?; + let root = match opened { + '{' => Root::Object, + '[' => Root::Array, + _ => return None, + }; + + let mut shape = Shape { + root, + named: Vec::new(), + counted: 0, + deep: 1, + whole: false, + }; + let mut depth = 1usize; + let mut quoted = false; + let mut escaped = false; + let mut word = String::new(); + let mut held: Option = None; + let mut body = false; + + for (_, one) in chars { + if quoted { + if escaped { + escaped = false; + word.push(one); + continue; + } + match one { + '\\' => escaped = true, + '"' => { + quoted = false; + held = Some(std::mem::take(&mut word)); + } + _ => word.push(one), + } + continue; + } + match one { + '"' => { + quoted = true; + body = true; + word.clear(); + } + '{' | '[' => { + depth += 1; + shape.deep = shape.deep.max(depth); + body = true; + held = None; + } + '}' | ']' => { + depth = depth.saturating_sub(1); + held = None; + if depth == 0 { + if root == Root::Array && body { + shape.counted += 1; + } + shape.whole = true; + break; + } + } + ':' if depth == 1 && root == Root::Object => { + if let Some(name) = held.take() { + shape.counted += 1; + if shape.named.len() < NAMES_UP_TO { + shape.named.push(name); + } + } + } + ',' if depth == 1 => { + held = None; + if root == Root::Array { + shape.counted += 1; + body = false; + } + } + one if !one.is_whitespace() => { + body = true; + held = None; + } + _ => {} + } + } + + if !shape.whole && root == Root::Array && body { + shape.counted += 1; + } + Some(shape) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Said { + pub root: String, + pub counted: String, + pub keys: String, +} + +pub fn said_of(text: &str, english: bool) -> Option { + let shape = shape_of(text)?; + Some(Said { + root: match shape.root { + Root::Object => "{ }".to_owned(), + Root::Array => "[ ]".to_owned(), + }, + counted: counted_text(&shape, english), + keys: shape.named.join(" · "), + }) +} + +fn counted_text(shape: &Shape, english: bool) -> String { + let say = |es: &'static str, en: &'static str| crate::say::pick_in(english, es, en); + let what = match (shape.root, shape.counted) { + (Root::Object, 1) => say("clave", "key"), + (Root::Object, _) => say("claves", "keys"), + (Root::Array, 1) => say("elemento", "element"), + (Root::Array, _) => say("elementos", "elements"), + }; + let how_many = if shape.whole { + format!("{} {what}", shape.counted) + } else if english { + format!("more than {} {what}", shape.counted) + } else { + format!("más de {} {what}", shape.counted) + }; + if shape.deep <= 1 { + return how_many; + } + format!("{how_many} · {} {}", shape.deep, say("niveles", "levels")) +} + +#[cfg(test)] +#[path = "shape_test.rs"] +mod tests; diff --git a/crates/cp-panel/src/shape_test.rs b/crates/cp-panel/src/shape_test.rs new file mode 100644 index 00000000..2d824535 --- /dev/null +++ b/crates/cp-panel/src/shape_test.rs @@ -0,0 +1,165 @@ +use super::*; + +#[test] +fn what_is_not_json_has_no_shape() { + assert!(shape_of("").is_none()); + assert!(shape_of(" ").is_none()); + assert!(shape_of("hola").is_none()); + assert!(shape_of("\"solo una cadena\"").is_none()); + assert!(shape_of("42").is_none()); +} + +#[test] +fn an_object_gives_its_first_level_keys_in_order() { + let shape = + shape_of(r#"{"id":4417,"cliente":{"nombre":"Acme"},"total":148900}"#).expect("json"); + assert_eq!(shape.root, Root::Object); + assert_eq!(shape.named, vec!["id", "cliente", "total"]); + assert_eq!(shape.counted, 3); + assert!(shape.whole); +} + +#[test] +fn keys_deeper_down_are_not_counted_as_first_level() { + let shape = shape_of(r#"{"a":{"b":{"c":1}},"d":[{"e":2}]}"#).expect("json"); + assert_eq!(shape.named, vec!["a", "d"]); + assert_eq!(shape.counted, 2); + assert_eq!(shape.deep, 3, "tres llaves abiertas a la vez, no cuatro"); +} + +#[test] +fn an_indented_object_reads_the_same_as_a_flat_one() { + let flat = shape_of(r#"{"annotations":[1,2],"id":7}"#).expect("json"); + let pretty = + shape_of("{\n \"annotations\": [\n 1,\n 2\n ],\n \"id\": 7\n}").expect("json"); + assert_eq!(flat.named, pretty.named); + assert_eq!(flat.counted, pretty.counted); + assert_eq!(flat.deep, pretty.deep); + assert_eq!(flat.whole, pretty.whole); +} + +#[test] +fn an_array_counts_its_elements() { + assert_eq!(shape_of("[]").expect("json").counted, 0); + assert_eq!(shape_of("[1]").expect("json").counted, 1); + assert_eq!(shape_of("[1,2,3]").expect("json").counted, 3); + let objects = shape_of(r#"[{"at":1},{"at":2}]"#).expect("json"); + assert_eq!(objects.root, Root::Array); + assert_eq!(objects.counted, 2); + assert!(objects.named.is_empty(), "an array has no keys of its own"); +} + +#[test] +fn a_text_cut_in_the_middle_says_so_and_keeps_what_it_read() { + let shape = shape_of(r#"{"id":4417,"cliente":{"nombre":"Ac"#).expect("json"); + assert!(!shape.whole, "it never closed"); + assert_eq!(shape.named, vec!["id", "cliente"]); + assert_eq!(shape.counted, 2, "only what the text allowed"); +} + +#[test] +fn a_cut_array_still_counts_the_element_it_was_reading() { + let shape = shape_of(r#"[{"at":1},{"at":2},{"at"#).expect("json"); + assert!(!shape.whole); + assert_eq!(shape.counted, 3); +} + +#[test] +fn a_brace_inside_a_string_is_not_a_level() { + let shape = shape_of(r#"{"plantilla":"{{nombre}}","otra":"]["}"#).expect("json"); + assert_eq!(shape.named, vec!["plantilla", "otra"]); + assert_eq!( + shape.deep, 1, + "nothing nested, only text that looks like it" + ); + assert!(shape.whole); +} + +#[test] +fn an_escaped_quote_does_not_end_the_string() { + let shape = shape_of(r#"{"dijo":"ella dijo \"hola\"","luego":2}"#).expect("json"); + assert_eq!(shape.named, vec!["dijo", "luego"]); + assert_eq!(shape.counted, 2); + assert!(shape.whole); +} + +#[test] +fn a_colon_inside_a_string_does_not_make_a_key() { + let shape = shape_of(r#"{"url":"https://ejemplo.test:8080/x"}"#).expect("json"); + assert_eq!(shape.named, vec!["url"]); + assert_eq!(shape.counted, 1); +} + +#[test] +fn a_key_with_an_accent_or_an_emoji_survives_whole() { + let shape = shape_of(r#"{"año":2026,"día":"martes","🔑":1}"#).expect("json"); + assert_eq!(shape.named, vec!["año", "día", "🔑"]); +} + +#[test] +fn only_the_first_handful_of_keys_is_kept_but_all_are_counted() { + let many: String = (0..30) + .map(|at| format!("\"k{at}\":{at}")) + .collect::>() + .join(","); + let shape = shape_of(&format!("{{{many}}}")).expect("json"); + assert_eq!(shape.counted, 30); + assert_eq!(shape.named.len(), NAMES_UP_TO); + assert_eq!(shape.named[0], "k0"); +} + +#[test] +fn trailing_space_after_the_close_is_still_whole() { + assert!(shape_of("{\"a\":1} \n").expect("json").whole); +} + +#[test] +fn what_the_row_says_about_an_object_it_read_whole() { + let said = said_of(r#"{"id":1,"cliente":{"plan":"pro"}}"#, false).expect("json"); + assert_eq!(said.root, "{ }"); + assert_eq!(said.counted, "2 claves · 2 niveles"); + assert_eq!(said.keys, "id · cliente"); +} + +#[test] +fn what_the_row_says_when_the_text_was_cut() { + let said = said_of(r#"{"id":1,"cliente":{"plan":"pr"#, false).expect("json"); + assert_eq!(said.counted, "más de 2 claves · 2 niveles"); + assert_eq!( + said_of(r#"{"id":1,"c"#, true).expect("json").counted, + "more than 1 key" + ); +} + +#[test] +fn one_of_something_is_said_in_the_singular() { + assert_eq!( + said_of(r#"{"solo":1}"#, false).expect("json").counted, + "1 clave" + ); + assert_eq!(said_of("[7]", false).expect("json").counted, "1 elemento"); + assert_eq!( + said_of(r#"{"solo":1}"#, true).expect("json").counted, + "1 key" + ); + assert_eq!(said_of("[7]", true).expect("json").counted, "1 element"); +} + +#[test] +fn a_flat_object_does_not_mention_levels() { + let said = said_of(r#"{"a":1,"b":2}"#, false).expect("json"); + assert_eq!(said.counted, "2 claves", "one level is not worth saying"); +} + +#[test] +fn an_array_is_counted_in_elements_and_shows_no_keys() { + let said = said_of(r#"[{"at":1},{"at":2},{"at":3}]"#, false).expect("json"); + assert_eq!(said.root, "[ ]"); + assert_eq!(said.counted, "3 elementos · 2 niveles"); + assert_eq!(said.keys, ""); +} + +#[test] +fn what_is_not_json_says_nothing_at_all() { + assert!(said_of("hola", false).is_none()); +} diff --git a/crates/cp-panel/src/view.rs b/crates/cp-panel/src/view.rs index 94f4b0da..8f9164fe 100644 --- a/crates/cp-panel/src/view.rs +++ b/crates/cp-panel/src/view.rs @@ -77,6 +77,14 @@ fn claims_in(row: &Listed) -> Option { pub fn card_of(row: &Listed, now: i64) -> Card { let kind = row.kind.map(Kind::as_str).unwrap_or("text"); let claims = claims_in(row); + let shape = (row.kind == Some(Kind::Json)) + .then(|| crate::shape::said_of(&row.preview, crate::say::in_english())) + .flatten() + .unwrap_or_else(|| crate::shape::Said { + root: String::new(), + counted: String::new(), + keys: String::new(), + }); let body = body_of(row); let (lead, hit, tail) = match &row.snippet { Some(snippet) => parts_of(&snippet.excerpt), @@ -95,6 +103,9 @@ pub fn card_of(row: &Listed, now: i64) -> Card { age: age_text(now, row.modified_at).into(), lines: lines_of(&body), squeezed: squeezed_of(&body).into(), + shape_root: shape.root.into(), + shape_said: shape.counted.into(), + shape_keys: shape.keys.into(), body: body.into(), found: !hit.is_empty(), badge: badge_of(claims.as_ref()).into(), diff --git a/crates/cp-panel/ui/types.slint b/crates/cp-panel/ui/types.slint index 0f6b9f43..c63d5ebb 100644 --- a/crates/cp-panel/ui/types.slint +++ b/crates/cp-panel/ui/types.slint @@ -7,6 +7,9 @@ export struct Card { age: string, body: string, squeezed: string, + shape-root: string, + shape-said: string, + shape-keys: string, lead: string, hit: string, tail: string, From 129a40ade353a8c410df701ccbbb6ba5b16d7dd4 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 30 Sep 2026 22:06:39 -0300 Subject: [PATCH 04/22] feat(panel): one body per view, and the row height follows the filter --- crates/cp-panel/src/app.rs | 1 + crates/cp-panel/src/model.rs | 16 +++++++++-- crates/cp-panel/src/model_test.rs | 23 +++++++++++++++ crates/cp-panel/ui/bodies.slint | 48 +++++++++++++++++++++++++++++++ crates/cp-panel/ui/cards.slint | 8 +++++- crates/cp-panel/ui/panel.slint | 1 + crates/cp-panel/ui/theme.slint | 1 + 7 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 crates/cp-panel/ui/bodies.slint diff --git a/crates/cp-panel/src/app.rs b/crates/cp-panel/src/app.rs index 4695d842..c5489928 100644 --- a/crates/cp-panel/src/app.rs +++ b/crates/cp-panel/src/app.rs @@ -74,6 +74,7 @@ impl App { let metrics = Metrics { tall: theme.get_row_thumb(), plain: theme.get_row_plain(), + json: theme.get_row_json(), found: theme.get_row_found(), frame: theme.get_row_frame(), line: theme.get_line(), diff --git a/crates/cp-panel/src/model.rs b/crates/cp-panel/src/model.rs index e7d62fb8..cb960b17 100644 --- a/crates/cp-panel/src/model.rs +++ b/crates/cp-panel/src/model.rs @@ -12,6 +12,7 @@ const AHEAD: usize = 40; pub struct Metrics { pub tall: f32, pub plain: f32, + pub json: f32, pub found: f32, pub frame: f32, pub line: f32, @@ -19,6 +20,13 @@ pub struct Metrics { const EDGE: f32 = 8.0; +pub fn shut_height_for(filter: &Filter, metrics: &Metrics) -> f32 { + match crate::layout::layout_for(&filter.kinds) { + crate::layout::Layout::Json => metrics.json, + _ => metrics.plain, + } +} + pub fn reveal(top: f32, span: f32, scroll: f32, viewport: f32) -> f32 { if viewport <= 0.0 { return scroll; @@ -133,7 +141,7 @@ impl Rows { return if was_found(row) { self.metrics.found } else { - self.metrics.plain + self.shut_height() }; } self.height_of(row) @@ -145,10 +153,14 @@ impl Rows { } else if was_found(row) { self.metrics.found } else { - self.metrics.plain + self.shut_height() } } + fn shut_height(&self) -> f32 { + shut_height_for(&self.filter, &self.metrics) + } + fn resize(&self, index: usize, height: f32) { let mut tops = self.tops.borrow_mut(); let (Some(&top), Some(&next)) = (tops.get(index), tops.get(index + 1)) else { diff --git a/crates/cp-panel/src/model_test.rs b/crates/cp-panel/src/model_test.rs index d928ffa4..6f173ec7 100644 --- a/crates/cp-panel/src/model_test.rs +++ b/crates/cp-panel/src/model_test.rs @@ -2,6 +2,7 @@ use super::*; const SIZES: Metrics = Metrics { tall: 146.0, + json: 112.0, plain: 86.0, found: 68.0, frame: 50.0, @@ -216,3 +217,25 @@ fn revealing_a_row_only_scrolls_when_the_row_is_out_of_sight() { ); assert_eq!(reveal(500.0, 124.0, 0.0, 0.0), 0.0, "sin alto no se decide"); } + +#[test] +fn a_json_only_list_gives_its_rows_the_taller_shut_height() { + let metrics = SIZES; + let general = Filter::default(); + let only_json = Filter { + kinds: vec![cp_core::kind::Kind::Json], + ..Default::default() + }; + let mixed = Filter { + kinds: vec![cp_core::kind::Kind::Json, cp_core::kind::Kind::Text], + ..Default::default() + }; + + assert_eq!(shut_height_for(&general, &metrics), 86.0); + assert_eq!(shut_height_for(&only_json, &metrics), 112.0); + assert_eq!( + shut_height_for(&mixed, &metrics), + 86.0, + "two kinds is the general layout, and the general height" + ); +} diff --git a/crates/cp-panel/ui/bodies.slint b/crates/cp-panel/ui/bodies.slint new file mode 100644 index 00000000..814116a2 --- /dev/null +++ b/crates/cp-panel/ui/bodies.slint @@ -0,0 +1,48 @@ +import { Card } from "types.slint"; +import { Theme } from "theme.slint"; + +export component JsonBody inherits VerticalLayout { + in property card; + spacing: 7px; + + HorizontalLayout { + height: 16px; + spacing: 7px; + alignment: start; + Rectangle { + width: root-row.preferred-width; + height: 16px; + border-radius: 4px; + background: Theme.field; + root-row := HorizontalLayout { + padding-left: 5px; + padding-right: 5px; + Text { + text: card.shape-root; + color: Theme.accent; + font-family: "Cascadia Mono"; + font-size: 10.5px; + vertical-alignment: center; + } + } + } + Text { + text: card.shape-said; + color: Theme.title; + font-size: 11.5px; + font-weight: 600; + vertical-alignment: center; + overflow: elide; + } + } + + Text { + text: card.shape-keys; + color: Theme.muted; + font-family: "Cascadia Mono"; + font-size: 11.5px; + wrap: word-wrap; + overflow: elide; + height: 36px; + } +} diff --git a/crates/cp-panel/ui/cards.slint b/crates/cp-panel/ui/cards.slint index 967583ce..92e51c33 100644 --- a/crates/cp-panel/ui/cards.slint +++ b/crates/cp-panel/ui/cards.slint @@ -1,4 +1,5 @@ import { Card, Chip } from "types.slint"; +import { JsonBody } from "bodies.slint"; import { Theme } from "theme.slint"; import { Words } from "words.slint"; @@ -158,6 +159,7 @@ export component Tag inherits Rectangle { export component CardView inherits Rectangle { in property card; + in property layout; in property current; in property open; in property shown-here; @@ -207,7 +209,11 @@ export component CardView inherits Rectangle { Text { text: card.tail; color: Theme.body; font-size: 12.5px; overflow: elide; horizontal-stretch: 1; vertical-alignment: center; } } - if !card.has-thumb && (!card.found || open) : Text { + if !card.has-thumb && !open && layout == "json" : JsonBody { + card: card; + } + + if !card.has-thumb && (!card.found || open) && !(layout == "json" && !open) : Text { text: open ? card.body : card.squeezed; color: card.mono ? Theme.mono : Theme.body; font-size: card.mono ? 12px : 13px; diff --git a/crates/cp-panel/ui/panel.slint b/crates/cp-panel/ui/panel.slint index e84ab7ff..2a73eed7 100644 --- a/crates/cp-panel/ui/panel.slint +++ b/crates/cp-panel/ui/panel.slint @@ -505,6 +505,7 @@ export component Panel inherits Window { } root.moved(index); } + layout: root.layout; paste(id) => { root.paste(id); } forms(id) => { root.current = index; diff --git a/crates/cp-panel/ui/theme.slint b/crates/cp-panel/ui/theme.slint index 93783f33..cf5f49ee 100644 --- a/crates/cp-panel/ui/theme.slint +++ b/crates/cp-panel/ui/theme.slint @@ -24,6 +24,7 @@ export global Theme { out property margin: 18px; out property row-thumb: 146px; out property row-plain: 86px; + out property row-json: 112px; out property row-found: 68px; out property row-frame: 50px; out property line: 18px; From ff630e90199952d92106499a2c1a1b3fc755da97 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 30 Sep 2026 22:12:40 -0300 Subject: [PATCH 05/22] feat(store): the meta a page needs comes in one query, not one per row --- crates/cp-store/src/store.rs | 36 +++++++++++++++++ crates/cp-store/src/store_listing_test.rs | 48 +++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/crates/cp-store/src/store.rs b/crates/cp-store/src/store.rs index 9c557f19..81e53df1 100644 --- a/crates/cp-store/src/store.rs +++ b/crates/cp-store/src/store.rs @@ -242,6 +242,8 @@ impl Clauses { } } +pub type MetaByItem = std::collections::HashMap>; + pub struct Store { db: Connection, blobs: Option, @@ -448,6 +450,40 @@ impl Store { Ok(rows.collect::>()?) } + pub fn meta_for(&self, ids: &[i64], keys: &[&str]) -> Result { + if ids.is_empty() || keys.is_empty() { + return Ok(MetaByItem::new()); + } + let marks = |how_many: usize| vec!["?"; how_many].join(", "); + let sql = format!( + "SELECT item_id, key, value FROM item_meta + WHERE item_id IN ({}) AND key IN ({})", + marks(ids.len()), + marks(keys.len()) + ); + let mut bound: Vec> = Vec::with_capacity(ids.len() + keys.len()); + for id in ids { + bound.push(Box::new(*id)); + } + for key in keys { + bound.push(Box::new((*key).to_owned())); + } + let mut stmt = self.db.prepare(&sql)?; + let rows = stmt.query_map(params_from_iter(bound.iter()), |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + })?; + let mut found = MetaByItem::new(); + for row in rows { + let (id, key, value) = row?; + found.entry(id).or_default().insert(key, value); + } + Ok(found) + } + pub fn enqueue(&self, id: i64, job: &str) -> Result<()> { self.db.execute( "INSERT OR IGNORE INTO pending_work (item_id, job) VALUES (?1, ?2)", diff --git a/crates/cp-store/src/store_listing_test.rs b/crates/cp-store/src/store_listing_test.rs index 6fe9f14e..f5a0668e 100644 --- a/crates/cp-store/src/store_listing_test.rs +++ b/crates/cp-store/src/store_listing_test.rs @@ -987,3 +987,51 @@ fn what_you_paste_rises_to_the_top_of_the_recent_order() { ); assert_eq!(ordered[0].paste_count, 1); } + +#[test] +fn the_meta_of_a_whole_page_comes_in_one_query() { + let store = history(); + let rows = all(&store, &Filter::default()); + store.set_meta(rows[0].id, "duration", "138").expect("set"); + store.set_meta(rows[0].id, "width", "1920").expect("set"); + store.set_meta(rows[0].id, "artist", "nadie").expect("set"); + store.set_meta(rows[1].id, "duration", "3").expect("set"); + + let ids: Vec = rows.iter().map(|one| one.id).collect(); + let found = store.meta_for(&ids, &["duration", "width"]).expect("meta"); + + assert_eq!(found.len(), 2, "only the two that have any of those keys"); + let first = &found[&rows[0].id]; + assert_eq!(first.get("duration").map(String::as_str), Some("138")); + assert_eq!(first.get("width").map(String::as_str), Some("1920")); + assert!(first.get("artist").is_none(), "a key nobody asked for"); + assert_eq!(found[&rows[1].id].len(), 1); +} + +#[test] +fn asking_for_nothing_reads_nothing() { + let store = history(); + let rows = all(&store, &Filter::default()); + store.set_meta(rows[0].id, "duration", "138").expect("set"); + assert!(store.meta_for(&[], &["duration"]).expect("meta").is_empty()); + assert!(store.meta_for(&[rows[0].id], &[]).expect("meta").is_empty()); +} + +#[test] +fn an_item_with_no_meta_at_all_is_simply_absent() { + let store = history(); + let rows = all(&store, &Filter::default()); + let ids: Vec = rows.iter().map(|one| one.id).collect(); + let found = store.meta_for(&ids, &["duration"]).expect("meta"); + assert!(found.is_empty(), "nobody wrote any, so nobody answers"); +} + +#[test] +fn deleting_an_item_takes_its_meta_with_it() { + let store = history(); + let rows = all(&store, &Filter::default()); + store.set_meta(rows[0].id, "duration", "138").expect("set"); + store.mark_deleted(rows[0].id, 99).expect("deleted"); + let found = store.meta_for(&[rows[0].id], &["duration"]).expect("meta"); + assert!(found.is_empty(), "erasing a row leaves no meta behind"); +} From 4ae643dfc5d881d8fe69c88d755d9f30f0c28fb2 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 30 Sep 2026 22:16:55 -0300 Subject: [PATCH 06/22] feat(panel): video and audio finally get the shell facts nobody was masking for --- crates/cp-panel/src/engine.rs | 30 ++++++++++++++ crates/cp-panel/src/here.rs | 24 ++++++++++- crates/cp-panel/src/main.rs | 1 + crates/cp-panel/src/media.rs | 39 ++++++++++++++++++ crates/cp-panel/src/media_test.rs | 67 +++++++++++++++++++++++++++++++ 5 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 crates/cp-panel/src/media.rs create mode 100644 crates/cp-panel/src/media_test.rs diff --git a/crates/cp-panel/src/engine.rs b/crates/cp-panel/src/engine.rs index bd3beed9..893070f1 100644 --- a/crates/cp-panel/src/engine.rs +++ b/crates/cp-panel/src/engine.rs @@ -122,6 +122,10 @@ fn errand(store: &Store, thumbs: &Path) -> bool { read_out(store, id, at); return true; } + if let Some(id) = first_waiting(store, "media", at) { + measured(store, id, at); + return true; + } false } @@ -150,6 +154,30 @@ fn thumbed(store: &Store, id: i64, at: i64, thumbs: &Path) { done(store, id, "thumb"); } +fn measured(store: &Store, id: i64, at: i64) { + let Some(path) = store + .item(id) + .ok() + .flatten() + .as_ref() + .and_then(crate::media::first_path_of) + else { + done(store, id, "media"); + return; + }; + let said = here::media_of(std::path::Path::new(&path)); + if said.is_empty() { + give_up(store, id, "media", "the shell knows nothing about it", at); + return; + } + for (key, value) in said { + if let Err(why) = store.set_meta(id, key, &value) { + note(&format!("{id} has a {key} nobody wrote down: {why}")); + } + } + done(store, id, "media"); +} + fn read_out(store: &Store, id: i64, at: i64) { if !here::ocr_available() { done(store, id, "ocr"); @@ -294,6 +322,8 @@ fn name_for(at: i64, _item: &Item) -> String { fn jobs_for(item: &Item) -> &'static [&'static str] { match item.kind { Some(Kind::Image) => &["thumb", "ocr"], + Some(Kind::Video) => &["thumb", "media"], + Some(Kind::Audio) => &["media"], Some(Kind::File) | Some(Kind::Folder) if here::THUMBNAILS_FILES => &["thumb"], _ => &[], } diff --git a/crates/cp-panel/src/here.rs b/crates/cp-panel/src/here.rs index f835397b..6b69b823 100644 --- a/crates/cp-panel/src/here.rs +++ b/crates/cp-panel/src/here.rs @@ -7,8 +7,8 @@ use std::path::{Path, PathBuf}; pub use platform::{ THUMBNAILS_FILES, Watching, ahead_now, capture_insisting, content_of, data_dir, dress, forward, - in_front, ocr_available, paste_into, read_stuck, stay_out_of_the_dock, system_is_light, - text_in, thumb_of_file, thumbs_dir, to_clipboard, + in_front, media_of, ocr_available, paste_into, read_stuck, stay_out_of_the_dock, + system_is_light, text_in, thumb_of_file, thumbs_dir, to_clipboard, }; #[derive(Debug, Clone, PartialEq, Eq)] @@ -78,6 +78,16 @@ mod platform { cp_win_sys::clipboard::read_stuck_for().is_some() } + pub fn media_of(path: &Path) -> Vec<(&'static str, String)> { + crate::media::said_of( + cp_win_sys::media::info_for(path).map(|one| crate::media::Facts { + duration: one.duration, + width: one.width, + height: one.height, + }), + ) + } + pub fn system_is_light() -> bool { cp_win_sys::theme::wants_light().unwrap_or(false) } @@ -174,6 +184,16 @@ mod platform { false } + pub fn media_of(path: &Path) -> Vec<(&'static str, String)> { + crate::media::said_of( + cp_mac_sys::media::info_for(path).map(|one| crate::media::Facts { + duration: one.duration, + width: one.width, + height: one.height, + }), + ) + } + pub fn system_is_light() -> bool { cp_mac_sys::theme::wants_light().unwrap_or(false) } diff --git a/crates/cp-panel/src/main.rs b/crates/cp-panel/src/main.rs index 5e385d0b..1f22a78d 100644 --- a/crates/cp-panel/src/main.rs +++ b/crates/cp-panel/src/main.rs @@ -4,6 +4,7 @@ mod engine; mod here; mod layout; mod measure; +mod media; mod model; mod note; mod say; diff --git a/crates/cp-panel/src/media.rs b/crates/cp-panel/src/media.rs new file mode 100644 index 00000000..7b6bb87f --- /dev/null +++ b/crates/cp-panel/src/media.rs @@ -0,0 +1,39 @@ +use cp_core::item::Item; + +pub const DURATION: &str = "duration-ms"; +pub const WIDTH: &str = "pixels-wide"; +pub const HEIGHT: &str = "pixels-high"; + +pub const KEYS: [&str; 3] = [DURATION, WIDTH, HEIGHT]; + +pub fn first_path_of(item: &Item) -> Option { + crate::here::content_of(item, None).paths.into_iter().next() +} + +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct Facts { + pub duration: Option, + pub width: Option, + pub height: Option, +} + +pub fn said_of(facts: Option) -> Vec<(&'static str, String)> { + let Some(facts) = facts else { + return Vec::new(); + }; + let mut said = Vec::with_capacity(KEYS.len()); + if let Some(seconds) = facts.duration.filter(|one| one.is_finite() && *one > 0.0) { + said.push((DURATION, format!("{:.0}", seconds * 1_000.0))); + } + if let Some(wide) = facts.width.filter(|one| *one > 0) { + said.push((WIDTH, wide.to_string())); + } + if let Some(high) = facts.height.filter(|one| *one > 0) { + said.push((HEIGHT, high.to_string())); + } + said +} + +#[cfg(test)] +#[path = "media_test.rs"] +mod tests; diff --git a/crates/cp-panel/src/media_test.rs b/crates/cp-panel/src/media_test.rs new file mode 100644 index 00000000..c0dc736b --- /dev/null +++ b/crates/cp-panel/src/media_test.rs @@ -0,0 +1,67 @@ +use super::*; + +#[test] +fn a_shell_that_knows_nothing_leaves_no_meta() { + assert!(said_of(None).is_empty()); + assert!(said_of(Some(Facts::default())).is_empty()); +} + +#[test] +fn seconds_become_whole_milliseconds() { + let said = said_of(Some(Facts { + duration: Some(138.4), + ..Default::default() + })); + assert_eq!(said, vec![(DURATION, "138400".to_owned())]); +} + +#[test] +fn a_duration_that_makes_no_sense_is_not_written_down() { + for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] { + let said = said_of(Some(Facts { + duration: Some(bad), + ..Default::default() + })); + assert!(said.is_empty(), "{bad} should not reach the store"); + } +} + +#[test] +fn a_zero_side_is_not_a_measurement() { + let said = said_of(Some(Facts { + duration: None, + width: Some(0), + height: Some(1080), + })); + assert_eq!(said, vec![(HEIGHT, "1080".to_owned())]); +} + +#[test] +fn the_three_keys_travel_together_when_the_shell_knows_them() { + let said = said_of(Some(Facts { + duration: Some(2.5), + width: Some(1920), + height: Some(1080), + })); + assert_eq!( + said, + vec![ + (DURATION, "2500".to_owned()), + (WIDTH, "1920".to_owned()), + (HEIGHT, "1080".to_owned()) + ] + ); + assert_eq!(said.len(), KEYS.len()); +} + +#[test] +fn every_key_written_is_a_key_the_views_ask_for() { + let said = said_of(Some(Facts { + duration: Some(1.0), + width: Some(2), + height: Some(3), + })); + for (key, _) in &said { + assert!(KEYS.contains(key), "«{key}» is not in KEYS"); + } +} From f87601ad3045f0c10660dfe8fa6e42bdfeab189f Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 30 Sep 2026 22:30:07 -0300 Subject: [PATCH 07/22] feat(panel): the media row shows the clock and the sides the shell measured --- crates/cp-panel/src/media.rs | 25 ++++++++++ crates/cp-panel/src/media_test.rs | 66 +++++++++++++++++++++++++++ crates/cp-panel/src/model.rs | 20 +++++++- crates/cp-panel/src/view.rs | 7 ++- crates/cp-panel/src/view_test.rs | 8 ++-- crates/cp-panel/ui/bodies_media.slint | 43 +++++++++++++++++ crates/cp-panel/ui/cards.slint | 8 +++- crates/cp-panel/ui/types.slint | 2 + crates/cp-store/src/lib.rs | 1 + 9 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 crates/cp-panel/ui/bodies_media.slint diff --git a/crates/cp-panel/src/media.rs b/crates/cp-panel/src/media.rs index 7b6bb87f..a734b9c1 100644 --- a/crates/cp-panel/src/media.rs +++ b/crates/cp-panel/src/media.rs @@ -34,6 +34,31 @@ pub fn said_of(facts: Option) -> Vec<(&'static str, String)> { said } +pub fn clock_of(millis: i64) -> String { + let whole = millis.max(0) / 1_000; + let seconds = whole % 60; + let minutes = (whole / 60) % 60; + let hours = whole / 3_600; + if hours > 0 { + format!("{hours}:{minutes:02}:{seconds:02}") + } else { + format!("{minutes}:{seconds:02}") + } +} + +pub fn said_in(meta: Option<&std::collections::HashMap>) -> (String, String) { + let Some(meta) = meta else { + return (String::new(), String::new()); + }; + let read = |key: &str| meta.get(key).and_then(|one| one.parse::().ok()); + let clock = read(DURATION).map(clock_of).unwrap_or_default(); + let measures = match (read(WIDTH), read(HEIGHT)) { + (Some(wide), Some(high)) if wide > 0 && high > 0 => format!("{wide}×{high}"), + _ => String::new(), + }; + (clock, measures) +} + #[cfg(test)] #[path = "media_test.rs"] mod tests; diff --git a/crates/cp-panel/src/media_test.rs b/crates/cp-panel/src/media_test.rs index c0dc736b..8d8db61a 100644 --- a/crates/cp-panel/src/media_test.rs +++ b/crates/cp-panel/src/media_test.rs @@ -65,3 +65,69 @@ fn every_key_written_is_a_key_the_views_ask_for() { assert!(KEYS.contains(key), "«{key}» is not in KEYS"); } } + +#[test] +fn a_clock_reads_the_way_a_player_shows_it() { + assert_eq!(clock_of(0), "0:00"); + assert_eq!(clock_of(3_000), "0:03"); + assert_eq!(clock_of(138_000), "2:18"); + assert_eq!(clock_of(2_320_000), "38:40"); + assert_eq!(clock_of(3_600_000), "1:00:00"); + assert_eq!(clock_of(7_384_000), "2:03:04"); +} + +#[test] +fn a_clock_never_goes_backwards() { + assert_eq!(clock_of(-5_000), "0:00"); +} + +fn meta(pairs: &[(&str, &str)]) -> std::collections::HashMap { + pairs + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect() +} + +#[test] +fn a_row_with_no_meta_yet_says_nothing_instead_of_zero() { + assert_eq!(said_in(None), (String::new(), String::new())); + assert_eq!( + said_in(Some(&meta(&[]))), + (String::new(), String::new()), + "measured but empty is still nothing to show" + ); +} + +#[test] +fn what_the_shell_measured_becomes_a_clock_and_a_pair_of_sides() { + let said = said_in(Some(&meta(&[ + (DURATION, "138000"), + (WIDTH, "1920"), + (HEIGHT, "1080"), + ]))); + assert_eq!(said, ("2:18".to_owned(), "1920×1080".to_owned())); +} + +#[test] +fn an_audio_has_a_clock_and_no_sides() { + let said = said_in(Some(&meta(&[(DURATION, "2320000")]))); + assert_eq!(said, ("38:40".to_owned(), String::new())); +} + +#[test] +fn half_a_measurement_is_not_shown_as_a_size() { + let said = said_in(Some(&meta(&[(WIDTH, "1920")]))); + assert_eq!(said.1, "", "one side alone says nothing"); + let zeroed = said_in(Some(&meta(&[(WIDTH, "0"), (HEIGHT, "1080")]))); + assert_eq!(zeroed.1, ""); +} + +#[test] +fn a_meta_value_that_is_not_a_number_is_ignored_rather_than_shown_raw() { + let said = said_in(Some(&meta(&[ + (DURATION, "un rato"), + (WIDTH, "ancho"), + (HEIGHT, "1080"), + ]))); + assert_eq!(said, (String::new(), String::new())); +} diff --git a/crates/cp-panel/src/model.rs b/crates/cp-panel/src/model.rs index cb960b17..a6356344 100644 --- a/crates/cp-panel/src/model.rs +++ b/crates/cp-panel/src/model.rs @@ -20,6 +20,13 @@ pub struct Metrics { const EDGE: f32 = 8.0; +pub fn wants_media(filter: &Filter) -> bool { + matches!( + crate::layout::layout_for(&filter.kinds), + crate::layout::Layout::Video | crate::layout::Layout::Audio + ) +} + pub fn shut_height_for(filter: &Filter, metrics: &Metrics) -> f32 { match crate::layout::layout_for(&filter.kinds) { crate::layout::Layout::Json => metrics.json, @@ -47,6 +54,7 @@ pub struct Rows { now: i64, metrics: Metrics, rows: RefCell>, + meta: RefCell, cards: RefCell>>, tops: RefCell>, open: Cell>, @@ -66,6 +74,7 @@ impl Rows { now, metrics, rows: RefCell::new(Vec::new()), + meta: RefCell::new(cp_store::MetaByItem::new()), cards: RefCell::new(Vec::new()), tops: RefCell::new(vec![0.0]), open: Cell::new(None), @@ -202,6 +211,13 @@ impl Rows { tops.push(at); } } + if wants_media(&self.filter) { + let ids: Vec = page.rows.iter().map(|one| one.id).collect(); + match self.store.meta_for(&ids, &crate::media::KEYS) { + Ok(found) => self.meta.borrow_mut().extend(found), + Err(why) => crate::note::note(&format!("los metadatos no se pudieron leer: {why}")), + } + } self.rows.borrow_mut().extend(page.rows); self.notify.row_added(start, added); added @@ -237,7 +253,9 @@ impl Rows { } else { self.metrics.plain }; - let mut card = card_of(row, self.now); + let meta = self.meta.borrow(); + let mut card = card_of(row, self.now, meta.get(&row.id)); + drop(meta); if let Some(path) = &row.thumb_path && let Ok(image) = slint::Image::load_from_path(std::path::Path::new(path)) { diff --git a/crates/cp-panel/src/view.rs b/crates/cp-panel/src/view.rs index 8f9164fe..c926354d 100644 --- a/crates/cp-panel/src/view.rs +++ b/crates/cp-panel/src/view.rs @@ -6,6 +6,8 @@ use cp_core::search::Excerpt; use cp_core::token::Claims; use cp_store::{Facet, Listed}; +pub type MetaOfOne = std::collections::HashMap; + const LEAD: usize = 30; const PER_LINE: usize = 57; const SHUT: i32 = 2; @@ -74,9 +76,10 @@ fn claims_in(row: &Listed) -> Option { .flatten() } -pub fn card_of(row: &Listed, now: i64) -> Card { +pub fn card_of(row: &Listed, now: i64, meta: Option<&MetaOfOne>) -> Card { let kind = row.kind.map(Kind::as_str).unwrap_or("text"); let claims = claims_in(row); + let (clock, measures) = crate::media::said_in(meta); let shape = (row.kind == Some(Kind::Json)) .then(|| crate::shape::said_of(&row.preview, crate::say::in_english())) .flatten() @@ -106,6 +109,8 @@ pub fn card_of(row: &Listed, now: i64) -> Card { shape_root: shape.root.into(), shape_said: shape.counted.into(), shape_keys: shape.keys.into(), + media_clock: clock.into(), + media_measures: measures.into(), body: body.into(), found: !hit.is_empty(), badge: badge_of(claims.as_ref()).into(), diff --git a/crates/cp-panel/src/view_test.rs b/crates/cp-panel/src/view_test.rs index 2d7e2235..9c9b345e 100644 --- a/crates/cp-panel/src/view_test.rs +++ b/crates/cp-panel/src/view_test.rs @@ -23,7 +23,7 @@ fn row(kind: Option) -> Listed { #[test] fn a_row_becomes_the_card_the_panel_draws() { - let card = card_of(&row(Some(Kind::Json)), 1_000_000 + 9 * 60_000); + let card = card_of(&row(Some(Kind::Json)), 1_000_000 + 9 * 60_000, None); assert_eq!(card.id, 7); assert_eq!(card.kind.as_str(), "json"); assert_eq!(card.title.as_str(), "JSON"); @@ -58,7 +58,7 @@ fn a_search_hit_shows_its_excerpt_instead_of_the_preview() { ], }, }); - let card = card_of(&hit, 2_000_000); + let card = card_of(&hit, 2_000_000, None); assert_eq!(card.body.as_str(), "…la reunión del jueves…"); assert!(!card.mono); assert_eq!(card.times.as_str(), "3"); @@ -69,7 +69,7 @@ fn what_was_pasted_once_or_never_carries_no_count() { let mut once = row(None); once.paste_count = 1; once.app = None; - let card = card_of(&once, 1_000_000); + let card = card_of(&once, 1_000_000, None); assert_eq!(card.times.as_str(), ""); assert_eq!(card.source.as_str(), "—"); assert_eq!(card.title.as_str(), "Texto"); @@ -176,7 +176,7 @@ fn a_row_that_matched_carries_its_three_parts() { found_in: FoundIn::Text, excerpt: excerpt_of(&[("antes de ", false), ("esto", true), (" y después", false)]), }); - let card = card_of(&row, 1_000_000); + let card = card_of(&row, 1_000_000, None); assert!(card.found); assert_eq!(card.lead.as_str(), "antes de "); assert_eq!(card.hit.as_str(), "esto"); diff --git a/crates/cp-panel/ui/bodies_media.slint b/crates/cp-panel/ui/bodies_media.slint new file mode 100644 index 00000000..eeff87a3 --- /dev/null +++ b/crates/cp-panel/ui/bodies_media.slint @@ -0,0 +1,43 @@ +import { Card } from "types.slint"; +import { Theme } from "theme.slint"; + +export component MediaBody inherits HorizontalLayout { + in property card; + spacing: 9px; + + if card.media-clock != "" : Rectangle { + width: clock-row.preferred-width; + height: 18px; + border-radius: 4px; + background: Theme.field; + y: 0; + clock-row := HorizontalLayout { + padding-left: 6px; + padding-right: 6px; + Text { + text: card.media-clock; + color: Theme.accent; + font-size: 11px; + vertical-alignment: center; + } + } + } + + VerticalLayout { + spacing: 4px; + horizontal-stretch: 1; + Text { + text: card.squeezed; + color: Theme.body; + font-size: 12.5px; + overflow: elide; + wrap: no-wrap; + } + if card.media-measures != "" : Text { + text: card.media-measures; + color: Theme.muted; + font-size: 10.5px; + overflow: elide; + } + } +} diff --git a/crates/cp-panel/ui/cards.slint b/crates/cp-panel/ui/cards.slint index 92e51c33..aef6ce72 100644 --- a/crates/cp-panel/ui/cards.slint +++ b/crates/cp-panel/ui/cards.slint @@ -1,4 +1,5 @@ import { Card, Chip } from "types.slint"; +import { MediaBody } from "bodies_media.slint"; import { JsonBody } from "bodies.slint"; import { Theme } from "theme.slint"; import { Words } from "words.slint"; @@ -160,6 +161,7 @@ export component Tag inherits Rectangle { export component CardView inherits Rectangle { in property card; in property layout; + property special: layout == "json" || layout == "video" || layout == "audio"; in property current; in property open; in property shown-here; @@ -213,7 +215,11 @@ export component CardView inherits Rectangle { card: card; } - if !card.has-thumb && (!card.found || open) && !(layout == "json" && !open) : Text { + if !card.has-thumb && !open && (layout == "video" || layout == "audio") : MediaBody { + card: card; + } + + if !card.has-thumb && (!card.found || open) && !(special && !open) : Text { text: open ? card.body : card.squeezed; color: card.mono ? Theme.mono : Theme.body; font-size: card.mono ? 12px : 13px; diff --git a/crates/cp-panel/ui/types.slint b/crates/cp-panel/ui/types.slint index c63d5ebb..ad5eb114 100644 --- a/crates/cp-panel/ui/types.slint +++ b/crates/cp-panel/ui/types.slint @@ -10,6 +10,8 @@ export struct Card { shape-root: string, shape-said: string, shape-keys: string, + media-clock: string, + media-measures: string, lead: string, hit: string, tail: string, diff --git a/crates/cp-store/src/lib.rs b/crates/cp-store/src/lib.rs index f283e0ff..f9194045 100644 --- a/crates/cp-store/src/lib.rs +++ b/crates/cp-store/src/lib.rs @@ -9,6 +9,7 @@ pub use backup::{Brought, Made, Taken}; pub use blobs::Blobs; pub use query::{Clock, parse}; pub use schema::SCHEMA_VERSION; +pub use store::MetaByItem; pub use store::restrict; pub use store::{ A_DAY, AppCount, Broken, Cursor, Facet, Filter, FoundIn, Listed, More, Order, PREVIEW_CHARS, From 0cdb5a272125ba6c5d9fb9262985efbef44d4519 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 30 Sep 2026 22:33:28 -0300 Subject: [PATCH 08/22] feat(panel): an office file says its format, and what it cannot measure it does not pretend --- crates/cp-panel/src/main.rs | 1 + crates/cp-panel/src/papers.rs | 42 ++++++++++++++ crates/cp-panel/src/papers_test.rs | 79 ++++++++++++++++++++++++++ crates/cp-panel/src/view.rs | 9 +++ crates/cp-panel/ui/bodies_papers.slint | 48 ++++++++++++++++ crates/cp-panel/ui/cards.slint | 8 ++- crates/cp-panel/ui/types.slint | 2 + 7 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 crates/cp-panel/src/papers.rs create mode 100644 crates/cp-panel/src/papers_test.rs create mode 100644 crates/cp-panel/ui/bodies_papers.slint diff --git a/crates/cp-panel/src/main.rs b/crates/cp-panel/src/main.rs index 1f22a78d..e61bb101 100644 --- a/crates/cp-panel/src/main.rs +++ b/crates/cp-panel/src/main.rs @@ -7,6 +7,7 @@ mod measure; mod media; mod model; mod note; +mod papers; mod say; mod shape; mod view; diff --git a/crates/cp-panel/src/papers.rs b/crates/cp-panel/src/papers.rs new file mode 100644 index 00000000..70c4ed38 --- /dev/null +++ b/crates/cp-panel/src/papers.rs @@ -0,0 +1,42 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Papers { + pub format: String, + pub family: &'static str, +} + +const SHEETS: &[&str] = &["xlsx", "xlsm", "xls", "ods", "csv", "tsv"]; +const WORDS: &[&str] = &["docx", "docm", "doc", "odt", "rtf"]; +const SLIDES: &[&str] = &["pptx", "pptm", "ppt", "odp"]; +const PAGES: &[&str] = &["pdf"]; + +pub fn papers_of(preview: &str) -> Option { + let first = preview.lines().next()?.trim_end(); + let name = first.rsplit(['\\', '/']).next()?; + let (_, after) = name.rsplit_once('.')?; + if after.is_empty() || after.len() > 8 || after.contains(' ') { + return None; + } + let folded = after.to_lowercase(); + Some(Papers { + format: folded.to_uppercase(), + family: family_of(&folded), + }) +} + +fn family_of(folded: &str) -> &'static str { + if SHEETS.contains(&folded) { + "sheets" + } else if WORDS.contains(&folded) { + "words" + } else if SLIDES.contains(&folded) { + "slides" + } else if PAGES.contains(&folded) { + "pages" + } else { + "plain" + } +} + +#[cfg(test)] +#[path = "papers_test.rs"] +mod tests; diff --git a/crates/cp-panel/src/papers_test.rs b/crates/cp-panel/src/papers_test.rs new file mode 100644 index 00000000..2316380c --- /dev/null +++ b/crates/cp-panel/src/papers_test.rs @@ -0,0 +1,79 @@ +use super::*; + +#[test] +fn a_windows_path_gives_the_format_of_its_last_segment() { + let found = papers_of(r"D:\Mario\Contratos\Presupuesto Q4 2026.xlsx").expect("has one"); + assert_eq!(found.format, "XLSX"); + assert_eq!(found.family, "sheets"); +} + +#[test] +fn a_unix_path_and_a_bare_name_read_the_same() { + assert_eq!( + papers_of("/home/mario/acta.odt").expect("one").format, + "ODT" + ); + assert_eq!(papers_of("acta.odt").expect("one").format, "ODT"); +} + +#[test] +fn the_four_families_the_view_colours_are_told_apart() { + assert_eq!(papers_of("a.xlsx").expect("one").family, "sheets"); + assert_eq!(papers_of("a.csv").expect("one").family, "sheets"); + assert_eq!(papers_of("a.docx").expect("one").family, "words"); + assert_eq!(papers_of("a.pptx").expect("one").family, "slides"); + assert_eq!(papers_of("a.pdf").expect("one").family, "pages"); +} + +#[test] +fn a_format_nobody_coloured_still_shows_its_name() { + let found = papers_of("respaldo.7z").expect("one"); + assert_eq!(found.format, "7Z"); + assert_eq!(found.family, "plain", "no colour, but it says what it is"); +} + +#[test] +fn the_case_of_the_extension_does_not_change_the_family() { + assert_eq!(papers_of("A.XLSX").expect("one").family, "sheets"); + assert_eq!(papers_of("A.XlSx").expect("one").format, "XLSX"); +} + +#[test] +fn a_name_with_dots_in_it_takes_only_the_last_piece() { + let found = papers_of("informe.final.v3.docx").expect("one"); + assert_eq!(found.format, "DOCX"); +} + +#[test] +fn what_has_no_extension_has_no_format() { + assert!(papers_of("").is_none()); + assert!(papers_of("sin_extension").is_none()); + assert!(papers_of(r"D:\carpeta\sin_extension").is_none()); + assert!(papers_of("acaba.en.punto.").is_none()); +} + +#[test] +fn something_that_only_looks_like_an_extension_is_refused() { + assert!( + papers_of("una frase con. un punto suelto").is_none(), + "a space after the dot is not an extension" + ); + assert!( + papers_of("archivo.extensionlarguisima").is_none(), + "eight characters is already generous" + ); +} + +#[test] +fn only_the_first_line_is_read_because_that_is_the_first_file() { + let found = papers_of("primero.pdf\nsegundo.xlsx").expect("one"); + assert_eq!(found.format, "PDF"); +} + +#[test] +fn a_trailing_carriage_return_does_not_become_part_of_the_format() { + assert_eq!( + papers_of("acta.pdf\r\notro.docx").expect("one").format, + "PDF" + ); +} diff --git a/crates/cp-panel/src/view.rs b/crates/cp-panel/src/view.rs index c926354d..3a79f2b6 100644 --- a/crates/cp-panel/src/view.rs +++ b/crates/cp-panel/src/view.rs @@ -80,6 +80,9 @@ pub fn card_of(row: &Listed, now: i64, meta: Option<&MetaOfOne>) -> Card { let kind = row.kind.map(Kind::as_str).unwrap_or("text"); let claims = claims_in(row); let (clock, measures) = crate::media::said_in(meta); + let papers = (row.kind == Some(Kind::File)) + .then(|| crate::papers::papers_of(&row.preview)) + .flatten(); let shape = (row.kind == Some(Kind::Json)) .then(|| crate::shape::said_of(&row.preview, crate::say::in_english())) .flatten() @@ -111,6 +114,12 @@ pub fn card_of(row: &Listed, now: i64, meta: Option<&MetaOfOne>) -> Card { shape_keys: shape.keys.into(), media_clock: clock.into(), media_measures: measures.into(), + papers_format: papers + .as_ref() + .map(|one| one.format.clone()) + .unwrap_or_default() + .into(), + papers_family: papers.as_ref().map_or("plain", |one| one.family).into(), body: body.into(), found: !hit.is_empty(), badge: badge_of(claims.as_ref()).into(), diff --git a/crates/cp-panel/ui/bodies_papers.slint b/crates/cp-panel/ui/bodies_papers.slint new file mode 100644 index 00000000..81582e43 --- /dev/null +++ b/crates/cp-panel/ui/bodies_papers.slint @@ -0,0 +1,48 @@ +import { Card } from "types.slint"; +import { Theme } from "theme.slint"; + +export global Paper { + out property sheets: #6FCF97; + out property words: #7FA9F0; + out property slides: #FCD34D; + out property pages: #F28B82; + out property plain: #83858E; + pure public function of(family: string) -> color { + return family == "sheets" ? self.sheets + : family == "words" ? self.words + : family == "slides" ? self.slides + : family == "pages" ? self.pages + : self.plain; + } +} + +export component PapersBody inherits HorizontalLayout { + in property card; + spacing: 9px; + + Rectangle { + width: 34px; + height: 34px; + border-radius: 7px; + background: Paper.of(card.papers-family).transparentize(0.86); + y: 0; + Text { + text: card.papers-format; + color: Paper.of(card.papers-family); + font-size: 9.5px; + font-weight: 700; + horizontal-alignment: center; + vertical-alignment: center; + } + } + + Text { + text: card.squeezed; + color: Theme.body; + font-size: 12.5px; + horizontal-stretch: 1; + overflow: elide; + wrap: no-wrap; + vertical-alignment: center; + } +} diff --git a/crates/cp-panel/ui/cards.slint b/crates/cp-panel/ui/cards.slint index aef6ce72..de8fb046 100644 --- a/crates/cp-panel/ui/cards.slint +++ b/crates/cp-panel/ui/cards.slint @@ -1,4 +1,5 @@ import { Card, Chip } from "types.slint"; +import { PapersBody } from "bodies_papers.slint"; import { MediaBody } from "bodies_media.slint"; import { JsonBody } from "bodies.slint"; import { Theme } from "theme.slint"; @@ -161,7 +162,8 @@ export component Tag inherits Rectangle { export component CardView inherits Rectangle { in property card; in property layout; - property special: layout == "json" || layout == "video" || layout == "audio"; + property special: layout == "json" || layout == "video" || layout == "audio" + || layout == "papers"; in property current; in property open; in property shown-here; @@ -219,6 +221,10 @@ export component CardView inherits Rectangle { card: card; } + if !card.has-thumb && !open && layout == "papers" : PapersBody { + card: card; + } + if !card.has-thumb && (!card.found || open) && !(special && !open) : Text { text: open ? card.body : card.squeezed; color: card.mono ? Theme.mono : Theme.body; diff --git a/crates/cp-panel/ui/types.slint b/crates/cp-panel/ui/types.slint index ad5eb114..aa333e46 100644 --- a/crates/cp-panel/ui/types.slint +++ b/crates/cp-panel/ui/types.slint @@ -12,6 +12,8 @@ export struct Card { shape-keys: string, media-clock: string, media-measures: string, + papers-format: string, + papers-family: string, lead: string, hit: string, tail: string, From 59cfd6ff6c5b6c85a85ec3358376c334eed3aa49 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 30 Sep 2026 22:39:47 -0300 Subject: [PATCH 09/22] feat(panel): a folder says how many things are in it, without hanging on a dead share --- crates/cp-panel/src/engine.rs | 46 ++++++++++- crates/cp-panel/src/folder.rs | 75 ++++++++++++++++++ crates/cp-panel/src/folder_test.rs | 101 +++++++++++++++++++++++++ crates/cp-panel/src/main.rs | 1 + crates/cp-panel/src/model.rs | 16 ++-- crates/cp-panel/src/model_test.rs | 26 +++++++ crates/cp-panel/src/view.rs | 14 ++++ crates/cp-panel/ui/bodies_folder.slint | 41 ++++++++++ crates/cp-panel/ui/cards.slint | 7 +- crates/cp-panel/ui/types.slint | 3 + 10 files changed, 321 insertions(+), 9 deletions(-) create mode 100644 crates/cp-panel/src/folder.rs create mode 100644 crates/cp-panel/src/folder_test.rs create mode 100644 crates/cp-panel/ui/bodies_folder.slint diff --git a/crates/cp-panel/src/engine.rs b/crates/cp-panel/src/engine.rs index 893070f1..cc56dfce 100644 --- a/crates/cp-panel/src/engine.rs +++ b/crates/cp-panel/src/engine.rs @@ -126,6 +126,10 @@ fn errand(store: &Store, thumbs: &Path) -> bool { measured(store, id, at); return true; } + if let Some(id) = first_waiting(store, "folder", at) { + walked(store, id, at); + return true; + } false } @@ -178,6 +182,44 @@ fn measured(store: &Store, id: i64, at: i64) { done(store, id, "media"); } +fn walked(store: &Store, id: i64, at: i64) { + let Some(path) = store + .item(id) + .ok() + .flatten() + .as_ref() + .and_then(crate::media::first_path_of) + else { + done(store, id, "folder"); + return; + }; + let counting = + cp_core::reading::begin(move || crate::folder::counted_in(std::path::Path::new(&path))); + match counting.waited(crate::folder::PATIENCE) { + cp_core::reading::Waited::Answered(Some(seen)) => { + if let Err(why) = store.set_meta(id, crate::folder::ENTRIES, &seen.to_string()) { + note(&format!("{id} was counted and nobody wrote it down: {why}")); + } + done(store, id, "folder"); + } + cp_core::reading::Waited::Answered(None) => { + give_up(store, id, "folder", "the folder could not be read", at); + } + cp_core::reading::Waited::StillRunning => { + give_up(store, id, "folder", "the folder did not answer in time", at); + } + cp_core::reading::Waited::Gone => { + give_up( + store, + id, + "folder", + "counting the folder did not survive", + at, + ); + } + } +} + fn read_out(store: &Store, id: i64, at: i64) { if !here::ocr_available() { done(store, id, "ocr"); @@ -324,7 +366,9 @@ fn jobs_for(item: &Item) -> &'static [&'static str] { Some(Kind::Image) => &["thumb", "ocr"], Some(Kind::Video) => &["thumb", "media"], Some(Kind::Audio) => &["media"], - Some(Kind::File) | Some(Kind::Folder) if here::THUMBNAILS_FILES => &["thumb"], + Some(Kind::Folder) if here::THUMBNAILS_FILES => &["thumb", "folder"], + Some(Kind::Folder) => &["folder"], + Some(Kind::File) if here::THUMBNAILS_FILES => &["thumb"], _ => &[], } } diff --git a/crates/cp-panel/src/folder.rs b/crates/cp-panel/src/folder.rs new file mode 100644 index 00000000..f3dc0536 --- /dev/null +++ b/crates/cp-panel/src/folder.rs @@ -0,0 +1,75 @@ +use std::time::Duration; + +pub const ENTRIES: &str = "entries"; +pub const KEYS: [&str; 1] = [ENTRIES]; + +pub const PATIENCE: Duration = Duration::from_secs(3); +pub const UP_TO: usize = 5_000; + +const _: () = assert!(PATIENCE.as_secs() >= 1); +const _: () = assert!(UP_TO >= 100); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Parts { + pub name: String, + pub parent: String, +} + +pub fn parts_of(preview: &str) -> Option { + let first = preview.lines().next()?.trim_end(); + let trimmed = first.trim_end_matches(['\\', '/']); + if trimmed.is_empty() { + return None; + } + let cut = trimmed.rfind(['\\', '/']); + let (parent, name) = match cut { + Some(at) => (&trimmed[..=at], &trimmed[at + 1..]), + None => ("", trimmed), + }; + (!name.is_empty()).then(|| Parts { + name: name.to_owned(), + parent: parent.to_owned(), + }) +} + +pub fn counted_in(path: &std::path::Path) -> Option { + let reading = std::fs::read_dir(path).ok()?; + let mut seen = 0; + for one in reading { + if one.is_err() { + continue; + } + seen += 1; + if seen >= UP_TO { + break; + } + } + Some(seen) +} + +pub fn said_in(meta: Option<&std::collections::HashMap>, english: bool) -> String { + let say = |es: &'static str, en: &'static str| crate::say::pick_in(english, es, en); + let Some(seen) = meta + .and_then(|one| one.get(ENTRIES)) + .and_then(|one| one.parse::().ok()) + else { + return String::new(); + }; + let what = if seen == 1 { + say("elemento", "entry") + } else { + say("elementos", "entries") + }; + if seen >= UP_TO { + return if english { + format!("more than {seen} {what}") + } else { + format!("más de {seen} {what}") + }; + } + format!("{seen} {what}") +} + +#[cfg(test)] +#[path = "folder_test.rs"] +mod tests; diff --git a/crates/cp-panel/src/folder_test.rs b/crates/cp-panel/src/folder_test.rs new file mode 100644 index 00000000..03d25cc3 --- /dev/null +++ b/crates/cp-panel/src/folder_test.rs @@ -0,0 +1,101 @@ +use super::*; + +#[test] +fn a_windows_path_splits_into_name_and_the_folder_above() { + let parts = parts_of(r"D:\Mario\Orca\CopyPaste\rc1").expect("one"); + assert_eq!(parts.name, "rc1"); + assert_eq!(parts.parent, r"D:\Mario\Orca\CopyPaste\"); +} + +#[test] +fn a_trailing_separator_does_not_eat_the_name() { + let parts = parts_of(r"D:\Mario\Pictures\Screenshots\").expect("one"); + assert_eq!(parts.name, "Screenshots"); + assert_eq!(parts.parent, r"D:\Mario\Pictures\"); +} + +#[test] +fn a_network_share_keeps_its_two_leading_slashes() { + let parts = parts_of(r"\\servidor\legal\Contratos 2026").expect("one"); + assert_eq!(parts.name, "Contratos 2026"); + assert_eq!(parts.parent, r"\\servidor\legal\"); +} + +#[test] +fn a_unix_path_reads_the_same_way() { + let parts = parts_of("/home/mario/Documentos").expect("one"); + assert_eq!(parts.name, "Documentos"); + assert_eq!(parts.parent, "/home/mario/"); +} + +#[test] +fn a_bare_name_has_no_folder_above_it() { + let parts = parts_of("Descargas").expect("one"); + assert_eq!(parts.name, "Descargas"); + assert_eq!(parts.parent, ""); +} + +#[test] +fn nothing_at_all_has_no_parts() { + assert!(parts_of("").is_none()); + assert!(parts_of(" ").is_none(), "only trailing space is trimmed"); + assert!(parts_of("\\").is_none()); + assert!(parts_of("///").is_none()); +} + +#[test] +fn only_the_first_line_is_read_because_that_is_the_first_folder() { + assert_eq!(parts_of("/uno/dos\n/tres/cuatro").expect("one").name, "dos"); +} + +#[test] +fn a_folder_nobody_counted_yet_says_nothing() { + assert_eq!(said_in(None, false), ""); + assert_eq!(said_in(Some(&std::collections::HashMap::new()), false), ""); +} + +fn counted(seen: usize) -> std::collections::HashMap { + std::collections::HashMap::from([(ENTRIES.to_owned(), seen.to_string())]) +} + +#[test] +fn what_was_counted_is_said_in_both_tongues() { + assert_eq!(said_in(Some(&counted(412)), false), "412 elementos"); + assert_eq!(said_in(Some(&counted(412)), true), "412 entries"); + assert_eq!(said_in(Some(&counted(1)), false), "1 elemento"); + assert_eq!(said_in(Some(&counted(1)), true), "1 entry"); + assert_eq!(said_in(Some(&counted(0)), false), "0 elementos"); +} + +#[test] +fn a_count_that_hit_the_ceiling_admits_it_is_a_floor() { + let said = said_in(Some(&counted(UP_TO)), false); + assert_eq!(said, format!("más de {UP_TO} elementos")); + assert_eq!( + said_in(Some(&counted(UP_TO)), true), + format!("more than {UP_TO} entries") + ); +} + +#[test] +fn a_value_that_is_not_a_number_is_ignored_rather_than_shown_raw() { + let bad = std::collections::HashMap::from([(ENTRIES.to_owned(), "muchos".to_owned())]); + assert_eq!(said_in(Some(&bad), false), ""); +} + +#[test] +fn counting_a_real_folder_sees_what_is_in_it() { + let dir = std::env::temp_dir().join(format!("cp-folder-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("made"); + for at in 0..3 { + std::fs::write(dir.join(format!("{at}.txt")), b"x").expect("wrote"); + } + assert_eq!(counted_in(&dir), Some(3)); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn counting_what_is_not_there_answers_nothing_instead_of_zero() { + let nowhere = std::env::temp_dir().join("cp-folder-que-no-existe-jamas"); + assert_eq!(counted_in(&nowhere), None); +} diff --git a/crates/cp-panel/src/main.rs b/crates/cp-panel/src/main.rs index e61bb101..1c96b190 100644 --- a/crates/cp-panel/src/main.rs +++ b/crates/cp-panel/src/main.rs @@ -1,6 +1,7 @@ mod age; mod app; mod engine; +mod folder; mod here; mod layout; mod measure; diff --git a/crates/cp-panel/src/model.rs b/crates/cp-panel/src/model.rs index a6356344..2bb0881f 100644 --- a/crates/cp-panel/src/model.rs +++ b/crates/cp-panel/src/model.rs @@ -20,11 +20,12 @@ pub struct Metrics { const EDGE: f32 = 8.0; -pub fn wants_media(filter: &Filter) -> bool { - matches!( - crate::layout::layout_for(&filter.kinds), - crate::layout::Layout::Video | crate::layout::Layout::Audio - ) +pub fn meta_keys_for(filter: &Filter) -> &'static [&'static str] { + match crate::layout::layout_for(&filter.kinds) { + crate::layout::Layout::Video | crate::layout::Layout::Audio => &crate::media::KEYS, + crate::layout::Layout::Folder => &crate::folder::KEYS, + _ => &[], + } } pub fn shut_height_for(filter: &Filter, metrics: &Metrics) -> f32 { @@ -211,9 +212,10 @@ impl Rows { tops.push(at); } } - if wants_media(&self.filter) { + let keys = meta_keys_for(&self.filter); + if !keys.is_empty() { let ids: Vec = page.rows.iter().map(|one| one.id).collect(); - match self.store.meta_for(&ids, &crate::media::KEYS) { + match self.store.meta_for(&ids, keys) { Ok(found) => self.meta.borrow_mut().extend(found), Err(why) => crate::note::note(&format!("los metadatos no se pudieron leer: {why}")), } diff --git a/crates/cp-panel/src/model_test.rs b/crates/cp-panel/src/model_test.rs index 6f173ec7..c103c3fa 100644 --- a/crates/cp-panel/src/model_test.rs +++ b/crates/cp-panel/src/model_test.rs @@ -239,3 +239,29 @@ fn a_json_only_list_gives_its_rows_the_taller_shut_height() { "two kinds is the general layout, and the general height" ); } + +#[test] +fn only_the_views_that_use_meta_ask_the_store_for_any() { + let of = |kinds: Vec| { + meta_keys_for(&Filter { + kinds, + ..Default::default() + }) + }; + assert!(of(vec![]).is_empty(), "the general view asks for nothing"); + assert!( + of(vec![cp_core::kind::Kind::Json]).is_empty(), + "json parses the preview" + ); + assert!( + of(vec![cp_core::kind::Kind::File]).is_empty(), + "the format is in the path" + ); + assert_eq!(of(vec![cp_core::kind::Kind::Video]), &crate::media::KEYS); + assert_eq!(of(vec![cp_core::kind::Kind::Audio]), &crate::media::KEYS); + assert_eq!(of(vec![cp_core::kind::Kind::Folder]), &crate::folder::KEYS); + assert!( + of(vec![cp_core::kind::Kind::Video, cp_core::kind::Kind::Audio]).is_empty(), + "two kinds is the general view, which asks for nothing" + ); +} diff --git a/crates/cp-panel/src/view.rs b/crates/cp-panel/src/view.rs index 3a79f2b6..6e8dfc87 100644 --- a/crates/cp-panel/src/view.rs +++ b/crates/cp-panel/src/view.rs @@ -80,6 +80,9 @@ pub fn card_of(row: &Listed, now: i64, meta: Option<&MetaOfOne>) -> Card { let kind = row.kind.map(Kind::as_str).unwrap_or("text"); let claims = claims_in(row); let (clock, measures) = crate::media::said_in(meta); + let folder = (row.kind == Some(Kind::Folder)) + .then(|| crate::folder::parts_of(&row.preview)) + .flatten(); let papers = (row.kind == Some(Kind::File)) .then(|| crate::papers::papers_of(&row.preview)) .flatten(); @@ -120,6 +123,17 @@ pub fn card_of(row: &Listed, now: i64, meta: Option<&MetaOfOne>) -> Card { .unwrap_or_default() .into(), papers_family: papers.as_ref().map_or("plain", |one| one.family).into(), + folder_name: folder + .as_ref() + .map(|one| one.name.clone()) + .unwrap_or_default() + .into(), + folder_parent: folder + .as_ref() + .map(|one| one.parent.clone()) + .unwrap_or_default() + .into(), + folder_said: crate::folder::said_in(meta, crate::say::in_english()).into(), body: body.into(), found: !hit.is_empty(), badge: badge_of(claims.as_ref()).into(), diff --git a/crates/cp-panel/ui/bodies_folder.slint b/crates/cp-panel/ui/bodies_folder.slint new file mode 100644 index 00000000..1c69710a --- /dev/null +++ b/crates/cp-panel/ui/bodies_folder.slint @@ -0,0 +1,41 @@ +import { Card } from "types.slint"; +import { Theme } from "theme.slint"; + +export component FolderBody inherits HorizontalLayout { + in property card; + spacing: 9px; + + Image { + source: @image-url("../assets/icons/folder.svg"); + width: 18px; + height: 18px; + colorize: #FBBF24; + y: 0; + } + + VerticalLayout { + spacing: 3px; + horizontal-stretch: 1; + Text { + text: card.folder-name; + color: Theme.body; + font-size: 12.5px; + overflow: elide; + wrap: no-wrap; + } + Text { + text: card.folder-parent; + color: Theme.placeholder; + font-size: 10.5px; + overflow: elide; + wrap: no-wrap; + } + } + + if card.folder-said != "" : Text { + text: card.folder-said; + color: Theme.muted; + font-size: 10.5px; + vertical-alignment: center; + } +} diff --git a/crates/cp-panel/ui/cards.slint b/crates/cp-panel/ui/cards.slint index de8fb046..20c8b103 100644 --- a/crates/cp-panel/ui/cards.slint +++ b/crates/cp-panel/ui/cards.slint @@ -1,4 +1,5 @@ import { Card, Chip } from "types.slint"; +import { FolderBody } from "bodies_folder.slint"; import { PapersBody } from "bodies_papers.slint"; import { MediaBody } from "bodies_media.slint"; import { JsonBody } from "bodies.slint"; @@ -163,7 +164,7 @@ export component CardView inherits Rectangle { in property card; in property layout; property special: layout == "json" || layout == "video" || layout == "audio" - || layout == "papers"; + || layout == "papers" || layout == "folder"; in property current; in property open; in property shown-here; @@ -225,6 +226,10 @@ export component CardView inherits Rectangle { card: card; } + if !card.has-thumb && !open && layout == "folder" : FolderBody { + card: card; + } + if !card.has-thumb && (!card.found || open) && !(special && !open) : Text { text: open ? card.body : card.squeezed; color: card.mono ? Theme.mono : Theme.body; diff --git a/crates/cp-panel/ui/types.slint b/crates/cp-panel/ui/types.slint index aa333e46..bad76fbf 100644 --- a/crates/cp-panel/ui/types.slint +++ b/crates/cp-panel/ui/types.slint @@ -14,6 +14,9 @@ export struct Card { media-measures: string, papers-format: string, papers-family: string, + folder-name: string, + folder-parent: string, + folder-said: string, lead: string, hit: string, tail: string, From a027098100e69361df43254fed4ddeb37cb5f6a4 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 30 Sep 2026 22:54:16 -0300 Subject: [PATCH 10/22] feat(panel): a link row shows its domain, its title and its path apart --- crates/cp-panel/src/link.rs | 34 ++++++++++++ crates/cp-panel/src/link_test.rs | 78 ++++++++++++++++++++++++++++ crates/cp-panel/src/main.rs | 1 + crates/cp-panel/src/view.rs | 13 +++++ crates/cp-panel/ui/bodies_link.slint | 32 ++++++++++++ crates/cp-panel/ui/cards.slint | 7 ++- crates/cp-panel/ui/types.slint | 2 + 7 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 crates/cp-panel/src/link.rs create mode 100644 crates/cp-panel/src/link_test.rs create mode 100644 crates/cp-panel/ui/bodies_link.slint diff --git a/crates/cp-panel/src/link.rs b/crates/cp-panel/src/link.rs new file mode 100644 index 00000000..7df33e73 --- /dev/null +++ b/crates/cp-panel/src/link.rs @@ -0,0 +1,34 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Parts { + pub domain: String, + pub path: String, +} + +pub fn parts_of(preview: &str) -> Option { + let first = preview.lines().next()?.trim(); + let after = first + .split_once("://") + .map_or(first, |(_, rest)| rest) + .trim_start_matches('/'); + if after.is_empty() { + return None; + } + let (host, rest) = match after.find(['/', '?', '#']) { + Some(at) => (&after[..at], &after[at..]), + None => (after, ""), + }; + let host = host.split('@').next_back().unwrap_or(host); + let bare = host.split(':').next().unwrap_or(host); + let bare = bare.trim_start_matches("www."); + if bare.is_empty() || !bare.contains('.') { + return None; + } + Some(Parts { + domain: bare.to_lowercase(), + path: rest.to_owned(), + }) +} + +#[cfg(test)] +#[path = "link_test.rs"] +mod tests; diff --git a/crates/cp-panel/src/link_test.rs b/crates/cp-panel/src/link_test.rs new file mode 100644 index 00000000..855712a4 --- /dev/null +++ b/crates/cp-panel/src/link_test.rs @@ -0,0 +1,78 @@ +use super::*; + +fn of(text: &str) -> Parts { + parts_of(text).expect("a link") +} + +#[test] +fn a_plain_url_splits_into_domain_and_the_rest() { + let parts = of("https://github.com/rgdevment/CopyPaste/pull/95"); + assert_eq!(parts.domain, "github.com"); + assert_eq!(parts.path, "/rgdevment/CopyPaste/pull/95"); +} + +#[test] +fn the_www_is_not_part_of_the_domain_anyone_recognises() { + assert_eq!(of("https://www.github.com/x").domain, "github.com"); + assert_eq!(of("www.github.com").domain, "github.com"); +} + +#[test] +fn a_domain_is_folded_so_two_spellings_group_together() { + assert_eq!(of("https://GitHub.COM/x").domain, "github.com"); +} + +#[test] +fn a_port_belongs_to_the_address_not_to_the_domain() { + let parts = of("http://ejemplo.test:8080/panel"); + assert_eq!(parts.domain, "ejemplo.test"); + assert_eq!(parts.path, "/panel"); +} + +#[test] +fn credentials_in_the_url_do_not_become_the_domain() { + assert_eq!( + of("https://mario:clave@ejemplo.test/x").domain, + "ejemplo.test" + ); +} + +#[test] +fn a_domain_with_no_path_has_an_empty_one_rather_than_a_slash() { + let parts = of("https://docs.rs"); + assert_eq!(parts.domain, "docs.rs"); + assert_eq!(parts.path, ""); +} + +#[test] +fn a_query_or_a_fragment_counts_as_the_path() { + assert_eq!(of("https://ejemplo.test?a=1").path, "?a=1"); + assert_eq!(of("https://ejemplo.test#arriba").path, "#arriba"); +} + +#[test] +fn any_scheme_is_cut_the_same_way() { + assert_eq!(of("ftp://archivos.test/pub").domain, "archivos.test"); + assert_eq!(of("ejemplo.test/sin/esquema").domain, "ejemplo.test"); +} + +#[test] +fn what_has_no_dot_is_not_a_domain() { + assert!(parts_of("localhost:3000").is_none()); + assert!(parts_of("solo-texto").is_none()); + assert!(parts_of("").is_none()); + assert!(parts_of("https://").is_none()); +} + +#[test] +fn only_the_first_line_is_read_because_that_is_the_first_link() { + assert_eq!( + of("https://uno.test/a\nhttps://dos.test/b").domain, + "uno.test" + ); +} + +#[test] +fn surrounding_space_does_not_reach_the_domain() { + assert_eq!(of(" https://ejemplo.test/x ").domain, "ejemplo.test"); +} diff --git a/crates/cp-panel/src/main.rs b/crates/cp-panel/src/main.rs index 1c96b190..2fe9a81d 100644 --- a/crates/cp-panel/src/main.rs +++ b/crates/cp-panel/src/main.rs @@ -4,6 +4,7 @@ mod engine; mod folder; mod here; mod layout; +mod link; mod measure; mod media; mod model; diff --git a/crates/cp-panel/src/view.rs b/crates/cp-panel/src/view.rs index 6e8dfc87..d27e8362 100644 --- a/crates/cp-panel/src/view.rs +++ b/crates/cp-panel/src/view.rs @@ -80,6 +80,9 @@ pub fn card_of(row: &Listed, now: i64, meta: Option<&MetaOfOne>) -> Card { let kind = row.kind.map(Kind::as_str).unwrap_or("text"); let claims = claims_in(row); let (clock, measures) = crate::media::said_in(meta); + let link = (row.kind == Some(Kind::Link)) + .then(|| crate::link::parts_of(&row.preview)) + .flatten(); let folder = (row.kind == Some(Kind::Folder)) .then(|| crate::folder::parts_of(&row.preview)) .flatten(); @@ -134,6 +137,16 @@ pub fn card_of(row: &Listed, now: i64, meta: Option<&MetaOfOne>) -> Card { .unwrap_or_default() .into(), folder_said: crate::folder::said_in(meta, crate::say::in_english()).into(), + link_domain: link + .as_ref() + .map(|one| one.domain.clone()) + .unwrap_or_default() + .into(), + link_path: link + .as_ref() + .map(|one| one.path.clone()) + .unwrap_or_default() + .into(), body: body.into(), found: !hit.is_empty(), badge: badge_of(claims.as_ref()).into(), diff --git a/crates/cp-panel/ui/bodies_link.slint b/crates/cp-panel/ui/bodies_link.slint new file mode 100644 index 00000000..6629a5a7 --- /dev/null +++ b/crates/cp-panel/ui/bodies_link.slint @@ -0,0 +1,32 @@ +import { Card } from "types.slint"; +import { Theme } from "theme.slint"; + +export component LinkBody inherits VerticalLayout { + in property card; + spacing: 3px; + + Text { + text: card.link-domain; + color: Theme.accent; + font-size: 11px; + font-weight: 600; + overflow: elide; + wrap: no-wrap; + } + + Text { + text: card.squeezed; + color: Theme.body; + font-size: 12.5px; + overflow: elide; + wrap: no-wrap; + } + + if card.link-path != "" : Text { + text: card.link-path; + color: Theme.placeholder; + font-size: 10.5px; + overflow: elide; + wrap: no-wrap; + } +} diff --git a/crates/cp-panel/ui/cards.slint b/crates/cp-panel/ui/cards.slint index 20c8b103..132d7f10 100644 --- a/crates/cp-panel/ui/cards.slint +++ b/crates/cp-panel/ui/cards.slint @@ -1,4 +1,5 @@ import { Card, Chip } from "types.slint"; +import { LinkBody } from "bodies_link.slint"; import { FolderBody } from "bodies_folder.slint"; import { PapersBody } from "bodies_papers.slint"; import { MediaBody } from "bodies_media.slint"; @@ -164,7 +165,7 @@ export component CardView inherits Rectangle { in property card; in property layout; property special: layout == "json" || layout == "video" || layout == "audio" - || layout == "papers" || layout == "folder"; + || layout == "papers" || layout == "folder" || layout == "link"; in property current; in property open; in property shown-here; @@ -230,6 +231,10 @@ export component CardView inherits Rectangle { card: card; } + if !card.has-thumb && !open && layout == "link" : LinkBody { + card: card; + } + if !card.has-thumb && (!card.found || open) && !(special && !open) : Text { text: open ? card.body : card.squeezed; color: card.mono ? Theme.mono : Theme.body; diff --git a/crates/cp-panel/ui/types.slint b/crates/cp-panel/ui/types.slint index bad76fbf..3536b758 100644 --- a/crates/cp-panel/ui/types.slint +++ b/crates/cp-panel/ui/types.slint @@ -17,6 +17,8 @@ export struct Card { folder-name: string, folder-parent: string, folder-said: string, + link-domain: string, + link-path: string, lead: string, hit: string, tail: string, From 1388f6099c2833e9d0ee45a354b8c0cf78134400 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 30 Sep 2026 23:04:12 -0300 Subject: [PATCH 11/22] feat(store): a group key the listing can order by, and the seam it forced open --- crates/cp-panel/src/model.rs | 11 +- crates/cp-panel/src/view_test.rs | 1 + crates/cp-store/examples/budget.rs | 2 +- crates/cp-store/src/enrich.rs | 153 ++++++++++++++++++ crates/cp-store/src/lib.rs | 3 +- crates/cp-store/src/schema.rs | 13 +- crates/cp-store/src/store.rs | 180 ++++++---------------- crates/cp-store/src/store_listing_test.rs | 14 +- 8 files changed, 233 insertions(+), 144 deletions(-) create mode 100644 crates/cp-store/src/enrich.rs diff --git a/crates/cp-panel/src/model.rs b/crates/cp-panel/src/model.rs index 2bb0881f..b3948005 100644 --- a/crates/cp-panel/src/model.rs +++ b/crates/cp-panel/src/model.rs @@ -60,7 +60,7 @@ pub struct Rows { tops: RefCell>, open: Cell>, thumbless: RefCell>, - next: Cell>, + next: RefCell>, exhausted: Cell, loading: Cell, notify: ModelNotify, @@ -80,7 +80,7 @@ impl Rows { tops: RefCell::new(vec![0.0]), open: Cell::new(None), thumbless: RefCell::new(std::collections::HashSet::new()), - next: Cell::new(None), + next: RefCell::new(None), exhausted: Cell::new(false), loading: Cell::new(false), notify: ModelNotify::default(), @@ -189,7 +189,10 @@ impl Rows { if self.exhausted.get() { return 0; } - let page = match self.store.list(&self.filter, PAGE, self.next.get()) { + let page = match self + .store + .list(&self.filter, PAGE, self.next.borrow().clone()) + { Ok(page) => page, Err(why) => { crate::note::note(&format!("la lista no se pudo leer: {why}")); @@ -198,10 +201,10 @@ impl Rows { } }; let added = page.rows.len(); - self.next.set(page.next); if page.next.is_none() { self.exhausted.set(true); } + *self.next.borrow_mut() = page.next; let start = self.rows.borrow().len(); self.cards.borrow_mut().extend((0..added).map(|_| None)); { diff --git a/crates/cp-panel/src/view_test.rs b/crates/cp-panel/src/view_test.rs index 9c9b345e..046492ff 100644 --- a/crates/cp-panel/src/view_test.rs +++ b/crates/cp-panel/src/view_test.rs @@ -17,6 +17,7 @@ fn row(kind: Option) -> Listed { last_used_at: None, broken_since: None, pinned: true, + group: String::new(), snippet: None, } } diff --git a/crates/cp-store/examples/budget.rs b/crates/cp-store/examples/budget.rs index 40852a74..e131010a 100644 --- a/crates/cp-store/examples/budget.rs +++ b/crates/cp-store/examples/budget.rs @@ -158,7 +158,7 @@ fn main() -> std::process::ExitCode { query: Some("with".into()), ..Default::default() }; - store.list(&filter, 100, deep).expect("queried"); + store.list(&filter, 100, deep.clone()).expect("queried"); }, ); over += measure( diff --git a/crates/cp-store/src/enrich.rs b/crates/cp-store/src/enrich.rs new file mode 100644 index 00000000..12a6bfb0 --- /dev/null +++ b/crates/cp-store/src/enrich.rs @@ -0,0 +1,153 @@ +use crate::Result; +use crate::store::Store; +use cp_core::kind::Kind; +use rusqlite::types::ToSql; +use rusqlite::{OptionalExtension, params, params_from_iter}; + +pub type MetaByItem = std::collections::HashMap>; + +impl Store { + pub fn set_group(&self, id: i64, key: &str) -> Result<()> { + self.raw().execute( + "UPDATE items SET group_key = ?2 WHERE id = ?1", + params![id, key], + )?; + Ok(()) + } + + pub fn ungrouped(&self, limit: usize) -> Result, String)>> { + let mut stmt = self.raw().prepare( + "SELECT id, kind, SUBSTR(preview_text, 1, 400) FROM items + WHERE group_key = '' AND deleted_at IS NULL + ORDER BY id DESC LIMIT ?1", + )?; + let rows = stmt.query_map([i64::try_from(limit).unwrap_or(i64::MAX)], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, String>(2)?, + )) + })?; + Ok(rows + .collect::>>()? + .into_iter() + .map(|(id, kind, preview)| (id, kind.as_deref().and_then(Kind::from_name), preview)) + .collect()) + } + + pub fn set_meta(&self, id: i64, key: &str, value: &str) -> Result<()> { + self.raw().execute( + "INSERT INTO item_meta (item_id, key, value) VALUES (?1, ?2, ?3) + ON CONFLICT(item_id, key) DO UPDATE SET value = excluded.value", + params![id, key, value], + )?; + Ok(()) + } + + pub fn meta(&self, id: i64, key: &str) -> Result> { + Ok(self + .raw() + .query_row( + "SELECT value FROM item_meta WHERE item_id = ?1 AND key = ?2", + params![id, key], + |row| row.get(0), + ) + .optional()?) + } + + pub fn all_meta(&self, id: i64) -> Result> { + let mut stmt = self + .raw() + .prepare("SELECT key, value FROM item_meta WHERE item_id = ?1 ORDER BY key")?; + let rows = stmt.query_map([id], |row| Ok((row.get(0)?, row.get(1)?)))?; + Ok(rows.collect::>()?) + } + + pub fn meta_for(&self, ids: &[i64], keys: &[&str]) -> Result { + if ids.is_empty() || keys.is_empty() { + return Ok(MetaByItem::new()); + } + let marks = |how_many: usize| vec!["?"; how_many].join(", "); + let sql = format!( + "SELECT item_id, key, value FROM item_meta + WHERE item_id IN ({}) AND key IN ({})", + marks(ids.len()), + marks(keys.len()) + ); + let mut bound: Vec> = Vec::with_capacity(ids.len() + keys.len()); + for id in ids { + bound.push(Box::new(*id)); + } + for key in keys { + bound.push(Box::new((*key).to_owned())); + } + let mut stmt = self.raw().prepare(&sql)?; + let rows = stmt.query_map(params_from_iter(bound.iter()), |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + })?; + let mut found = MetaByItem::new(); + for row in rows { + let (id, key, value) = row?; + found.entry(id).or_default().insert(key, value); + } + Ok(found) + } + + pub fn enqueue(&self, id: i64, job: &str) -> Result<()> { + self.raw().execute( + "INSERT OR IGNORE INTO pending_work (item_id, job) VALUES (?1, ?2)", + params![id, job], + )?; + Ok(()) + } + + pub fn take_pending(&self, job: &str, now: i64, limit: usize) -> Result> { + let mut stmt = self.raw().prepare( + "SELECT w.item_id + FROM pending_work w + JOIN items i ON i.id = w.item_id + WHERE w.job = ?1 AND w.not_before <= ?2 AND i.deleted_at IS NULL + ORDER BY i.modified_at DESC + LIMIT ?3", + )?; + let rows = stmt.query_map(params![job, now, limit as i64], |row| row.get(0))?; + Ok(rows.collect::>()?) + } + + pub fn work_done(&self, id: i64, job: &str) -> Result<()> { + self.raw().execute( + "DELETE FROM pending_work WHERE item_id = ?1 AND job = ?2", + params![id, job], + )?; + Ok(()) + } + + pub const MAX_ATTEMPTS: i64 = 3; + + pub fn work_failed(&self, id: i64, job: &str, why: &str, retry_at: i64) -> Result { + self.raw().execute( + "UPDATE pending_work + SET attempts = attempts + 1, last_error = ?3, not_before = ?4 + WHERE item_id = ?1 AND job = ?2", + params![id, job, why, retry_at], + )?; + let attempts: i64 = self + .raw() + .query_row( + "SELECT attempts FROM pending_work WHERE item_id = ?1 AND job = ?2", + params![id, job], + |row| row.get(0), + ) + .optional()? + .unwrap_or(0); + if attempts >= Self::MAX_ATTEMPTS { + self.work_done(id, job)?; + return Ok(false); + } + Ok(true) + } +} diff --git a/crates/cp-store/src/lib.rs b/crates/cp-store/src/lib.rs index f9194045..cde833d8 100644 --- a/crates/cp-store/src/lib.rs +++ b/crates/cp-store/src/lib.rs @@ -1,5 +1,6 @@ pub mod backup; pub mod blobs; +pub mod enrich; pub mod legacy; pub mod query; pub mod schema; @@ -7,9 +8,9 @@ pub mod store; pub use backup::{Brought, Made, Taken}; pub use blobs::Blobs; +pub use enrich::MetaByItem; pub use query::{Clock, parse}; pub use schema::SCHEMA_VERSION; -pub use store::MetaByItem; pub use store::restrict; pub use store::{ A_DAY, AppCount, Broken, Cursor, Facet, Filter, FoundIn, Listed, More, Order, PREVIEW_CHARS, diff --git a/crates/cp-store/src/schema.rs b/crates/cp-store/src/schema.rs index 38935b60..d55668a7 100644 --- a/crates/cp-store/src/schema.rs +++ b/crates/cp-store/src/schema.rs @@ -1,6 +1,6 @@ use rusqlite::{Connection, Result}; -pub const SCHEMA_VERSION: u32 = 4; +pub const SCHEMA_VERSION: u32 = 5; pub fn migrate(db: &Connection) -> crate::Result { let found: u32 = db.query_row("PRAGMA user_version", [], |row| row.get(0))?; @@ -27,6 +27,9 @@ pub fn migrate(db: &Connection) -> crate::Result { if ocr_text_is_missing(db)? { db.execute_batch("ALTER TABLE items ADD COLUMN ocr_text TEXT;")?; } + if column_is_missing(db, "group_key")? { + db.execute_batch("ALTER TABLE items ADD COLUMN group_key TEXT NOT NULL DEFAULT '';")?; + } if found < 5 { db.execute_batch(&format!( "DROP TRIGGER IF EXISTS items_au; {SEARCH_TRIGGER}" @@ -72,10 +75,14 @@ fn search_trigger_is_broad(db: &Connection) -> Result { } fn ocr_text_is_missing(db: &Connection) -> Result { + column_is_missing(db, "ocr_text") +} + +fn column_is_missing(db: &Connection, wanted: &str) -> Result { let mut stmt = db.prepare("SELECT name FROM pragma_table_info('items')")?; let columns = stmt.query_map([], |row| row.get::<_, String>(0))?; for column in columns { - if column? == "ocr_text" { + if column? == wanted { return Ok(false); } } @@ -87,6 +94,7 @@ const ORDERING_INDEXES: &str = " CREATE INDEX IF NOT EXISTS items_by_touch ON items(MAX(modified_at, COALESCE(last_used_at, 0)) DESC, id DESC); CREATE INDEX IF NOT EXISTS items_by_kind ON items(kind, modified_at DESC, id DESC); + CREATE INDEX IF NOT EXISTS items_by_group ON items(group_key DESC, id DESC); "; pub fn configure(db: &Connection) -> Result<()> { @@ -134,6 +142,7 @@ const TABLES: &str = r#" kind TEXT, preview_text TEXT NOT NULL DEFAULT '', app_source TEXT, + group_key TEXT NOT NULL DEFAULT '', label TEXT, card_color INTEGER NOT NULL DEFAULT 0, thumb_path TEXT, diff --git a/crates/cp-store/src/store.rs b/crates/cp-store/src/store.rs index 81e53df1..dde60007 100644 --- a/crates/cp-store/src/store.rs +++ b/crates/cp-store/src/store.rs @@ -42,27 +42,34 @@ pub struct Listed { pub last_used_at: Option, pub broken_since: Option, pub pinned: bool, + pub group: String, pub snippet: Option, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Cursor { order: Order, - key: i64, + key: String, id: i64, } impl Cursor { - pub fn encode(self) -> String { + pub fn encode(&self) -> String { format!("{}:{}:{}", self.order.as_str(), self.key, self.id) } pub fn decode(text: &str) -> Option { - let mut parts = text.split(':'); - let order = Order::from_name(parts.next()?)?; - let key = parts.next()?.parse().ok()?; - let id = parts.next()?.parse().ok()?; - parts.next().is_none().then_some(Cursor { order, key, id }) + let (name, rest) = text.split_once(':')?; + let order = Order::from_name(name)?; + let (key, id) = rest.rsplit_once(':')?; + if order != Order::ByGroup && key.parse::().is_err() { + return None; + } + Some(Cursor { + order, + key: key.to_owned(), + id: id.parse().ok()?, + }) } } @@ -80,16 +87,23 @@ pub enum Order { Recent, MostPasted, LastUsed, + ByGroup, } impl Order { - pub const ALL: [Order; 3] = [Order::Recent, Order::MostPasted, Order::LastUsed]; + pub const ALL: [Order; 4] = [ + Order::Recent, + Order::MostPasted, + Order::LastUsed, + Order::ByGroup, + ]; pub fn as_str(self) -> &'static str { match self { Order::Recent => "recent", Order::MostPasted => "most-pasted", Order::LastUsed => "last-used", + Order::ByGroup => "by-group", } } @@ -102,6 +116,7 @@ impl Order { Order::Recent => TOUCHED, Order::MostPasted => "items.paste_count", Order::LastUsed => "COALESCE(items.last_used_at, -1)", + Order::ByGroup => "items.group_key", } } } @@ -242,8 +257,6 @@ impl Clauses { } } -pub type MetaByItem = std::collections::HashMap>; - pub struct Store { db: Connection, blobs: Option, @@ -422,122 +435,6 @@ impl Store { Ok(rows.collect::>()?) } - pub fn set_meta(&self, id: i64, key: &str, value: &str) -> Result<()> { - self.db.execute( - "INSERT INTO item_meta (item_id, key, value) VALUES (?1, ?2, ?3) - ON CONFLICT(item_id, key) DO UPDATE SET value = excluded.value", - params![id, key, value], - )?; - Ok(()) - } - - pub fn meta(&self, id: i64, key: &str) -> Result> { - Ok(self - .db - .query_row( - "SELECT value FROM item_meta WHERE item_id = ?1 AND key = ?2", - params![id, key], - |row| row.get(0), - ) - .optional()?) - } - - pub fn all_meta(&self, id: i64) -> Result> { - let mut stmt = self - .db - .prepare("SELECT key, value FROM item_meta WHERE item_id = ?1 ORDER BY key")?; - let rows = stmt.query_map([id], |row| Ok((row.get(0)?, row.get(1)?)))?; - Ok(rows.collect::>()?) - } - - pub fn meta_for(&self, ids: &[i64], keys: &[&str]) -> Result { - if ids.is_empty() || keys.is_empty() { - return Ok(MetaByItem::new()); - } - let marks = |how_many: usize| vec!["?"; how_many].join(", "); - let sql = format!( - "SELECT item_id, key, value FROM item_meta - WHERE item_id IN ({}) AND key IN ({})", - marks(ids.len()), - marks(keys.len()) - ); - let mut bound: Vec> = Vec::with_capacity(ids.len() + keys.len()); - for id in ids { - bound.push(Box::new(*id)); - } - for key in keys { - bound.push(Box::new((*key).to_owned())); - } - let mut stmt = self.db.prepare(&sql)?; - let rows = stmt.query_map(params_from_iter(bound.iter()), |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - )) - })?; - let mut found = MetaByItem::new(); - for row in rows { - let (id, key, value) = row?; - found.entry(id).or_default().insert(key, value); - } - Ok(found) - } - - pub fn enqueue(&self, id: i64, job: &str) -> Result<()> { - self.db.execute( - "INSERT OR IGNORE INTO pending_work (item_id, job) VALUES (?1, ?2)", - params![id, job], - )?; - Ok(()) - } - - pub fn take_pending(&self, job: &str, now: i64, limit: usize) -> Result> { - let mut stmt = self.db.prepare( - "SELECT w.item_id - FROM pending_work w - JOIN items i ON i.id = w.item_id - WHERE w.job = ?1 AND w.not_before <= ?2 AND i.deleted_at IS NULL - ORDER BY i.modified_at DESC - LIMIT ?3", - )?; - let rows = stmt.query_map(params![job, now, limit as i64], |row| row.get(0))?; - Ok(rows.collect::>()?) - } - - pub fn work_done(&self, id: i64, job: &str) -> Result<()> { - self.db.execute( - "DELETE FROM pending_work WHERE item_id = ?1 AND job = ?2", - params![id, job], - )?; - Ok(()) - } - - pub const MAX_ATTEMPTS: i64 = 3; - - pub fn work_failed(&self, id: i64, job: &str, why: &str, retry_at: i64) -> Result { - self.db.execute( - "UPDATE pending_work - SET attempts = attempts + 1, last_error = ?3, not_before = ?4 - WHERE item_id = ?1 AND job = ?2", - params![id, job, why, retry_at], - )?; - let attempts: i64 = self - .db - .query_row( - "SELECT attempts FROM pending_work WHERE item_id = ?1 AND job = ?2", - params![id, job], - |row| row.get(0), - ) - .optional()? - .unwrap_or(0); - if attempts >= Self::MAX_ATTEMPTS { - self.work_done(id, job)?; - return Ok(false); - } - Ok(true) - } - pub fn set_label(&self, id: i64, label: Option<&str>, at: i64) -> Result<()> { self.db.execute( "UPDATE items SET label = ?2, search_label = ?3, updated_at = ?4 WHERE id = ?1", @@ -1020,8 +917,8 @@ impl Store { clauses .conditions .push(format!("({key} < ? OR ({key} = ? AND items.id < ?))")); - clauses.bound.push(Box::new(cursor.key)); - clauses.bound.push(Box::new(cursor.key)); + clauses.bound.push(bound_key(filter.order, &cursor.key)); + clauses.bound.push(bound_key(filter.order, &cursor.key)); clauses.bound.push(Box::new(cursor.id)); } let sql = page_sql(&clauses, key, !terms.is_empty()); @@ -1048,18 +945,25 @@ impl Store { last_used_at: row.get(10)?, broken_since: row.get(11)?, pinned: row.get::<_, i64>(12)? == 1, + group: row.get(16)?, snippet: None, }; listed.snippet = snippet_of(&listed, &whole, &ocr, &terms); - Ok((listed, row.get::<_, i64>(14)?)) + let key = match filter.order { + Order::ByGroup => row.get::<_, String>(14)?, + Order::Recent | Order::MostPasted | Order::LastUsed => { + row.get::<_, i64>(14)?.to_string() + } + }; + Ok((listed, key)) })?; - let mut keyed: Vec<(Listed, i64)> = rows.collect::>()?; + let mut keyed: Vec<(Listed, String)> = rows.collect::>()?; let more = keyed.len() > limit; keyed.truncate(limit); let next = match keyed.last() { Some((last, key)) if more => Some(Cursor { order: filter.order, - key: *key, + key: key.clone(), id: last.id, }), _ => None, @@ -1343,12 +1247,22 @@ pub struct Usage { pub const PREVIEW_CHARS: usize = 2_000; +fn bound_key(order: Order, key: &str) -> Box { + match order { + Order::ByGroup => Box::new(key.to_owned()), + Order::Recent | Order::MostPasted | Order::LastUsed => { + Box::new(key.parse::().unwrap_or(i64::MIN)) + } + } +} + fn page_sql(clauses: &Clauses, key: &str, with_query: bool) -> String { format!( "SELECT items.id, items.modified_at, items.created_at, items.kind, SUBSTR(items.preview_text, 1, {preview}), items.app_source, items.label, items.card_color, items.thumb_path, items.paste_count, items.last_used_at, - items.broken_since, items.pinned, {ocr}, page.key, {whole} + items.broken_since, items.pinned, {ocr}, page.key, {whole}, + items.group_key FROM (SELECT items.id AS id, {key} AS key {} ORDER BY {key} DESC, items.id DESC LIMIT ?) AS page JOIN items ON items.id = page.id diff --git a/crates/cp-store/src/store_listing_test.rs b/crates/cp-store/src/store_listing_test.rs index f5a0668e..23f04852 100644 --- a/crates/cp-store/src/store_listing_test.rs +++ b/crates/cp-store/src/store_listing_test.rs @@ -668,7 +668,7 @@ fn every_order_has_a_stable_name_that_comes_back() { let mut names: Vec<&str> = Order::ALL.iter().map(|order| order.as_str()).collect(); names.sort_unstable(); names.dedup(); - assert_eq!(names.len(), 3); + assert_eq!(names.len(), 4); assert_eq!(Order::from_name("Recent"), None, "the name is exact"); } @@ -682,7 +682,7 @@ fn a_cursor_from_another_order_is_refused_not_misread() { ..Default::default() }; assert!(matches!( - store.list(&pasted, 2, Some(cursor)), + store.list(&pasted, 2, Some(cursor.clone())), Err(Error::WrongCursor { .. }) )); let text = cursor.encode(); @@ -694,7 +694,15 @@ fn a_cursor_from_another_order_is_refused_not_misread() { assert_eq!(Cursor::decode("recent:1"), None); assert_eq!(Cursor::decode("sideways:1:2"), None); assert_eq!(Cursor::decode("recent:1:2:3"), None); - assert_eq!(Cursor::decode("recent:x:2"), None); + assert_eq!( + Cursor::decode("recent:x:2"), + None, + "a numeric order wants a number" + ); + let grouped = Cursor::decode("by-group:ejemplo.test:7").expect("a group cursor"); + assert_eq!(Cursor::decode(&grouped.encode()), Some(grouped)); + let colons = Cursor::decode("by-group:carpeta:de:red:7").expect("a key may hold colons"); + assert_eq!(Cursor::decode(&colons.encode()), Some(colons)); } #[test] From 34d7fd9c1a5baa7a85d56c0fc917adf789f9c63f Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 30 Sep 2026 23:18:23 -0300 Subject: [PATCH 12/22] feat(panel): a grouped view heads each group from the card that opens it --- crates/cp-panel/src/app.rs | 3 ++ crates/cp-panel/src/engine.rs | 34 ++++++++++++++++- crates/cp-panel/src/folder.rs | 22 +++++++++++ crates/cp-panel/src/folder_test.rs | 29 +++++++++++++++ crates/cp-panel/src/group.rs | 30 +++++++++++++++ crates/cp-panel/src/group_test.rs | 48 ++++++++++++++++++++++++ crates/cp-panel/src/layout.rs | 4 ++ crates/cp-panel/src/layout_test.rs | 12 ++++++ crates/cp-panel/src/main.rs | 1 + crates/cp-panel/src/model.rs | 20 ++++++++++ crates/cp-panel/src/model_test.rs | 59 ++++++++++++++++++++++++++++++ crates/cp-panel/src/view.rs | 2 + crates/cp-panel/ui/cards.slint | 20 ++++++++++ crates/cp-panel/ui/types.slint | 2 + crates/cp-store/src/enrich.rs | 1 + 15 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 crates/cp-panel/src/group.rs create mode 100644 crates/cp-panel/src/group_test.rs diff --git a/crates/cp-panel/src/app.rs b/crates/cp-panel/src/app.rs index c5489928..12ee00f0 100644 --- a/crates/cp-panel/src/app.rs +++ b/crates/cp-panel/src/app.rs @@ -728,6 +728,9 @@ fn filter_of(state: &State) -> Filter { if state.pinned { filter.pinned_only = true; } + if crate::layout::layout_for(&filter.kinds).groups() { + filter.order = cp_store::Order::ByGroup; + } filter } diff --git a/crates/cp-panel/src/engine.rs b/crates/cp-panel/src/engine.rs index cc56dfce..52d99a7e 100644 --- a/crates/cp-panel/src/engine.rs +++ b/crates/cp-panel/src/engine.rs @@ -130,7 +130,7 @@ fn errand(store: &Store, thumbs: &Path) -> bool { walked(store, id, at); return true; } - false + grouped_some(store) } fn first_waiting(store: &Store, job: &str, at: i64) -> Option { @@ -182,6 +182,32 @@ fn measured(store: &Store, id: i64, at: i64) { done(store, id, "media"); } +fn grouped_some(store: &Store) -> bool { + let waiting = match store.ungrouped(crate::group::AT_A_TIME) { + Ok(waiting) => waiting, + Err(why) => { + note(&format!("what has no group could not be looked at: {why}")); + return false; + } + }; + if waiting.is_empty() { + return false; + } + for (id, kind, preview) in &waiting { + let key = crate::group::key_of(*kind, preview); + let key = if key.is_empty() { + crate::group::UNKNOWN + } else { + key.as_str() + }; + if let Err(why) = store.set_group(*id, key) { + note(&format!("{id} was left without a group: {why}")); + return false; + } + } + true +} + fn walked(store: &Store, id: i64, at: i64) { let Some(path) = store .item(id) @@ -333,6 +359,12 @@ fn keep(store: &Store, item: &Item, at: i64, from: Option<&str>) -> Option "{id} was left not knowing where it came from: {why}" )); } + let key = crate::group::key_of(item.kind, &preview_of(item)); + if !key.is_empty() + && let Err(why) = store.set_group(id, &key) + { + note(&format!("{id} was left without a group: {why}")); + } for job in jobs_for(item) { if let Err(why) = store.enqueue(id, job) { note(&format!("{id} was left without {job} queued: {why}")); diff --git a/crates/cp-panel/src/folder.rs b/crates/cp-panel/src/folder.rs index f3dc0536..70052952 100644 --- a/crates/cp-panel/src/folder.rs +++ b/crates/cp-panel/src/folder.rs @@ -32,6 +32,28 @@ pub fn parts_of(preview: &str) -> Option { }) } +pub fn unit_of(preview: &str) -> String { + let first = preview.lines().next().unwrap_or("").trim(); + if let Some(rest) = first.strip_prefix(r"\\") { + let host = rest.split(['\\', '/']).next().unwrap_or(""); + return if host.is_empty() { + String::new() + } else { + format!(r"\\{host}") + }; + } + if let Some((drive, _)) = first.split_once(':') + && drive.len() == 1 + && drive.chars().all(char::is_alphabetic) + { + return drive.to_uppercase() + ":"; + } + if first.starts_with('/') { + return "/".to_owned(); + } + String::new() +} + pub fn counted_in(path: &std::path::Path) -> Option { let reading = std::fs::read_dir(path).ok()?; let mut seen = 0; diff --git a/crates/cp-panel/src/folder_test.rs b/crates/cp-panel/src/folder_test.rs index 03d25cc3..9f86e68e 100644 --- a/crates/cp-panel/src/folder_test.rs +++ b/crates/cp-panel/src/folder_test.rs @@ -99,3 +99,32 @@ fn counting_what_is_not_there_answers_nothing_instead_of_zero() { let nowhere = std::env::temp_dir().join("cp-folder-que-no-existe-jamas"); assert_eq!(counted_in(&nowhere), None); } + +#[test] +fn a_drive_letter_is_the_unit_a_folder_belongs_to() { + assert_eq!(unit_of(r"D:\Mario\Orca"), "D:"); + assert_eq!(unit_of(r"c:\usuarios"), "C:", "the letter is folded up"); +} + +#[test] +fn a_share_is_grouped_by_its_server_not_by_its_folders() { + assert_eq!(unit_of(r"\\servidor\legal\Contratos"), r"\\servidor"); + assert_eq!(unit_of(r"\\servidor"), r"\\servidor"); +} + +#[test] +fn a_unix_root_is_one_single_unit() { + assert_eq!(unit_of("/home/mario/Documentos"), "/"); +} + +#[test] +fn what_has_no_unit_gets_no_group_instead_of_a_wrong_one() { + assert_eq!(unit_of(""), ""); + assert_eq!(unit_of("Descargas"), ""); + assert_eq!(unit_of(r"\\"), ""); + assert_eq!( + unit_of("https://ejemplo.test/x"), + "", + "a scheme is not a drive" + ); +} diff --git a/crates/cp-panel/src/group.rs b/crates/cp-panel/src/group.rs new file mode 100644 index 00000000..ce393bd5 --- /dev/null +++ b/crates/cp-panel/src/group.rs @@ -0,0 +1,30 @@ +use cp_core::kind::Kind; + +pub const UNKNOWN: &str = "?"; +pub const AT_A_TIME: usize = 60; + +const _: () = assert!(!UNKNOWN.is_empty()); +const _: () = assert!(AT_A_TIME >= 1); + +pub fn key_of(kind: Option, preview: &str) -> String { + let found = match kind { + Some(Kind::Link) => crate::link::parts_of(preview) + .map(|one| one.domain) + .unwrap_or_default(), + Some(Kind::Folder) => crate::folder::unit_of(preview), + _ => return String::new(), + }; + if found.is_empty() { + UNKNOWN.to_owned() + } else { + found + } +} + +pub fn shown(key: &str) -> bool { + !key.is_empty() && key != UNKNOWN +} + +#[cfg(test)] +#[path = "group_test.rs"] +mod tests; diff --git a/crates/cp-panel/src/group_test.rs b/crates/cp-panel/src/group_test.rs new file mode 100644 index 00000000..20459101 --- /dev/null +++ b/crates/cp-panel/src/group_test.rs @@ -0,0 +1,48 @@ +use super::*; + +#[test] +fn a_link_is_grouped_by_its_domain() { + assert_eq!( + key_of(Some(Kind::Link), "https://github.com/a/b"), + "github.com" + ); + assert_eq!( + key_of(Some(Kind::Link), "https://www.GitHub.com/c"), + "github.com", + "two spellings of one site land in one group" + ); +} + +#[test] +fn a_folder_is_grouped_by_its_unit() { + assert_eq!(key_of(Some(Kind::Folder), r"D:\Mario\Orca"), "D:"); +} + +#[test] +fn a_kind_that_does_not_group_gets_an_empty_key() { + assert_eq!(key_of(Some(Kind::Text), "hola"), ""); + assert_eq!(key_of(Some(Kind::Json), "{}"), ""); + assert_eq!(key_of(None, "lo que sea"), ""); +} + +#[test] +fn a_candidate_nobody_could_read_is_marked_so_it_is_not_tried_forever() { + assert_eq!(key_of(Some(Kind::Link), "no-es-una-url"), UNKNOWN); + assert_eq!(key_of(Some(Kind::Folder), "sin_unidad"), UNKNOWN); + assert_ne!( + key_of(Some(Kind::Link), "no-es-una-url"), + "", + "an empty key would be read as «not computed yet» and queued again" + ); +} + +#[test] +fn what_is_not_a_real_group_draws_no_heading() { + assert!(shown("github.com")); + assert!(shown("D:")); + assert!(!shown(""), "no group at all"); + assert!( + !shown(UNKNOWN), + "a group we could not name is not a heading" + ); +} diff --git a/crates/cp-panel/src/layout.rs b/crates/cp-panel/src/layout.rs index cfb9640c..587120ba 100644 --- a/crates/cp-panel/src/layout.rs +++ b/crates/cp-panel/src/layout.rs @@ -25,6 +25,10 @@ impl Layout { Layout::Papers, ]; + pub fn groups(self) -> bool { + matches!(self, Layout::Link | Layout::Folder) + } + pub fn as_str(self) -> &'static str { match self { Layout::Everything => "everything", diff --git a/crates/cp-panel/src/layout_test.rs b/crates/cp-panel/src/layout_test.rs index f674da86..8e898384 100644 --- a/crates/cp-panel/src/layout_test.rs +++ b/crates/cp-panel/src/layout_test.rs @@ -92,3 +92,15 @@ fn no_two_layouts_answer_to_the_same_name() { assert!(!one.as_str().is_empty()); } } + +#[test] +fn only_the_two_views_the_maquette_grouped_ask_for_grouping() { + assert!(Layout::Link.groups()); + assert!(Layout::Folder.groups()); + for one in Layout::ALL { + if matches!(one, Layout::Link | Layout::Folder) { + continue; + } + assert!(!one.groups(), "{one:?} was drawn as one flat list"); + } +} diff --git a/crates/cp-panel/src/main.rs b/crates/cp-panel/src/main.rs index 2fe9a81d..ce860397 100644 --- a/crates/cp-panel/src/main.rs +++ b/crates/cp-panel/src/main.rs @@ -2,6 +2,7 @@ mod age; mod app; mod engine; mod folder; +mod group; mod here; mod layout; mod link; diff --git a/crates/cp-panel/src/model.rs b/crates/cp-panel/src/model.rs index b3948005..2a96ecc5 100644 --- a/crates/cp-panel/src/model.rs +++ b/crates/cp-panel/src/model.rs @@ -20,6 +20,22 @@ pub struct Metrics { const EDGE: f32 = 8.0; +pub fn heads_group(filter: &Filter, rows: &[Listed], index: usize) -> bool { + if !crate::layout::layout_for(&filter.kinds).groups() { + return false; + } + let Some(row) = rows.get(index) else { + return false; + }; + if !crate::group::shown(&row.group) { + return false; + } + match index.checked_sub(1).and_then(|before| rows.get(before)) { + Some(before) => before.group != row.group, + None => true, + } +} + pub fn meta_keys_for(filter: &Filter) -> &'static [&'static str] { match crate::layout::layout_for(&filter.kinds) { crate::layout::Layout::Video | crate::layout::Layout::Audio => &crate::media::KEYS, @@ -261,6 +277,10 @@ impl Rows { let meta = self.meta.borrow(); let mut card = card_of(row, self.now, meta.get(&row.id)); drop(meta); + if heads_group(&self.filter, &rows, index) { + card.heads_group = true; + card.group_said = row.group.clone().into(); + } if let Some(path) = &row.thumb_path && let Ok(image) = slint::Image::load_from_path(std::path::Path::new(path)) { diff --git a/crates/cp-panel/src/model_test.rs b/crates/cp-panel/src/model_test.rs index c103c3fa..4b898a85 100644 --- a/crates/cp-panel/src/model_test.rs +++ b/crates/cp-panel/src/model_test.rs @@ -265,3 +265,62 @@ fn only_the_views_that_use_meta_ask_the_store_for_any() { "two kinds is the general view, which asks for nothing" ); } + +fn grouped(group: &str) -> cp_store::Listed { + cp_store::Listed { + id: 1, + modified_at: 0, + created_at: 0, + kind: Some(cp_core::kind::Kind::Link), + preview: String::new(), + app: None, + label: None, + color: 0, + thumb_path: None, + paste_count: 0, + last_used_at: None, + broken_since: None, + pinned: false, + group: group.to_owned(), + snippet: None, + } +} + +#[test] +fn the_first_row_of_a_group_is_the_one_that_heads_it() { + let linked = Filter { + kinds: vec![cp_core::kind::Kind::Link], + ..Default::default() + }; + let rows = [ + grouped("docs.rs"), + grouped("docs.rs"), + grouped("github.com"), + ]; + assert!(heads_group(&linked, &rows, 0), "the first row always heads"); + assert!( + !heads_group(&linked, &rows, 1), + "same group as the one above" + ); + assert!(heads_group(&linked, &rows, 2), "the group changed"); + assert!(!heads_group(&linked, &rows, 9), "there is no row there"); +} + +#[test] +fn a_view_that_does_not_group_draws_no_heading_at_all() { + let general = Filter::default(); + let rows = [grouped("docs.rs"), grouped("github.com")]; + assert!(!heads_group(&general, &rows, 0)); + assert!(!heads_group(&general, &rows, 1)); +} + +#[test] +fn a_group_nobody_could_name_heads_nothing() { + let linked = Filter { + kinds: vec![cp_core::kind::Kind::Link], + ..Default::default() + }; + let rows = [grouped(crate::group::UNKNOWN), grouped("")]; + assert!(!heads_group(&linked, &rows, 0)); + assert!(!heads_group(&linked, &rows, 1)); +} diff --git a/crates/cp-panel/src/view.rs b/crates/cp-panel/src/view.rs index d27e8362..b1439c6c 100644 --- a/crates/cp-panel/src/view.rs +++ b/crates/cp-panel/src/view.rs @@ -142,6 +142,8 @@ pub fn card_of(row: &Listed, now: i64, meta: Option<&MetaOfOne>) -> Card { .map(|one| one.domain.clone()) .unwrap_or_default() .into(), + heads_group: false, + group_said: Default::default(), link_path: link .as_ref() .map(|one| one.path.clone()) diff --git a/crates/cp-panel/ui/cards.slint b/crates/cp-panel/ui/cards.slint index 132d7f10..ea9236e6 100644 --- a/crates/cp-panel/ui/cards.slint +++ b/crates/cp-panel/ui/cards.slint @@ -198,6 +198,26 @@ export component CardView inherits Rectangle { padding: 10px; spacing: 8px; + if card.heads-group : HorizontalLayout { + height: 15px; + spacing: 7px; + Text { + text: card.group-said; + color: Theme.title; + font-size: 11px; + font-weight: 600; + vertical-alignment: center; + } + Rectangle { + horizontal-stretch: 1; + Rectangle { + y: (parent.height - 1px) / 2; + height: 1px; + background: Theme.panel-border; + } + } + } + if card.has-thumb : Rectangle { height: 96px; border-radius: 8px; diff --git a/crates/cp-panel/ui/types.slint b/crates/cp-panel/ui/types.slint index 3536b758..682a9bd2 100644 --- a/crates/cp-panel/ui/types.slint +++ b/crates/cp-panel/ui/types.slint @@ -19,6 +19,8 @@ export struct Card { folder-said: string, link-domain: string, link-path: string, + heads-group: bool, + group-said: string, lead: string, hit: string, tail: string, diff --git a/crates/cp-store/src/enrich.rs b/crates/cp-store/src/enrich.rs index 12a6bfb0..12ed0e80 100644 --- a/crates/cp-store/src/enrich.rs +++ b/crates/cp-store/src/enrich.rs @@ -19,6 +19,7 @@ impl Store { let mut stmt = self.raw().prepare( "SELECT id, kind, SUBSTR(preview_text, 1, 400) FROM items WHERE group_key = '' AND deleted_at IS NULL + AND kind IN ('link', 'folder') ORDER BY id DESC LIMIT ?1", )?; let rows = stmt.query_map([i64::try_from(limit).unwrap_or(i64::MAX)], |row| { From 1a179d8ac35aead02853d3161c8cff2d9cea931b Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 30 Sep 2026 23:26:54 -0300 Subject: [PATCH 13/22] fix(store): opening a history adds the columns its own indexes name --- crates/cp-store/src/schema.rs | 26 ++++++----- crates/cp-store/src/schema_test.rs | 70 ++++++++++++++++++++++++++++-- 2 files changed, 81 insertions(+), 15 deletions(-) diff --git a/crates/cp-store/src/schema.rs b/crates/cp-store/src/schema.rs index d55668a7..fcd065ca 100644 --- a/crates/cp-store/src/schema.rs +++ b/crates/cp-store/src/schema.rs @@ -14,22 +14,17 @@ pub fn migrate(db: &Connection) -> crate::Result { return Ok(false); } db.execute_batch("BEGIN IMMEDIATE;")?; + added_columns(db)?; if ordering_indexes_are_stale(db)? { - db.execute_batch(&format!( + db.execute_batch( "DROP INDEX IF EXISTS items_by_recency; - DROP INDEX IF EXISTS items_by_kind; - {ORDERING_INDEXES}" - ))?; + DROP INDEX IF EXISTS items_by_kind;", + )?; } + db.execute_batch(ORDERING_INDEXES)?; if found < 3 { db.execute_batch("INSERT INTO items_fts(items_fts) VALUES ('optimize');")?; } - if ocr_text_is_missing(db)? { - db.execute_batch("ALTER TABLE items ADD COLUMN ocr_text TEXT;")?; - } - if column_is_missing(db, "group_key")? { - db.execute_batch("ALTER TABLE items ADD COLUMN group_key TEXT NOT NULL DEFAULT '';")?; - } if found < 5 { db.execute_batch(&format!( "DROP TRIGGER IF EXISTS items_au; {SEARCH_TRIGGER}" @@ -74,8 +69,14 @@ fn search_trigger_is_broad(db: &Connection) -> Result { Ok(said.is_some_and(|one| !one.contains("UPDATE OF"))) } -fn ocr_text_is_missing(db: &Connection) -> Result { - column_is_missing(db, "ocr_text") +fn added_columns(db: &Connection) -> Result<()> { + if column_is_missing(db, "ocr_text")? { + db.execute_batch("ALTER TABLE items ADD COLUMN ocr_text TEXT;")?; + } + if column_is_missing(db, "group_key")? { + db.execute_batch("ALTER TABLE items ADD COLUMN group_key TEXT NOT NULL DEFAULT '';")?; + } + Ok(()) } fn column_is_missing(db: &Connection, wanted: &str) -> Result { @@ -113,6 +114,7 @@ pub fn configure(db: &Connection) -> Result<()> { pub fn create(db: &Connection) -> Result<()> { configure(db)?; db.execute_batch(TABLES)?; + added_columns(db)?; db.execute_batch(ORDERING_INDEXES)?; if search_trigger_is_broad(db)? { db.execute_batch(&format!( diff --git a/crates/cp-store/src/schema_test.rs b/crates/cp-store/src/schema_test.rs index 41620255..4d9636fd 100644 --- a/crates/cp-store/src/schema_test.rs +++ b/crates/cp-store/src/schema_test.rs @@ -275,11 +275,14 @@ fn a_version_three_database_gains_the_column_for_the_text_as_read() { PRAGMA user_version = 3;", ) .expect("a database as version 3 left it"); - create(&db).expect("opening does not add columns to a table that already exists"); - assert!(ocr_text_is_missing(&db).expect("looked")); + assert!(column_is_missing(&db, "ocr_text").expect("looked")); + create(&db).expect("opening adds the columns its own indexes and queries name"); + assert!( + !column_is_missing(&db, "ocr_text").expect("looked"), + "opening cannot leave a column missing: the indexes right below name it" + ); assert!(migrate(&db).expect("migrated")); - assert!(!ocr_text_is_missing(&db).expect("looked")); db.execute( "INSERT INTO items (uuid, created_at, modified_at, updated_at, content_hash, ocr_text) VALUES ('u', 1, 1, 1, 0, 'Raw')", @@ -320,3 +323,64 @@ fn a_version_one_database_gets_its_ordering_indexes_rebuilt() { .expect("queried"); assert_eq!(version, SCHEMA_VERSION); } + +#[test] +fn a_database_without_the_group_column_gets_it_before_anything_indexes_it() { + let db = Connection::open_in_memory().expect("opened"); + create(&db).expect("schema"); + db.execute_batch( + "DROP INDEX IF EXISTS items_by_group; + ALTER TABLE items DROP COLUMN group_key; + PRAGMA user_version = 4;", + ) + .expect("wound back to before the column existed"); + + migrate(&db).expect("the migration adds the column before the index names it"); + + let has: i64 = db + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('items') WHERE name = 'group_key'", + [], + |row| row.get(0), + ) + .expect("asked"); + assert_eq!(has, 1, "the column is there"); + let indexed: i64 = db + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'items_by_group'", + [], + |row| row.get(0), + ) + .expect("asked"); + assert_eq!(indexed, 1, "and so is its index"); +} + +#[test] +fn a_row_that_predates_the_group_column_reads_as_having_no_group() { + let db = Connection::open_in_memory().expect("opened"); + create(&db).expect("schema"); + db.execute_batch( + "DROP INDEX IF EXISTS items_by_group; + ALTER TABLE items DROP COLUMN group_key;", + ) + .expect("wound back"); + db.execute_batch( + "INSERT INTO items (uuid, kind, preview_text, created_at, modified_at, updated_at, + content_hash) + VALUES ('antes', 'link', 'https://ejemplo.test/x', 1, 1, 1, 7);", + ) + .expect("a row from before"); + db.execute_batch("PRAGMA user_version = 4;") + .expect("stamped"); + + migrate(&db).expect("migrated"); + + let group: String = db + .query_row( + "SELECT group_key FROM items WHERE uuid = 'antes'", + [], + |row| row.get(0), + ) + .expect("read"); + assert_eq!(group, "", "empty means «nobody has grouped it yet»"); +} From 98a2d56c570685c1d49234efee89b64ebf79aef7 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 30 Sep 2026 23:34:23 -0300 Subject: [PATCH 14/22] feat(panel): images come in a grid, and the list keeps every index it had --- crates/cp-panel/src/app.rs | 1 + crates/cp-panel/src/main.rs | 1 + crates/cp-panel/src/paired.rs | 53 +++++++++++++++++ crates/cp-panel/src/paired_test.rs | 28 +++++++++ crates/cp-panel/ui/bodies_grid.slint | 89 ++++++++++++++++++++++++++++ crates/cp-panel/ui/panel.slint | 40 +++++++++++++ crates/cp-panel/ui/theme.slint | 1 + 7 files changed, 213 insertions(+) create mode 100644 crates/cp-panel/src/paired.rs create mode 100644 crates/cp-panel/src/paired_test.rs create mode 100644 crates/cp-panel/ui/bodies_grid.slint diff --git a/crates/cp-panel/src/app.rs b/crates/cp-panel/src/app.rs index 12ee00f0..1a20dd69 100644 --- a/crates/cp-panel/src/app.rs +++ b/crates/cp-panel/src/app.rs @@ -626,6 +626,7 @@ fn refresh(ui: &Panel, state: &Rc>) { ui.set_current(if rows.loaded() > 0 { 0 } else { -1 }); ui.set_scroll_y(0.0); ui.set_cards(ModelRc::from(rows.clone())); + ui.set_grid_lines(ModelRc::from(crate::paired::Paired::over(rows.clone()))); let mut state = state.borrow_mut(); state.rows = Some(rows); state.last_refresh = started.elapsed(); diff --git a/crates/cp-panel/src/main.rs b/crates/cp-panel/src/main.rs index ce860397..7c756f05 100644 --- a/crates/cp-panel/src/main.rs +++ b/crates/cp-panel/src/main.rs @@ -10,6 +10,7 @@ mod measure; mod media; mod model; mod note; +mod paired; mod papers; mod say; mod shape; diff --git a/crates/cp-panel/src/paired.rs b/crates/cp-panel/src/paired.rs new file mode 100644 index 00000000..84c2897f --- /dev/null +++ b/crates/cp-panel/src/paired.rs @@ -0,0 +1,53 @@ +use crate::model::Rows; +use slint::{Model, ModelTracker}; +use std::rc::Rc; + +pub const ACROSS: usize = 2; + +const _: () = assert!(ACROSS >= 2); + +pub struct Paired { + rows: Rc, +} + +impl Paired { + pub fn over(rows: Rc) -> Rc { + Rc::new(Self { rows }) + } + + pub fn lines(&self) -> usize { + lines_for(self.rows.row_count()) + } +} + +pub fn lines_for(cells: usize) -> usize { + cells.div_ceil(ACROSS) +} + +impl Model for Paired { + type Data = i32; + + fn row_count(&self) -> usize { + self.lines() + } + + fn row_data(&self, index: usize) -> Option { + if index >= self.lines() { + return None; + } + let _ = self.rows.row_data(index * ACROSS); + i32::try_from(index).ok() + } + + fn model_tracker(&self) -> &dyn ModelTracker { + self.rows.model_tracker() + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} + +#[cfg(test)] +#[path = "paired_test.rs"] +mod tests; diff --git a/crates/cp-panel/src/paired_test.rs b/crates/cp-panel/src/paired_test.rs new file mode 100644 index 00000000..ffd896f6 --- /dev/null +++ b/crates/cp-panel/src/paired_test.rs @@ -0,0 +1,28 @@ +use super::*; + +#[test] +fn a_grid_of_two_across_needs_half_the_lines_rounded_up() { + assert_eq!(lines_for(0), 0); + assert_eq!(lines_for(1), 1, "one cell still takes a whole line"); + assert_eq!(lines_for(2), 1); + assert_eq!(lines_for(3), 2, "the odd one gets a line of its own"); + assert_eq!(lines_for(4), 2); + assert_eq!(lines_for(9), 5); +} + +#[test] +fn the_count_never_loses_a_cell() { + for cells in 0..40usize { + let lines = lines_for(cells); + assert!( + lines * ACROSS >= cells, + "{cells} cells would not fit in {lines} lines" + ); + if cells > 0 { + assert!( + (lines - 1) * ACROSS < cells, + "{lines} lines for {cells} cells leaves one empty" + ); + } + } +} diff --git a/crates/cp-panel/ui/bodies_grid.slint b/crates/cp-panel/ui/bodies_grid.slint new file mode 100644 index 00000000..80467f7c --- /dev/null +++ b/crates/cp-panel/ui/bodies_grid.slint @@ -0,0 +1,89 @@ +import { Card } from "types.slint"; +import { Theme } from "theme.slint"; + +export component GridCell inherits Rectangle { + in property card; + in property current; + callback chosen(); + callback paste(); + callback stirred(); + border-radius: 10px; + background: current ? Theme.card-hover : Theme.card; + border-width: 1px; + border-color: current ? Theme.selected-edge : Theme.panel-border; + clip: true; + accessible-role: AccessibleRole.list-item; + accessible-label: card.title + ", " + card.body; + + touch := TouchArea { + clicked => { root.chosen(); } + double-clicked => { root.paste(); } + pointer-event(event) => { + if (event.kind == PointerEventKind.move) { + root.stirred(); + } + } + } + + VerticalLayout { + padding: 0px; + spacing: 0px; + + Rectangle { + vertical-stretch: 1; + clip: true; + background: #12141c; + if card.has-thumb : Image { + source: card.thumb; + width: parent.width; + height: parent.height; + image-fit: contain; + } + if !card.has-thumb : VerticalLayout { + alignment: center; + spacing: 5px; + HorizontalLayout { + alignment: center; + Image { + source: @image-url("../assets/icons/image.svg"); + colorize: card.broken ? Theme.danger : Theme.field-border; + width: 22px; + height: 22px; + } + } + Text { + text: card.alert; + color: Theme.danger; + font-size: 10px; + horizontal-alignment: center; + wrap: word-wrap; + } + } + } + + Rectangle { + height: 24px; + background: Theme.card; + HorizontalLayout { + padding-left: 8px; + padding-right: 8px; + spacing: 6px; + Text { + text: card.media-measures; + color: Theme.title; + font-size: 10.5px; + vertical-alignment: center; + overflow: elide; + } + Rectangle { horizontal-stretch: 1; } + Text { + text: card.source; + color: Theme.muted; + font-size: 10.5px; + vertical-alignment: center; + overflow: elide; + } + } + } + } +} diff --git a/crates/cp-panel/ui/panel.slint b/crates/cp-panel/ui/panel.slint index 2a73eed7..3a40306b 100644 --- a/crates/cp-panel/ui/panel.slint +++ b/crates/cp-panel/ui/panel.slint @@ -1,4 +1,5 @@ import { ListView } from "std-widgets.slint"; +import { GridCell } from "bodies_grid.slint"; import { Card, Chip, FormRow } from "types.slint"; import { Theme } from "theme.slint"; import { Words } from "words.slint"; @@ -15,6 +16,7 @@ export component Panel inherits Window { title: "CopyPaste"; in property <[Card]> cards; + in property <[int]> grid-lines; in property <[Chip]> chips; in property <[Chip]> tags; in property <[FormRow]> sheet-rows; @@ -446,7 +448,45 @@ export component Panel inherits Window { ); return accept; } + grid := ListView { + accessible-role: AccessibleRole.list; + vertical-scrollbar-policy: ScrollBarPolicy.always-off; + visible: root.layout == "image"; + height: root.layout == "image" ? parent.height : 0px; + for line[at] in root.grid-lines : HorizontalLayout { + height: Theme.cell; + spacing: 8px; + padding-left: 10px; + padding-right: 10px; + padding-bottom: 8px; + GridCell { + card: root.cards[line * 2]; + current: root.current == line * 2; + chosen => { + root.current = line * 2; + root.moved(line * 2); + } + paste => { root.paste(root.cards[line * 2].id); } + stirred => { root.stirred(line * 2); } + } + if line * 2 + 1 < root.cards.length : GridCell { + card: root.cards[line * 2 + 1]; + current: root.current == line * 2 + 1; + chosen => { + root.current = line * 2 + 1; + root.moved(line * 2 + 1); + } + paste => { root.paste(root.cards[line * 2 + 1].id); } + stirred => { root.stirred(line * 2 + 1); } + } + if line * 2 + 1 >= root.cards.length : Rectangle { + horizontal-stretch: 1; + } + } + } list := ListView { + visible: root.layout != "image"; + height: root.layout != "image" ? parent.height : 0px; accessible-role: AccessibleRole.list; vertical-scrollbar-policy: ScrollBarPolicy.always-off; animate content-y { duration: root.gliding ? 110ms : 0ms; easing: ease-out; } diff --git a/crates/cp-panel/ui/theme.slint b/crates/cp-panel/ui/theme.slint index cf5f49ee..36b43c3a 100644 --- a/crates/cp-panel/ui/theme.slint +++ b/crates/cp-panel/ui/theme.slint @@ -25,6 +25,7 @@ export global Theme { out property row-thumb: 146px; out property row-plain: 86px; out property row-json: 112px; + out property cell: 150px; out property row-found: 68px; out property row-frame: 50px; out property line: 18px; From d2b37e9295a236e715c436d79d03016eeb5a5d05 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 30 Sep 2026 23:40:39 -0300 Subject: [PATCH 15/22] feat(panel): a picture's sides come from the thumbnail, and what was never measured goes back in the queue --- crates/cp-panel/src/engine.rs | 44 ++++++++++++++++- crates/cp-panel/src/engine_test.rs | 19 +++++++- crates/cp-store/src/enrich.rs | 25 ++++++++++ crates/cp-store/src/store_listing_test.rs | 58 +++++++++++++++++++++++ 4 files changed, 143 insertions(+), 3 deletions(-) diff --git a/crates/cp-panel/src/engine.rs b/crates/cp-panel/src/engine.rs index 52d99a7e..f0a405a4 100644 --- a/crates/cp-panel/src/engine.rs +++ b/crates/cp-panel/src/engine.rs @@ -63,6 +63,7 @@ fn errands(db: &Path, stop: Arc) -> Option= SWEEPS_EVERY { @@ -76,6 +77,27 @@ fn errands(db: &Path, stop: Arc) -> Option { + for id in waiting { + if let Err(why) = store.enqueue(id, job) { + note(&format!("{id} was left without {job} queued: {why}")); + break; + } + } + } + Err(why) => note(&format!("what is unmeasured could not be looked at: {why}")), + } + } +} + fn sweep(store: &Store) { let Some(dir) = here::data_dir() else { return; @@ -144,7 +166,15 @@ fn first_waiting(store: &Store, job: &str, at: i64) -> Option { } fn thumbed(store: &Store, id: i64, at: i64, thumbs: &Path) { - let Some(png) = store.item(id).ok().flatten().as_ref().and_then(thumb_of) else { + let item = store.item(id).ok().flatten(); + if let Some(sides) = item.as_ref().and_then(|one| { + here::content_of(one, None) + .image + .and_then(cp_core::thumbnail::size_of) + }) { + measured_sides(store, id, sides.width, sides.height); + } + let Some(png) = item.as_ref().and_then(thumb_of) else { give_up(store, id, "thumb", "the thumbnail could not be drawn", at); return; }; @@ -286,6 +316,16 @@ fn thumb_of(item: &Item) -> Option> { here::thumb_of_file(Path::new(first), SIDE) } +fn measured_sides(store: &Store, id: i64, width: u32, height: u32) { + for (key, value) in [(crate::media::WIDTH, width), (crate::media::HEIGHT, height)] { + if value > 0 + && let Err(why) = store.set_meta(id, key, &value.to_string()) + { + note(&format!("{id} has a {key} nobody wrote down: {why}")); + } + } +} + fn written(dir: &Path, id: i64, png: &[u8]) -> Option { std::fs::create_dir_all(dir).ok()?; let _ = cp_store::restrict(dir, 0o700); @@ -395,7 +435,7 @@ fn name_for(at: i64, _item: &Item) -> String { fn jobs_for(item: &Item) -> &'static [&'static str] { match item.kind { - Some(Kind::Image) => &["thumb", "ocr"], + Some(Kind::Image) => &["thumb", "ocr", "media"], Some(Kind::Video) => &["thumb", "media"], Some(Kind::Audio) => &["media"], Some(Kind::Folder) if here::THUMBNAILS_FILES => &["thumb", "folder"], diff --git a/crates/cp-panel/src/engine_test.rs b/crates/cp-panel/src/engine_test.rs index cb9cec1a..0d263eda 100644 --- a/crates/cp-panel/src/engine_test.rs +++ b/crates/cp-panel/src/engine_test.rs @@ -13,6 +13,13 @@ fn text(what: &str) -> Item { } } +fn of_kind(kind: Kind) -> Item { + Item { + kind: Some(kind), + formats: Vec::new(), + } +} + fn image() -> Item { Item { kind: Some(Kind::Image), @@ -101,7 +108,17 @@ fn the_name_never_gives_away_what_was_copied() { #[test] fn only_what_can_be_enriched_is_queued() { - assert_eq!(jobs_for(&image()), ["thumb", "ocr"]); + assert_eq!( + jobs_for(&image()), + ["thumb", "ocr", "media"], + "a picture gets a thumbnail, its text read, and its sides measured" + ); + assert_eq!(jobs_for(&of_kind(Kind::Video)), ["thumb", "media"]); + assert_eq!( + jobs_for(&of_kind(Kind::Audio)), + ["media"], + "sound has no cover to draw" + ); assert!(jobs_for(&text("nothing to do here")).is_empty()); } diff --git a/crates/cp-store/src/enrich.rs b/crates/cp-store/src/enrich.rs index 12ed0e80..b455097b 100644 --- a/crates/cp-store/src/enrich.rs +++ b/crates/cp-store/src/enrich.rs @@ -36,6 +36,31 @@ impl Store { .collect()) } + pub fn missing_meta(&self, kinds: &[&str], key: &str, limit: usize) -> Result> { + if kinds.is_empty() { + return Ok(Vec::new()); + } + let marks = vec!["?"; kinds.len()].join(", "); + let sql = format!( + "SELECT id FROM items + WHERE deleted_at IS NULL AND kind IN ({marks}) + AND NOT EXISTS ( + SELECT 1 FROM item_meta + WHERE item_id = items.id AND key = ? + ) + ORDER BY modified_at DESC LIMIT ?" + ); + let mut bound: Vec> = Vec::with_capacity(kinds.len() + 2); + for kind in kinds { + bound.push(Box::new((*kind).to_owned())); + } + bound.push(Box::new(key.to_owned())); + bound.push(Box::new(i64::try_from(limit).unwrap_or(i64::MAX))); + let mut stmt = self.raw().prepare(&sql)?; + let rows = stmt.query_map(params_from_iter(bound.iter()), |row| row.get(0))?; + Ok(rows.collect::>()?) + } + pub fn set_meta(&self, id: i64, key: &str, value: &str) -> Result<()> { self.raw().execute( "INSERT INTO item_meta (item_id, key, value) VALUES (?1, ?2, ?3) diff --git a/crates/cp-store/src/store_listing_test.rs b/crates/cp-store/src/store_listing_test.rs index 23f04852..73343955 100644 --- a/crates/cp-store/src/store_listing_test.rs +++ b/crates/cp-store/src/store_listing_test.rs @@ -1043,3 +1043,61 @@ fn deleting_an_item_takes_its_meta_with_it() { let found = store.meta_for(&[rows[0].id], &["duration"]).expect("meta"); assert!(found.is_empty(), "erasing a row leaves no meta behind"); } + +#[test] +fn what_is_missing_a_measurement_can_be_found_without_touching_the_rest() { + let store = history(); + let rows = all(&store, &Filter::default()); + let kinds: Vec<&str> = vec!["text", "email", "color"]; + let of_those = rows + .iter() + .filter(|row| { + row.kind + .map(|kind| kinds.contains(&kind.as_str())) + .unwrap_or(false) + }) + .count(); + assert!(of_those > 1, "the fixture has a few of those kinds"); + + let waiting = store + .missing_meta(&kinds, "pixels-wide", 100) + .expect("asked"); + assert_eq!(waiting.len(), of_those, "nobody has been measured yet"); + + store + .set_meta(waiting[0], "pixels-wide", "1920") + .expect("measured"); + let after = store + .missing_meta(&kinds, "pixels-wide", 100) + .expect("asked"); + assert_eq!(after.len(), of_those - 1, "the measured one drops out"); + assert!(!after.contains(&waiting[0])); +} + +#[test] +fn a_kind_nobody_asked_about_is_never_queued() { + let store = history(); + let only_colour = store + .missing_meta(&["color"], "duration-ms", 100) + .expect("asked"); + let every_row = all(&store, &Filter::default()).len(); + assert!(only_colour.len() < every_row, "only the colours"); + assert!( + store + .missing_meta(&[], "duration-ms", 100) + .expect("asked") + .is_empty(), + "asking about no kind at all reads nothing" + ); +} + +#[test] +fn a_deleted_row_is_not_sent_back_to_be_measured() { + let store = history(); + let rows = all(&store, &Filter::default()); + store.mark_deleted(rows[0].id, 99).expect("deleted"); + let waiting = store + .missing_meta(&["text", "email", "color"], "pixels-wide", 100) + .expect("asked"); + assert!(!waiting.contains(&rows[0].id), "it is gone, not unmeasured"); +} From ca600bf1ff107fed2e93ee98ab8a4702ede2a52d Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 30 Sep 2026 23:50:40 -0300 Subject: [PATCH 16/22] feat(panel): an audio draws its own waveform as its thumbnail --- Cargo.lock | 156 ++++++++++++++++++++++++++- Cargo.toml | 1 + crates/cp-core/src/thumbnail.rs | 26 +++++ crates/cp-core/src/thumbnail_test.rs | 31 ++++++ crates/cp-panel/Cargo.toml | 1 + crates/cp-panel/src/engine.rs | 8 +- crates/cp-panel/src/engine_test.rs | 4 +- crates/cp-panel/src/main.rs | 1 + crates/cp-panel/src/wave.rs | 112 +++++++++++++++++++ crates/cp-panel/src/wave_test.rs | 108 +++++++++++++++++++ 10 files changed, 444 insertions(+), 4 deletions(-) create mode 100644 crates/cp-panel/src/wave.rs create mode 100644 crates/cp-panel/src/wave_test.rs diff --git a/Cargo.lock b/Cargo.lock index 1990d5d6..4d11367e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1065,6 +1065,7 @@ dependencies = [ "raw-window-handle", "slint", "slint-build", + "symphonia", "sys-locale", "tempfile", ] @@ -1207,7 +1208,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" dependencies = [ "quote", - "syn 2.0.119", + "syn 3.0.5", ] [[package]] @@ -1699,6 +1700,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -6565,6 +6572,153 @@ dependencies = [ "serde_json", ] +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "syn" version = "1.0.109" diff --git a/Cargo.toml b/Cargo.toml index 5b9ec83f..c5ec100b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ tauri-plugin-shell = "2.4" tauri-plugin-dialog = "2.8" tauri-plugin-updater = "2.13" tauri-plugin-global-shortcut = "2.4" +symphonia = { version = "0.5", default-features = false, features = ["mp3", "aac", "isomp4", "flac", "vorbis", "ogg", "wav", "pcm"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "tiff", "gif", "bmp", "webp"] } proptest = "1" tempfile = "3" diff --git a/crates/cp-core/src/thumbnail.rs b/crates/cp-core/src/thumbnail.rs index b13a81ff..abc780e2 100644 --- a/crates/cp-core/src/thumbnail.rs +++ b/crates/cp-core/src/thumbnail.rs @@ -19,6 +19,32 @@ pub fn size_of(bytes: &[u8]) -> Option { Some(Size { width, height }) } +pub fn of_wave(bars: &[u8], tallest: u8, width: u32, height: u32) -> Option> { + if bars.is_empty() || width == 0 || height == 0 || tallest == 0 { + return None; + } + let ink = image::Rgba([124u8, 134, 201, 255]); + let mut canvas = image::RgbaImage::new(width, height); + let gap = 1u32; + let span = width / u32::try_from(bars.len()).ok()?.max(1); + let thick = span.saturating_sub(gap).max(1); + for (at, bar) in bars.iter().enumerate() { + let left = u32::try_from(at).ok()? * span; + let tall = (u32::from(*bar) * (height - 1) / u32::from(tallest)).max(1); + let top = (height - tall) / 2; + for x in left..(left + thick).min(width) { + for y in top..(top + tall).min(height) { + canvas.put_pixel(x, y, ink); + } + } + } + let mut out = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(canvas) + .write_to(&mut out, image::ImageFormat::Png) + .ok()?; + Some(out.into_inner()) +} + pub fn of_image(bytes: &[u8], max_side: u32) -> Option> { let decoded = image::load_from_memory(bytes).ok()?; let longest_side = decoded.width().max(decoded.height()); diff --git a/crates/cp-core/src/thumbnail_test.rs b/crates/cp-core/src/thumbnail_test.rs index 8329c455..4ee1681f 100644 --- a/crates/cp-core/src/thumbnail_test.rs +++ b/crates/cp-core/src/thumbnail_test.rs @@ -121,3 +121,34 @@ fn scaling_to_the_size_it_already_has_changes_nothing() { "with a side equal to the maximum, thumbnail does not alter a single pixel" ); } + +#[test] +fn a_wave_is_drawn_as_a_png_of_the_size_it_was_asked_for() { + let bars: Vec = (0..48).map(|at| (at * 2) as u8).collect(); + let png = of_wave(&bars, 100, 384, 64).expect("drawn"); + let size = size_of(&png).expect("read back"); + assert_eq!((size.width, size.height), (384, 64)); +} + +#[test] +fn a_wave_with_nothing_to_draw_draws_nothing() { + assert!(of_wave(&[], 100, 384, 64).is_none()); + assert!( + of_wave(&[50], 0, 384, 64).is_none(), + "no tallest to scale by" + ); + assert!(of_wave(&[50], 100, 0, 64).is_none()); + assert!(of_wave(&[50], 100, 384, 0).is_none()); +} + +#[test] +fn a_silent_wave_still_draws_a_line_so_the_row_is_not_empty() { + let png = of_wave(&[0, 0, 0, 0], 100, 64, 16).expect("drawn"); + let decoded = image::load_from_memory(&png).expect("a png"); + let inked = decoded + .to_rgba8() + .pixels() + .filter(|one| one.0[3] > 0) + .count(); + assert!(inked > 0, "silence is a flat line, not an empty picture"); +} diff --git a/crates/cp-panel/Cargo.toml b/crates/cp-panel/Cargo.toml index 9883bd02..adba179e 100644 --- a/crates/cp-panel/Cargo.toml +++ b/crates/cp-panel/Cargo.toml @@ -12,6 +12,7 @@ cp-config.workspace = true cp-core.workspace = true cp-store.workspace = true slint.workspace = true +symphonia.workspace = true sys-locale.workspace = true raw-window-handle.workspace = true diff --git a/crates/cp-panel/src/engine.rs b/crates/cp-panel/src/engine.rs index f0a405a4..9f9578ef 100644 --- a/crates/cp-panel/src/engine.rs +++ b/crates/cp-panel/src/engine.rs @@ -46,6 +46,8 @@ impl Drop for Engine { } const SIDE: i32 = cp_core::thumbnail::MAX_SIDE as i32; +const WAVE_WIDE: u32 = 384; +const WAVE_HIGH: u32 = 64; const NAP: std::time::Duration = std::time::Duration::from_millis(400); const LATER: i64 = 60_000; const SWEEPS_EVERY: std::time::Duration = std::time::Duration::from_secs(3_600); @@ -313,6 +315,10 @@ fn thumb_of(item: &Item) -> Option> { return cp_core::thumbnail::of_image(image, cp_core::thumbnail::MAX_SIDE); } let first = content.paths.first()?; + if item.kind == Some(Kind::Audio) { + let bars = crate::wave::bars_of(Path::new(first))?; + return cp_core::thumbnail::of_wave(&bars, crate::wave::TALLEST, WAVE_WIDE, WAVE_HIGH); + } here::thumb_of_file(Path::new(first), SIDE) } @@ -437,7 +443,7 @@ fn jobs_for(item: &Item) -> &'static [&'static str] { match item.kind { Some(Kind::Image) => &["thumb", "ocr", "media"], Some(Kind::Video) => &["thumb", "media"], - Some(Kind::Audio) => &["media"], + Some(Kind::Audio) => &["thumb", "media"], Some(Kind::Folder) if here::THUMBNAILS_FILES => &["thumb", "folder"], Some(Kind::Folder) => &["folder"], Some(Kind::File) if here::THUMBNAILS_FILES => &["thumb"], diff --git a/crates/cp-panel/src/engine_test.rs b/crates/cp-panel/src/engine_test.rs index 0d263eda..a3352db7 100644 --- a/crates/cp-panel/src/engine_test.rs +++ b/crates/cp-panel/src/engine_test.rs @@ -116,8 +116,8 @@ fn only_what_can_be_enriched_is_queued() { assert_eq!(jobs_for(&of_kind(Kind::Video)), ["thumb", "media"]); assert_eq!( jobs_for(&of_kind(Kind::Audio)), - ["media"], - "sound has no cover to draw" + ["thumb", "media"], + "sound has no cover, so its thumbnail is its own waveform" ); assert!(jobs_for(&text("nothing to do here")).is_empty()); } diff --git a/crates/cp-panel/src/main.rs b/crates/cp-panel/src/main.rs index 7c756f05..5e5d5f4b 100644 --- a/crates/cp-panel/src/main.rs +++ b/crates/cp-panel/src/main.rs @@ -15,6 +15,7 @@ mod papers; mod say; mod shape; mod view; +mod wave; slint::include_modules!(); diff --git a/crates/cp-panel/src/wave.rs b/crates/cp-panel/src/wave.rs new file mode 100644 index 00000000..6a9ce798 --- /dev/null +++ b/crates/cp-panel/src/wave.rs @@ -0,0 +1,112 @@ +use std::path::Path; +use symphonia::core::audio::AudioBufferRef; +use symphonia::core::codecs::DecoderOptions; +use symphonia::core::formats::FormatOptions; +use symphonia::core::io::MediaSourceStream; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; + +pub const BARS: usize = 48; +pub const LISTEN_FOR: std::time::Duration = std::time::Duration::from_secs(30); +pub const TALLEST: u8 = 100; + +const _: () = assert!(BARS >= 8); +const _: () = assert!(LISTEN_FOR.as_secs() >= 1); + +pub fn bars_of(path: &Path) -> Option> { + let file = std::fs::File::open(path).ok()?; + let stream = MediaSourceStream::new(Box::new(file), Default::default()); + let mut hint = Hint::new(); + if let Some(after) = path.extension().and_then(|one| one.to_str()) { + hint.with_extension(after); + } + let probed = symphonia::default::get_probe() + .format( + &hint, + stream, + &FormatOptions::default(), + &MetadataOptions::default(), + ) + .ok()?; + let mut reader = probed.format; + let track = reader + .tracks() + .iter() + .find(|one| one.codec_params.channels.is_some())?; + let wanted = track.id; + let rate = track.codec_params.sample_rate.unwrap_or(44_100); + let mut decoder = symphonia::default::get_codecs() + .make(&track.codec_params, &DecoderOptions::default()) + .ok()?; + + let ceiling = u64::from(rate) * LISTEN_FOR.as_secs(); + let mut heard = 0u64; + let mut peaks: Vec = Vec::new(); + while heard < ceiling { + let packet = match reader.next_packet() { + Ok(packet) => packet, + Err(_) => break, + }; + if packet.track_id() != wanted { + continue; + } + let Ok(decoded) = decoder.decode(&packet) else { + continue; + }; + heard += decoded.frames() as u64; + peaks.push(loudest(&decoded)); + } + (!peaks.is_empty()).then(|| shrunk(&peaks)) +} + +fn loudest(decoded: &AudioBufferRef<'_>) -> f32 { + use symphonia::core::audio::Signal; + match decoded { + AudioBufferRef::F32(buffer) => buffer + .chan(0) + .iter() + .fold(0.0f32, |most, one| most.max(one.abs())), + AudioBufferRef::S32(buffer) => { + let scale = i32::MAX as f32; + buffer + .chan(0) + .iter() + .fold(0.0f32, |most, one| most.max((*one as f32 / scale).abs())) + } + AudioBufferRef::S16(buffer) => { + let scale = i16::MAX as f32; + buffer + .chan(0) + .iter() + .fold(0.0f32, |most, one| most.max((*one as f32 / scale).abs())) + } + AudioBufferRef::U8(buffer) => buffer.chan(0).iter().fold(0.0f32, |most, one| { + most.max(((f32::from(*one) - 128.0) / 128.0).abs()) + }), + _ => 0.0, + } +} + +pub fn shrunk(peaks: &[f32]) -> Vec { + if peaks.is_empty() { + return Vec::new(); + } + let tallest = peaks.iter().fold(0.0f32, |most, one| most.max(*one)); + let scale = if tallest > 0.0 { tallest } else { 1.0 }; + (0..BARS) + .map(|bar| { + let from = bar * peaks.len() / BARS; + let to = ((bar + 1) * peaks.len() / BARS) + .max(from + 1) + .min(peaks.len()); + let slice = &peaks[from..to]; + let loudest = slice.iter().fold(0.0f32, |most, one| most.max(*one)); + let height = (loudest / scale * f32::from(TALLEST)).round(); + height.clamp(0.0, f32::from(TALLEST)) as u8 + }) + .collect() +} + +#[cfg(test)] +#[path = "wave_test.rs"] +mod tests; diff --git a/crates/cp-panel/src/wave_test.rs b/crates/cp-panel/src/wave_test.rs new file mode 100644 index 00000000..e3549eb0 --- /dev/null +++ b/crates/cp-panel/src/wave_test.rs @@ -0,0 +1,108 @@ +use super::*; + +#[test] +fn nothing_heard_is_no_wave_at_all() { + assert!(shrunk(&[]).is_empty()); +} + +#[test] +fn a_wave_always_has_the_same_number_of_bars() { + for how_many in [1usize, 2, 7, BARS, BARS + 1, 500, 3_000] { + let peaks: Vec = (0..how_many).map(|at| (at % 10) as f32 / 10.0).collect(); + assert_eq!(shrunk(&peaks).len(), BARS, "{how_many} peaks"); + } +} + +#[test] +fn the_loudest_moment_reaches_the_top_and_nothing_goes_over() { + let peaks: Vec = (0..BARS).map(|at| at as f32 / BARS as f32).collect(); + let bars = shrunk(&peaks); + assert_eq!(*bars.last().expect("a bar"), TALLEST); + for bar in &bars { + assert!(*bar <= TALLEST, "{bar} is taller than the tallest"); + } +} + +#[test] +fn silence_draws_a_flat_line_instead_of_dividing_by_nothing() { + let bars = shrunk(&[0.0, 0.0, 0.0, 0.0]); + assert_eq!(bars.len(), BARS); + assert!( + bars.iter().all(|bar| *bar == 0), + "silence is flat, not loud" + ); +} + +#[test] +fn a_quiet_recording_is_lifted_so_its_shape_can_be_seen() { + let bars = shrunk(&[0.01, 0.002, 0.01]); + assert_eq!( + *bars.iter().max().expect("a bar"), + TALLEST, + "scaled to its own loudest" + ); +} + +#[test] +fn every_bar_comes_from_its_own_slice_of_the_sound() { + let mut peaks = vec![0.0f32; BARS * 4]; + peaks[0] = 1.0; + let bars = shrunk(&peaks); + assert_eq!(bars[0], TALLEST, "the shout is in the first bar"); + assert!(bars[1..].iter().all(|bar| *bar == 0), "and nowhere else"); +} + +#[test] +fn what_is_not_audio_at_all_gives_no_wave() { + let dir = std::env::temp_dir().join(format!("cp-wave-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("made"); + let path = dir.join("no-es-audio.wav"); + std::fs::write(&path, b"ni de lejos un riff").expect("wrote"); + assert!(bars_of(&path).is_none()); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn a_real_wav_is_read_and_its_shape_survives() { + let dir = std::env::temp_dir().join(format!("cp-wave-ok-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("made"); + let path = dir.join("tono.wav"); + std::fs::write(&path, tone()).expect("wrote"); + + let bars = bars_of(&path).expect("a wave"); + assert_eq!(bars.len(), BARS); + assert_eq!(*bars.iter().max().expect("a bar"), TALLEST); + assert!( + bars[..BARS / 3].iter().all(|bar| *bar == 0), + "the file starts in silence and the wave says so" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +fn tone() -> Vec { + const RATE: u32 = 8_000; + const FRAMES: u32 = RATE * 2; + let mut samples: Vec = Vec::with_capacity(FRAMES as usize * 2); + for at in 0..FRAMES { + let loud = if at < FRAMES / 2 { 0.0 } else { 0.8 }; + let turn = (at as f32) * 440.0 * std::f32::consts::TAU / RATE as f32; + let value = (turn.sin() * loud * f32::from(i16::MAX)) as i16; + samples.extend_from_slice(&value.to_le_bytes()); + } + let data = u32::try_from(samples.len()).expect("fits"); + let mut out = Vec::with_capacity(samples.len() + 44); + out.extend_from_slice(b"RIFF"); + out.extend_from_slice(&(36 + data).to_le_bytes()); + out.extend_from_slice(b"WAVEfmt "); + out.extend_from_slice(&16u32.to_le_bytes()); + out.extend_from_slice(&1u16.to_le_bytes()); + out.extend_from_slice(&1u16.to_le_bytes()); + out.extend_from_slice(&RATE.to_le_bytes()); + out.extend_from_slice(&(RATE * 2).to_le_bytes()); + out.extend_from_slice(&2u16.to_le_bytes()); + out.extend_from_slice(&16u16.to_le_bytes()); + out.extend_from_slice(b"data"); + out.extend_from_slice(&data.to_le_bytes()); + out.extend_from_slice(&samples); + out +} From 614c6caec2363a9d1921e683db0e4f13cdc07fb9 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 30 Sep 2026 23:59:05 -0300 Subject: [PATCH 17/22] test(probe): ask the shell what a document publishes instead of guessing --- .github/oversized.txt | 2 +- crates/cp-win-sys/src/media.rs | 47 ++++++++++-- crates/cp-win/examples/probe/battery.rs | 98 ++++++++++++++++++++----- 3 files changed, 122 insertions(+), 25 deletions(-) diff --git a/.github/oversized.txt b/.github/oversized.txt index 6e6e1462..81051c70 100644 --- a/.github/oversized.txt +++ b/.github/oversized.txt @@ -1,3 +1,3 @@ 357 crates/cp-panel/src/app.rs::wire the panel callbacks, one after another: splitting it would only move the list -986 crates/cp-win/examples/probe/battery.rs::main a diagnostic main that walks every invariant of its platform in one pass +972 crates/cp-win/examples/probe/battery.rs::main a diagnostic main that walks every invariant of its platform in one pass 1037 crates/cp-mac/examples/probe/battery.rs::main the same battery, for the other platform diff --git a/crates/cp-win-sys/src/media.rs b/crates/cp-win-sys/src/media.rs index 53d8a8bc..f965a073 100644 --- a/crates/cp-win-sys/src/media.rs +++ b/crates/cp-win-sys/src/media.rs @@ -3,7 +3,8 @@ use windows::Win32::Foundation::PROPERTYKEY; use windows::Win32::System::Com::CoTaskMemFree; use windows::Win32::System::Com::StructuredStorage::{PROPVARIANT, PropVariantToStringAlloc}; use windows::Win32::UI::Shell::PropertiesSystem::{ - GPS_DEFAULT, IPropertyStore, SHGetPropertyStoreFromParsingName, + GETPROPERTYSTOREFLAGS, GPS_DEFAULT, GPS_OPENSLOWITEM, IPropertyStore, PSGetNameFromPropertyKey, + SHGetPropertyStoreFromParsingName, }; use windows::core::{GUID, PCWSTR}; @@ -57,21 +58,22 @@ const PKEY_MUSIC_ALBUM: PROPERTYKEY = PROPERTYKEY { pid: 4, }; -pub fn info_for(path: &std::path::Path) -> Option { +fn store_for(path: &std::path::Path, how: GETPROPERTYSTOREFLAGS) -> Option { if !path.exists() { return None; } - let _apartment = crate::com::Apartment::enter(); let absolute = crate::com::shell_path(path)?; let wide: Vec = absolute .as_os_str() .encode_wide() .chain(std::iter::once(0)) .collect(); + unsafe { SHGetPropertyStoreFromParsingName(PCWSTR(wide.as_ptr()), None, how) }.ok() +} - let store: IPropertyStore = - unsafe { SHGetPropertyStoreFromParsingName(PCWSTR(wide.as_ptr()), None, GPS_DEFAULT) } - .ok()?; +pub fn info_for(path: &std::path::Path) -> Option { + let _apartment = crate::com::Apartment::enter(); + let store = store_for(path, GPS_DEFAULT)?; let info = MediaInfo { duration: hundred_nanos(&store, PKEY_MEDIA_DURATION), @@ -85,6 +87,39 @@ pub fn info_for(path: &std::path::Path) -> Option { (!info.is_empty()).then_some(info) } +pub struct Published { + pub how_many: u32, + pub said: Vec<(String, String)>, +} + +pub fn everything_about(path: &std::path::Path) -> Option { + let _apartment = crate::com::Apartment::enter(); + let store = store_for(path, GPS_OPENSLOWITEM)?; + let how_many = unsafe { store.GetCount() }.unwrap_or(0); + let mut said = Vec::new(); + for at in 0..how_many { + let mut key = PROPERTYKEY::default(); + if unsafe { store.GetAt(at, &raw mut key) }.is_err() { + continue; + } + let name = named(key).unwrap_or_else(|| format!("{:?}:{}", key.fmtid, key.pid)); + if let Some(value) = text(&store, key) { + said.push((name, value)); + } + } + Some(Published { how_many, said }) +} + +fn named(key: PROPERTYKEY) -> Option { + let raw = unsafe { PSGetNameFromPropertyKey(&key) }.ok()?; + if raw.is_null() { + return None; + } + let name = unsafe { raw.to_string() }.ok(); + unsafe { CoTaskMemFree(Some(raw.as_ptr().cast())) }; + name +} + fn value_of(store: &IPropertyStore, key: PROPERTYKEY) -> Option { unsafe { store.GetValue(&key) }.ok() } diff --git a/crates/cp-win/examples/probe/battery.rs b/crates/cp-win/examples/probe/battery.rs index 60330796..1a72064e 100644 --- a/crates/cp-win/examples/probe/battery.rs +++ b/crates/cp-win/examples/probe/battery.rs @@ -18,8 +18,8 @@ use cp_win_sys::window::EditWindow; use cp_win_sys::writing::{Written, text_of, utf16_of}; use cp_win_sys::{files, media, ocr, source, thumbnail}; -const CASES: u32 = 47; -const MAY_SKIP: &[&str] = &["B2", "B4", "E1", "E2", "L1", "P1", "P2"]; +const CASES: u32 = 48; +const MAY_SKIP: &[&str] = &["B2", "B4", "E1", "E2", "L1", "P1", "P2", "Q1"]; const SKIPPED: &str = "skipped: "; const VIRTUAL_FILE_NAME: &str = "virtual attachment.txt"; @@ -375,6 +375,82 @@ fn what_is_read_inside_an_image(b: &mut Battery) { }); } +fn what_the_shell_knows_about_a_document(b: &mut Battery) { + b.group("Q · Lo que el shell sabe de un documento"); + + b.case_or_skip( + "Q1", + "un archivo de oficina publica sus propias medidas", + || { + let Some(said) = std::env::var_os("CP_PROBE_DOC") else { + return Err(format!( + "{SKIPPED}pon CP_PROBE_DOC con la ruta de un .xlsx, .docx o .pptx real" + )); + }; + let path = std::path::PathBuf::from(said); + if !path.exists() { + return Err(format!("{SKIPPED}{} no está ahí", path.display())); + } + let Some(found) = cp_win_sys::media::everything_about(&path) else { + return Err("no se pudo abrir la tienda de propiedades de ese archivo".into()); + }; + if found.how_many == 0 { + return Err("el shell abre el archivo y no publica ni una propiedad".into()); + } + println!( + " {} publicadas, {} legibles como texto:", + found.how_many, + found.said.len() + ); + for (name, value) in &found.said { + let cut: String = value.chars().take(70).collect(); + println!(" {name} = {cut}"); + } + let counted: Vec<&str> = found + .said + .iter() + .map(|(name, _)| name.as_str()) + .filter(|name| { + name.contains("PageCount") + || name.contains("SlideCount") + || name.contains("WordCount") + || name.contains("LineCount") + || name.contains("ParagraphCount") + }) + .collect(); + if counted.is_empty() { + return Err( + "ninguna propiedad cuenta páginas, palabras ni diapositivas: la vista de \ + oficina tendrá que leer el zip" + .into(), + ); + } + println!(" cuentan: {}", counted.join(", ")); + Ok(()) + }, + ); +} + +fn what_hangs_is_never_asked_for(b: &mut Battery) { + b.group("C · Los formatos que cuelgan no se piden"); + + b.case("C1", "nada marcado como presencia se llega a pedir", || { + let clipboard = Clipboard::open().ok_or("no abrió")?; + let ids = clipboard.offered(); + let risky: Vec = ids + .iter() + .map(|id| name_of(*id)) + .filter(|name| CATALOG.decide(name) != Take::Payload) + .collect(); + println!( + " {} anotados sin pedir: {}", + risky.len(), + risky.join(", ") + ); + Ok(()) + }); +} + fn main() -> std::process::ExitCode { println!("\nCore battery against the Windows clipboard"); @@ -1199,23 +1275,9 @@ fn main() -> std::process::ExitCode { opened }); - b.group("C · Los formatos que cuelgan no se piden"); + what_the_shell_knows_about_a_document(&mut b); - b.case("C1", "nada marcado como presencia se llega a pedir", || { - let clipboard = Clipboard::open().ok_or("no abrió")?; - let ids = clipboard.offered(); - let risky: Vec = ids - .iter() - .map(|id| name_of(*id)) - .filter(|name| CATALOG.decide(name) != Take::Payload) - .collect(); - println!( - " {} anotados sin pedir: {}", - risky.len(), - risky.join(", ") - ); - Ok(()) - }); + what_hangs_is_never_asked_for(&mut b); b.group("D · El vigilante"); From d232de0003aad2ccf393529d4a6e8e53de8517c8 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Thu, 1 Oct 2026 00:07:06 -0300 Subject: [PATCH 18/22] feat(panel): each view offers its two ways of looking at it --- crates/cp-panel/src/app.rs | 28 ++++++++- crates/cp-panel/src/group.rs | 3 + crates/cp-panel/src/group_test.rs | 10 ++++ crates/cp-panel/src/layout.rs | 2 +- crates/cp-panel/src/layout_test.rs | 9 +-- crates/cp-panel/src/main.rs | 1 + crates/cp-panel/src/model.rs | 2 +- crates/cp-panel/src/model_test.rs | 16 ++++++ crates/cp-panel/src/ways.rs | 92 ++++++++++++++++++++++++++++++ crates/cp-panel/src/ways_test.rs | 91 +++++++++++++++++++++++++++++ crates/cp-panel/ui/cards.slint | 16 +++--- crates/cp-panel/ui/panel.slint | 53 +++++++++++++++-- crates/cp-panel/ui/types.slint | 5 ++ crates/cp-store/src/enrich.rs | 2 +- 14 files changed, 309 insertions(+), 21 deletions(-) create mode 100644 crates/cp-panel/src/ways.rs create mode 100644 crates/cp-panel/src/ways_test.rs diff --git a/crates/cp-panel/src/app.rs b/crates/cp-panel/src/app.rs index 1a20dd69..a39c583a 100644 --- a/crates/cp-panel/src/app.rs +++ b/crates/cp-panel/src/app.rs @@ -37,6 +37,7 @@ struct State { query: String, tags: Vec, pinned: bool, + way: String, rows: Option>, options: Options, metrics: Metrics, @@ -88,6 +89,7 @@ impl App { query: String::new(), tags: Vec::new(), pinned: false, + way: String::new(), rows: None, options, metrics, @@ -175,6 +177,15 @@ impl App { } fn wire_chrome(&self, panel: &Panel) { + let ui = self.ui.clone(); + let state = self.state.clone(); + panel.on_way_chosen(move |key| { + state.borrow_mut().way = key.to_string(); + let Some(ui) = ui.upgrade() else { + return; + }; + refresh(&ui, &state); + }); let ui = self.ui.clone(); panel.on_ask_settings(move || { let Some(ui) = ui.upgrade() else { @@ -694,6 +705,10 @@ fn spawn_counter( panel.set_pinned_count(anchored.into()); panel.set_pinned_on(only_anchored); panel.set_layout(asked.as_str().into()); + let ways = crate::ways::ways_of(asked); + let english = crate::say::in_english(); + panel.set_way_a(said_way(ways.first(), english)); + panel.set_way_b(said_way(ways.get(1), english)); }); } }); @@ -729,7 +744,8 @@ fn filter_of(state: &State) -> Filter { if state.pinned { filter.pinned_only = true; } - if crate::layout::layout_for(&filter.kinds).groups() { + let layout = crate::layout::layout_for(&filter.kinds); + if layout.groups() && !crate::ways::chosen(layout, &state.way).recent { filter.order = cp_store::Order::ByGroup; } filter @@ -759,6 +775,16 @@ fn tags_of(state: &State) -> Vec { .collect() } +fn said_way(way: Option<&crate::ways::Way>, english: bool) -> crate::WayPill { + match way { + Some(way) => crate::WayPill { + key: way.key.into(), + says: crate::ways::label_of(*way, english).into(), + }, + None => crate::WayPill::default(), + } +} + fn keys_of(filter: &Filter) -> Vec { filter .kinds diff --git a/crates/cp-panel/src/group.rs b/crates/cp-panel/src/group.rs index ce393bd5..82372238 100644 --- a/crates/cp-panel/src/group.rs +++ b/crates/cp-panel/src/group.rs @@ -12,6 +12,9 @@ pub fn key_of(kind: Option, preview: &str) -> String { .map(|one| one.domain) .unwrap_or_default(), Some(Kind::Folder) => crate::folder::unit_of(preview), + Some(Kind::File) => crate::papers::papers_of(preview) + .map(|one| one.format) + .unwrap_or_default(), _ => return String::new(), }; if found.is_empty() { diff --git a/crates/cp-panel/src/group_test.rs b/crates/cp-panel/src/group_test.rs index 20459101..0ffe740e 100644 --- a/crates/cp-panel/src/group_test.rs +++ b/crates/cp-panel/src/group_test.rs @@ -18,6 +18,16 @@ fn a_folder_is_grouped_by_its_unit() { assert_eq!(key_of(Some(Kind::Folder), r"D:\Mario\Orca"), "D:"); } +#[test] +fn an_office_file_is_grouped_by_its_format() { + assert_eq!(key_of(Some(Kind::File), r"D:\x\cuentas.xlsx"), "XLSX"); + assert_eq!( + key_of(Some(Kind::File), r"D:\x\sin_extension"), + UNKNOWN, + "no format to group by" + ); +} + #[test] fn a_kind_that_does_not_group_gets_an_empty_key() { assert_eq!(key_of(Some(Kind::Text), "hola"), ""); diff --git a/crates/cp-panel/src/layout.rs b/crates/cp-panel/src/layout.rs index 587120ba..194cb986 100644 --- a/crates/cp-panel/src/layout.rs +++ b/crates/cp-panel/src/layout.rs @@ -26,7 +26,7 @@ impl Layout { ]; pub fn groups(self) -> bool { - matches!(self, Layout::Link | Layout::Folder) + matches!(self, Layout::Link | Layout::Folder | Layout::Papers) } pub fn as_str(self) -> &'static str { diff --git a/crates/cp-panel/src/layout_test.rs b/crates/cp-panel/src/layout_test.rs index 8e898384..cfdf018e 100644 --- a/crates/cp-panel/src/layout_test.rs +++ b/crates/cp-panel/src/layout_test.rs @@ -94,11 +94,12 @@ fn no_two_layouts_answer_to_the_same_name() { } #[test] -fn only_the_two_views_the_maquette_grouped_ask_for_grouping() { - assert!(Layout::Link.groups()); - assert!(Layout::Folder.groups()); +fn only_the_views_the_maquette_grouped_ask_for_grouping() { + assert!(Layout::Link.groups(), "by domain"); + assert!(Layout::Folder.groups(), "by drive"); + assert!(Layout::Papers.groups(), "by format"); for one in Layout::ALL { - if matches!(one, Layout::Link | Layout::Folder) { + if matches!(one, Layout::Link | Layout::Folder | Layout::Papers) { continue; } assert!(!one.groups(), "{one:?} was drawn as one flat list"); diff --git a/crates/cp-panel/src/main.rs b/crates/cp-panel/src/main.rs index 5e5d5f4b..1bddc40d 100644 --- a/crates/cp-panel/src/main.rs +++ b/crates/cp-panel/src/main.rs @@ -16,6 +16,7 @@ mod say; mod shape; mod view; mod wave; +mod ways; slint::include_modules!(); diff --git a/crates/cp-panel/src/model.rs b/crates/cp-panel/src/model.rs index 2a96ecc5..028bf0ab 100644 --- a/crates/cp-panel/src/model.rs +++ b/crates/cp-panel/src/model.rs @@ -21,7 +21,7 @@ pub struct Metrics { const EDGE: f32 = 8.0; pub fn heads_group(filter: &Filter, rows: &[Listed], index: usize) -> bool { - if !crate::layout::layout_for(&filter.kinds).groups() { + if filter.order != cp_store::Order::ByGroup { return false; } let Some(row) = rows.get(index) else { diff --git a/crates/cp-panel/src/model_test.rs b/crates/cp-panel/src/model_test.rs index 4b898a85..3d9854c3 100644 --- a/crates/cp-panel/src/model_test.rs +++ b/crates/cp-panel/src/model_test.rs @@ -290,6 +290,7 @@ fn grouped(group: &str) -> cp_store::Listed { fn the_first_row_of_a_group_is_the_one_that_heads_it() { let linked = Filter { kinds: vec![cp_core::kind::Kind::Link], + order: cp_store::Order::ByGroup, ..Default::default() }; let rows = [ @@ -314,10 +315,25 @@ fn a_view_that_does_not_group_draws_no_heading_at_all() { assert!(!heads_group(&general, &rows, 1)); } +#[test] +fn choosing_the_newest_way_drops_the_headings_with_the_grouping() { + let newest = Filter { + kinds: vec![cp_core::kind::Kind::Link], + order: cp_store::Order::Recent, + ..Default::default() + }; + let rows = [grouped("docs.rs"), grouped("github.com")]; + assert!( + !heads_group(&newest, &rows, 0), + "a heading over a list that is not grouped would be a lie" + ); +} + #[test] fn a_group_nobody_could_name_heads_nothing() { let linked = Filter { kinds: vec![cp_core::kind::Kind::Link], + order: cp_store::Order::ByGroup, ..Default::default() }; let rows = [grouped(crate::group::UNKNOWN), grouped("")]; diff --git a/crates/cp-panel/src/ways.rs b/crates/cp-panel/src/ways.rs new file mode 100644 index 00000000..390fae0c --- /dev/null +++ b/crates/cp-panel/src/ways.rs @@ -0,0 +1,92 @@ +use crate::layout::Layout; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Way { + pub key: &'static str, + pub es: &'static str, + pub en: &'static str, + pub recent: bool, +} + +const KEYS: Way = Way { + key: "keys", + es: "Claves", + en: "Keys", + recent: false, +}; +const RAW: Way = Way { + key: "raw", + es: "Crudo", + en: "Raw", + recent: false, +}; +const GRID: Way = Way { + key: "grid", + es: "Rejilla", + en: "Grid", + recent: false, +}; +const ROWS: Way = Way { + key: "rows", + es: "Lista", + en: "List", + recent: false, +}; +const COVER: Way = Way { + key: "cover", + es: "Carátula", + en: "Cover", + recent: false, +}; +const WAVE: Way = Way { + key: "wave", + es: "Onda", + en: "Wave", + recent: false, +}; +const TIGHT: Way = Way { + key: "tight", + es: "Compacto", + en: "Compact", + recent: false, +}; +const BY_GROUP: Way = Way { + key: "by-group", + es: "Por grupo", + en: "By group", + recent: false, +}; +const NEWEST: Way = Way { + key: "newest", + es: "Recientes", + en: "Newest", + recent: true, +}; + +pub fn ways_of(layout: Layout) -> &'static [Way] { + match layout { + Layout::Everything => &[], + Layout::Json => &[KEYS, RAW], + Layout::Image => &[GRID, ROWS], + Layout::Video => &[COVER, TIGHT], + Layout::Audio => &[WAVE, TIGHT], + Layout::Link | Layout::Folder | Layout::Papers => &[BY_GROUP, NEWEST], + } +} + +pub fn chosen(layout: Layout, key: &str) -> Way { + let ways = ways_of(layout); + ways.iter() + .find(|one| one.key == key) + .copied() + .or_else(|| ways.first().copied()) + .unwrap_or(NEWEST) +} + +pub fn label_of(way: Way, english: bool) -> &'static str { + crate::say::pick_in(english, way.es, way.en) +} + +#[cfg(test)] +#[path = "ways_test.rs"] +mod tests; diff --git a/crates/cp-panel/src/ways_test.rs b/crates/cp-panel/src/ways_test.rs new file mode 100644 index 00000000..10e65596 --- /dev/null +++ b/crates/cp-panel/src/ways_test.rs @@ -0,0 +1,91 @@ +use super::*; + +#[test] +fn the_general_view_has_no_second_control_to_offer() { + assert!(ways_of(Layout::Everything).is_empty()); +} + +#[test] +fn every_other_view_offers_exactly_two_ways_of_looking() { + for layout in Layout::ALL { + if layout == Layout::Everything { + continue; + } + assert_eq!( + ways_of(layout).len(), + 2, + "{layout:?} needs two ways, not one and not three" + ); + } +} + +#[test] +fn the_three_grouped_views_share_the_same_pair() { + let grouped = ways_of(Layout::Link); + assert_eq!(ways_of(Layout::Folder), grouped); + assert_eq!(ways_of(Layout::Papers), grouped); + assert_eq!(grouped[0].key, "by-group"); + assert_eq!(grouped[1].key, "newest"); +} + +#[test] +fn only_the_newest_way_asks_to_stop_grouping() { + for layout in Layout::ALL { + for way in ways_of(layout) { + if way.key == "newest" { + assert!(way.recent, "{layout:?} newest has to drop the grouping"); + } else { + assert!(!way.recent, "{layout:?} {} is not an ordering", way.key); + } + } + } +} + +#[test] +fn the_first_way_is_what_a_view_opens_with() { + assert_eq!(chosen(Layout::Json, "nada de eso").key, "keys"); + assert_eq!(chosen(Layout::Image, "").key, "grid"); + assert_eq!(chosen(Layout::Link, "inventada").key, "by-group"); +} + +#[test] +fn a_way_that_was_chosen_comes_back_as_itself() { + assert_eq!(chosen(Layout::Json, "raw").key, "raw"); + assert_eq!(chosen(Layout::Image, "rows").key, "rows"); + assert_eq!(chosen(Layout::Audio, "tight").key, "tight"); + assert_eq!(chosen(Layout::Folder, "newest").key, "newest"); +} + +#[test] +fn a_way_from_another_view_is_not_honoured_in_this_one() { + assert_eq!( + chosen(Layout::Json, "grid").key, + "keys", + "the grid belongs to images, so json falls back to its own first" + ); +} + +#[test] +fn nothing_is_left_untranslated() { + for layout in Layout::ALL { + for way in ways_of(layout) { + assert!(!way.es.is_empty(), "{} has no spanish", way.key); + assert!(!way.en.is_empty(), "{} has no english", way.key); + assert_ne!(way.es, way.en, "{} was left in one tongue", way.key); + assert_eq!(label_of(*way, false), way.es); + assert_eq!(label_of(*way, true), way.en); + } + } +} + +#[test] +fn no_view_offers_the_same_way_twice() { + for layout in Layout::ALL { + let ways = ways_of(layout); + for (at, one) in ways.iter().enumerate() { + for other in &ways[at + 1..] { + assert_ne!(one.key, other.key, "{layout:?} repeats {}", one.key); + } + } + } +} diff --git a/crates/cp-panel/ui/cards.slint b/crates/cp-panel/ui/cards.slint index ea9236e6..ce79012a 100644 --- a/crates/cp-panel/ui/cards.slint +++ b/crates/cp-panel/ui/cards.slint @@ -164,8 +164,10 @@ export component Tag inherits Rectangle { export component CardView inherits Rectangle { in property card; in property layout; - property special: layout == "json" || layout == "video" || layout == "audio" - || layout == "papers" || layout == "folder" || layout == "link"; + in property way; + property plain-way: way == "raw" || way == "tight"; + property special: !plain-way && (layout == "json" || layout == "video" + || layout == "audio" || layout == "papers" || layout == "folder" || layout == "link"); in property current; in property open; in property shown-here; @@ -235,23 +237,23 @@ export component CardView inherits Rectangle { Text { text: card.tail; color: Theme.body; font-size: 12.5px; overflow: elide; horizontal-stretch: 1; vertical-alignment: center; } } - if !card.has-thumb && !open && layout == "json" : JsonBody { + if !card.has-thumb && !open && !plain-way && layout == "json" : JsonBody { card: card; } - if !card.has-thumb && !open && (layout == "video" || layout == "audio") : MediaBody { + if !card.has-thumb && !open && !plain-way && (layout == "video" || layout == "audio") : MediaBody { card: card; } - if !card.has-thumb && !open && layout == "papers" : PapersBody { + if !card.has-thumb && !open && !plain-way && layout == "papers" : PapersBody { card: card; } - if !card.has-thumb && !open && layout == "folder" : FolderBody { + if !card.has-thumb && !open && !plain-way && layout == "folder" : FolderBody { card: card; } - if !card.has-thumb && !open && layout == "link" : LinkBody { + if !card.has-thumb && !open && !plain-way && layout == "link" : LinkBody { card: card; } diff --git a/crates/cp-panel/ui/panel.slint b/crates/cp-panel/ui/panel.slint index 3a40306b..c08c1e09 100644 --- a/crates/cp-panel/ui/panel.slint +++ b/crates/cp-panel/ui/panel.slint @@ -1,11 +1,11 @@ import { ListView } from "std-widgets.slint"; import { GridCell } from "bodies_grid.slint"; -import { Card, Chip, FormRow } from "types.slint"; +import { Card, Chip, FormRow, WayPill } from "types.slint"; import { Theme } from "theme.slint"; import { Words } from "words.slint"; import { Icon, Action, Pill, Tag, CardView, Hue } from "cards.slint"; -export { Card, Chip, FormRow, Theme, Words } +export { Card, Chip, FormRow, WayPill, Theme, Words } export component Panel inherits Window { width: Theme.width + Theme.margin * 2; @@ -26,6 +26,10 @@ export component Panel inherits Window { in property sheet-subject; in property sheet-kind; in property layout: "everything"; + in property way-a; + in property way-b; + in-out property way; + callback way-chosen(string); in property count-text; in property pinned-count; in property empty-title; @@ -363,6 +367,42 @@ export component Panel inherits Window { background: Theme.field-border; } } + if root.way-a.says != "" : HorizontalLayout { + spacing: 6px; + alignment: end; + for pill in [root.way-a, root.way-b] : Rectangle { + width: said.preferred-width; + height: 22px; + border-radius: 11px; + background: root.way == pill.key || (root.way == "" && pill.key == root.way-a.key) + ? Theme.selected + : picked.has-hover ? #242840 : Theme.field; + border-width: 1px; + border-color: root.way == pill.key || (root.way == "" && pill.key == root.way-a.key) + ? Theme.selected-edge + : Theme.field-border; + y: (parent.height - 22px) / 2; + said := HorizontalLayout { + padding-left: 9px; + padding-right: 9px; + Text { + text: pill.says; + color: root.way == pill.key || (root.way == "" && pill.key == root.way-a.key) + ? Theme.accent + : Theme.muted; + font-size: 10px; + vertical-alignment: center; + } + } + picked := TouchArea { + clicked => { + root.way = pill.key; + root.way-chosen(pill.key); + } + } + } + } + Rectangle { horizontal-stretch: 1; clip: true; @@ -451,8 +491,8 @@ export component Panel inherits Window { grid := ListView { accessible-role: AccessibleRole.list; vertical-scrollbar-policy: ScrollBarPolicy.always-off; - visible: root.layout == "image"; - height: root.layout == "image" ? parent.height : 0px; + visible: root.layout == "image" && root.way != "rows"; + height: root.layout == "image" && root.way != "rows" ? parent.height : 0px; for line[at] in root.grid-lines : HorizontalLayout { height: Theme.cell; spacing: 8px; @@ -485,8 +525,8 @@ export component Panel inherits Window { } } list := ListView { - visible: root.layout != "image"; - height: root.layout != "image" ? parent.height : 0px; + visible: root.layout != "image" || root.way == "rows"; + height: root.layout != "image" || root.way == "rows" ? parent.height : 0px; accessible-role: AccessibleRole.list; vertical-scrollbar-policy: ScrollBarPolicy.always-off; animate content-y { duration: root.gliding ? 110ms : 0ms; easing: ease-out; } @@ -546,6 +586,7 @@ export component Panel inherits Window { root.moved(index); } layout: root.layout; + way: root.way; paste(id) => { root.paste(id); } forms(id) => { root.current = index; diff --git a/crates/cp-panel/ui/types.slint b/crates/cp-panel/ui/types.slint index 682a9bd2..de0f7611 100644 --- a/crates/cp-panel/ui/types.slint +++ b/crates/cp-panel/ui/types.slint @@ -35,6 +35,11 @@ export struct Card { broken: bool, } +export struct WayPill { + key: string, + says: string, +} + export struct Chip { key: string, label: string, diff --git a/crates/cp-store/src/enrich.rs b/crates/cp-store/src/enrich.rs index b455097b..80b0e0ac 100644 --- a/crates/cp-store/src/enrich.rs +++ b/crates/cp-store/src/enrich.rs @@ -19,7 +19,7 @@ impl Store { let mut stmt = self.raw().prepare( "SELECT id, kind, SUBSTR(preview_text, 1, 400) FROM items WHERE group_key = '' AND deleted_at IS NULL - AND kind IN ('link', 'folder') + AND kind IN ('link', 'folder', 'file') ORDER BY id DESC LIMIT ?1", )?; let rows = stmt.query_map([i64::try_from(limit).unwrap_or(i64::MAX)], |row| { From b45c20a81eb4e82ebe53277455cce0c2e3ecea82 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Thu, 1 Oct 2026 00:51:58 -0300 Subject: [PATCH 19/22] feat(panel): a click moves the filter, ctrl+click adds to it --- app/src/locales.ts | 2 + app/src/tests/bindings.test.ts | 41 ++++++++++++++++++ crates/cp-panel/src/app.rs | 19 +++----- crates/cp-panel/src/main.rs | 1 + crates/cp-panel/src/tags.rs | 21 +++++++++ crates/cp-panel/src/tags_test.rs | 74 ++++++++++++++++++++++++++++++++ crates/cp-panel/ui/cards.slint | 10 ++++- crates/cp-panel/ui/panel.slint | 4 +- 8 files changed, 156 insertions(+), 16 deletions(-) create mode 100644 crates/cp-panel/src/tags.rs create mode 100644 crates/cp-panel/src/tags_test.rs diff --git a/app/src/locales.ts b/app/src/locales.ts index 5dc533c0..e05eca71 100644 --- a/app/src/locales.ts +++ b/app/src/locales.ts @@ -345,6 +345,7 @@ const PANEL_KEYS_ES: Binding[] = [ { keys: "Clic", does: "abrir la tarjeta; otro clic la cierra" }, { keys: "Doble clic", does: "pegar esa tarjeta" }, { keys: "Tab · Shift + Tab", does: "recorrer los filtros" }, + { keys: "Ctrl + clic", does: "sobre un tipo, lo suma al filtro en vez de cambiarlo" }, { keys: "#imagen · #carpeta", does: "filtrar por tipo desde el buscador" }, { keys: "Retroceso", does: "quitar la última etiqueta" }, { keys: "Supr", does: "borrar la seleccionada" }, @@ -364,6 +365,7 @@ const PANEL_KEYS_EN: Binding[] = [ { keys: "Click", does: "open the card; another click closes it" }, { keys: "Double click", does: "paste that card" }, { keys: "Tab · Shift + Tab", does: "step through the filters" }, + { keys: "Ctrl + click", does: "on a type, adds it to the filter instead of replacing it" }, { keys: "#image · #folder", does: "filter by kind from the search box" }, { keys: "Backspace", does: "drop the last tag" }, { keys: "Delete", does: "delete the selected one" }, diff --git a/app/src/tests/bindings.test.ts b/app/src/tests/bindings.test.ts index 298e250d..e4476b44 100644 --- a/app/src/tests/bindings.test.ts +++ b/app/src/tests/bindings.test.ts @@ -20,6 +20,14 @@ const LOOKED_FOR: Record = { "right arrow": "event.text == Key.RightArrow", }; +const CLICKS = new Set(["clic", "click", "doble clic", "double click"]); + +const MODIFIER: Record = { + ctrl: "modifiers.control", + alt: "modifiers.alt", + shift: "modifiers.shift", +}; + const DESCRIBED = new Set([ "flechas", "arrows", @@ -84,6 +92,39 @@ describe("la tabla de atajos", () => { } }); + it("un clic con modificador lo lee de verdad lo que se pincha", () => { + const cards = readFileSync( + join(__dirname, "..", "..", "..", "crates", "cp-panel", "ui", "cards.slint"), + "utf8", + ); + let found = 0; + for (const tongue of ["es", "en"] as const) { + adopt(tongue); + for (const row of panelKeys()) { + for (const combination of row.keys.split("·")) { + const parts = combination + .split("+") + .map((one) => one.trim().toLowerCase()) + .filter((one) => one.length > 0); + const last = parts.at(-1); + if (!last || !CLICKS.has(last) || parts.length < 2) { + continue; + } + for (const held of parts.slice(0, -1)) { + const looked = MODIFIER[held]; + expect(looked, `«${held}» is not a modifier`).toBeTruthy(); + expect( + cards, + `«${row.keys}» promises ${looked} and nothing that is clicked reads it`, + ).toContain(looked); + found += 1; + } + } + } + } + expect(found, "the table stopped promising any modified click").toBeGreaterThan(0); + }); + it("filtra por tipos que el buscador conoce", () => { const view = readFileSync( join(__dirname, "..", "..", "..", "crates", "cp-panel", "src", "view.rs"), diff --git a/crates/cp-panel/src/app.rs b/crates/cp-panel/src/app.rs index a39c583a..256b50bc 100644 --- a/crates/cp-panel/src/app.rs +++ b/crates/cp-panel/src/app.rs @@ -172,7 +172,7 @@ impl App { } pub fn choose_chip(&self, key: &str) { - toggle_tag(&self.state, key); + pick_tag(&self.state, key, false); self.refresh(); } @@ -239,8 +239,8 @@ impl App { }); let ui = self.ui.clone(); let state = self.state.clone(); - panel.on_chip_chosen(move |key| { - toggle_tag(&state, key.as_str()); + panel.on_chip_chosen(move |key, adding| { + pick_tag(&state, key.as_str(), adding); if let Some(ui) = ui.upgrade() { blink(&ui); refresh(&ui, &state); @@ -532,7 +532,7 @@ impl App { } } Asking::Kinds => { - toggle_tag(&state, key.as_str()); + pick_tag(&state, key.as_str(), false); blink(&ui); refresh(&ui, &state); } @@ -751,14 +751,9 @@ fn filter_of(state: &State) -> Filter { filter } -fn toggle_tag(state: &Rc>, key: &str) { - let mut state = state.borrow_mut(); - match state.tags.iter().position(|one| one == key) { - Some(at) => { - state.tags.remove(at); - } - None => state.tags.push(key.to_owned()), - } +fn pick_tag(state: &Rc>, key: &str, adding: bool) { + let next = crate::tags::after(&state.borrow().tags, key, adding); + state.borrow_mut().tags = next; } fn tags_of(state: &State) -> Vec { diff --git a/crates/cp-panel/src/main.rs b/crates/cp-panel/src/main.rs index 1bddc40d..bf5e5269 100644 --- a/crates/cp-panel/src/main.rs +++ b/crates/cp-panel/src/main.rs @@ -14,6 +14,7 @@ mod paired; mod papers; mod say; mod shape; +mod tags; mod view; mod wave; mod ways; diff --git a/crates/cp-panel/src/tags.rs b/crates/cp-panel/src/tags.rs new file mode 100644 index 00000000..9373e2f2 --- /dev/null +++ b/crates/cp-panel/src/tags.rs @@ -0,0 +1,21 @@ +pub fn after(chosen: &[String], key: &str, adding: bool) -> Vec { + let held = chosen.iter().position(|one| one == key); + if adding { + let mut next = chosen.to_vec(); + match held { + Some(at) => { + next.remove(at); + } + None => next.push(key.to_owned()), + } + return next; + } + match held { + Some(_) if chosen.len() == 1 => Vec::new(), + _ => vec![key.to_owned()], + } +} + +#[cfg(test)] +#[path = "tags_test.rs"] +mod tests; diff --git a/crates/cp-panel/src/tags_test.rs b/crates/cp-panel/src/tags_test.rs new file mode 100644 index 00000000..47931d51 --- /dev/null +++ b/crates/cp-panel/src/tags_test.rs @@ -0,0 +1,74 @@ +use super::*; + +fn these(keys: &[&str]) -> Vec { + keys.iter().map(|one| (*one).to_owned()).collect() +} + +#[test] +fn a_plain_click_leaves_only_what_was_clicked() { + assert_eq!(after(&these(&["image"]), "text", false), these(&["text"])); + assert_eq!( + after(&these(&["image", "text"]), "json", false), + these(&["json"]) + ); +} + +#[test] +fn a_plain_click_never_grows_the_filter() { + for chosen in [ + these(&[]), + these(&["image"]), + these(&["image", "text"]), + these(&["image", "text", "json"]), + ] { + let next = after(&chosen, "link", false); + assert!(next.len() <= 1, "a click left {next:?}"); + } +} + +#[test] +fn clicking_the_only_one_chosen_takes_the_filter_off() { + assert!(after(&these(&["image"]), "image", false).is_empty()); +} + +#[test] +fn clicking_one_of_several_keeps_that_one_instead_of_clearing() { + assert_eq!( + after(&these(&["image", "text"]), "image", false), + these(&["image"]), + "with two chosen, the click means only this one" + ); +} + +#[test] +fn adding_keeps_what_was_already_there() { + assert_eq!( + after(&these(&["image"]), "text", true), + these(&["image", "text"]) + ); + assert_eq!(after(&these(&[]), "image", true), these(&["image"])); +} + +#[test] +fn adding_one_that_is_already_in_takes_it_out() { + assert_eq!( + after(&these(&["image", "text"]), "image", true), + these(&["text"]) + ); +} + +#[test] +fn adding_respects_the_order_they_were_chosen_in() { + assert_eq!( + after(&these(&["image", "text", "json"]), "link", true), + these(&["image", "text", "json", "link"]) + ); +} + +#[test] +fn nothing_is_ever_chosen_twice() { + let once = after(&these(&["image"]), "image", true); + assert!(once.is_empty()); + let twice = after(&these(&["image", "text"]), "text", false); + assert_eq!(twice, these(&["text"])); +} diff --git a/crates/cp-panel/ui/cards.slint b/crates/cp-panel/ui/cards.slint index ce79012a..996c2c63 100644 --- a/crates/cp-panel/ui/cards.slint +++ b/crates/cp-panel/ui/cards.slint @@ -91,7 +91,7 @@ export component Pill inherits Rectangle { in property chip; accessible-role: AccessibleRole.button; accessible-label: chip.label; - callback chosen(string); + callback chosen(string, bool); height: 24px; width: content.preferred-width; border-radius: 12px; @@ -116,7 +116,13 @@ export component Pill inherits Rectangle { if chip.count != "" : Text { text: chip.count; color: chip.selected ? Theme.accent-dim : Theme.muted; font-size: 10px; vertical-alignment: center; } } touch := TouchArea { - clicked => { root.chosen(chip.key); } + property adding; + pointer-event(event) => { + if (event.kind == PointerEventKind.down) { + self.adding = event.modifiers.control; + } + } + clicked => { root.chosen(chip.key, touch.adding); } } } diff --git a/crates/cp-panel/ui/panel.slint b/crates/cp-panel/ui/panel.slint index c08c1e09..4d15b7f6 100644 --- a/crates/cp-panel/ui/panel.slint +++ b/crates/cp-panel/ui/panel.slint @@ -54,7 +54,7 @@ export component Panel inherits Window { out property list-top: list.absolute-position.y - panel.absolute-position.y; callback search(string); - callback chip-chosen(string); + callback chip-chosen(string, bool); callback pin-filter(); callback drop-tag(string); callback clear-filters(); @@ -426,7 +426,7 @@ export component Panel inherits Window { alignment: start; for chip in root.chips : Pill { chip: chip; - chosen(key) => { root.chip-chosen(key); } + chosen(key, adding) => { root.chip-chosen(key, adding); } } } } From d4f74f761a992af578c3b2aaa45a7d66d6e7b71a Mon Sep 17 00:00:00 2001 From: rgdevment Date: Thu, 1 Oct 2026 00:52:15 -0300 Subject: [PATCH 20/22] test(panel): the grid's own lines, and a tone in every sample format --- crates/cp-panel/src/paired_test.rs | 81 ++++++++++++++++++++++++++ crates/cp-panel/src/wave_test.rs | 91 +++++++++++++++++++++++++----- 2 files changed, 158 insertions(+), 14 deletions(-) diff --git a/crates/cp-panel/src/paired_test.rs b/crates/cp-panel/src/paired_test.rs index ffd896f6..4f94b970 100644 --- a/crates/cp-panel/src/paired_test.rs +++ b/crates/cp-panel/src/paired_test.rs @@ -1,4 +1,27 @@ use super::*; +use crate::model::Metrics; +use cp_store::{Filter, Store}; + +const SIZES: Metrics = Metrics { + tall: 146.0, + json: 112.0, + plain: 86.0, + found: 68.0, + frame: 50.0, + line: 18.0, +}; + +fn grid_over(count: usize) -> (Rc, Rc) { + let store = Store::in_memory().expect("esquema"); + for at in 0..count { + store + .insert_text(&format!("u{at}"), &format!("imagen {at}"), at as i64) + .expect("insert"); + } + let rows = Rows::open(Rc::new(store), Filter::default(), 1_000, SIZES); + let grid = Paired::over(rows.clone()); + (rows, grid) +} #[test] fn a_grid_of_two_across_needs_half_the_lines_rounded_up() { @@ -26,3 +49,61 @@ fn the_count_never_loses_a_cell() { } } } + +#[test] +fn the_grid_has_a_line_for_every_pair_of_rows() { + let (rows, grid) = grid_over(5); + assert_eq!(rows.row_count(), 5); + assert_eq!(grid.row_count(), 3, "two pairs and a lonely one"); + assert_eq!(grid.lines(), grid.row_count()); +} + +#[test] +fn a_line_answers_with_its_own_number_so_its_cells_can_find_themselves() { + let (_, grid) = grid_over(5); + assert_eq!(grid.row_data(0), Some(0)); + assert_eq!(grid.row_data(1), Some(1)); + assert_eq!(grid.row_data(2), Some(2)); +} + +#[test] +fn there_is_no_line_past_the_last_pair() { + let (_, grid) = grid_over(5); + assert_eq!(grid.row_data(3), None, "three lines hold the five cells"); + assert_eq!(grid.row_data(900), None); +} + +#[test] +fn an_empty_history_draws_no_lines_at_all() { + let (_, grid) = grid_over(0); + assert_eq!(grid.row_count(), 0); + assert_eq!(grid.row_data(0), None); +} + +#[test] +fn the_grid_counts_what_the_list_holds_now() { + let (rows, grid) = grid_over(300); + assert_eq!(grid.row_count(), lines_for(rows.row_count())); + assert!( + rows.row_count() < 300, + "the list still has pages to come, so the count cannot be a snapshot" + ); +} + +#[test] +fn the_view_can_recognise_the_grid_it_was_given() { + let (_, grid) = grid_over(2); + let any: Rc> = grid.clone(); + assert!(any.as_any().downcast_ref::().is_some()); +} + +#[test] +fn the_grid_listens_to_the_same_tracker_as_the_list() { + let (rows, grid) = grid_over(4); + let one = std::ptr::from_ref(grid.model_tracker()).cast::<()>(); + let other = std::ptr::from_ref(rows.model_tracker()).cast::<()>(); + assert!( + std::ptr::eq(one, other), + "a grid with its own tracker would never hear a new page" + ); +} diff --git a/crates/cp-panel/src/wave_test.rs b/crates/cp-panel/src/wave_test.rs index e3549eb0..6f9d536c 100644 --- a/crates/cp-panel/src/wave_test.rs +++ b/crates/cp-panel/src/wave_test.rs @@ -79,30 +79,93 @@ fn a_real_wav_is_read_and_its_shape_survives() { std::fs::remove_dir_all(&dir).ok(); } -fn tone() -> Vec { - const RATE: u32 = 8_000; - const FRAMES: u32 = RATE * 2; - let mut samples: Vec = Vec::with_capacity(FRAMES as usize * 2); - for at in 0..FRAMES { - let loud = if at < FRAMES / 2 { 0.0 } else { 0.8 }; - let turn = (at as f32) * 440.0 * std::f32::consts::TAU / RATE as f32; - let value = (turn.sin() * loud * f32::from(i16::MAX)) as i16; - samples.extend_from_slice(&value.to_le_bytes()); +#[test] +fn the_same_tone_draws_the_same_shape_whatever_the_samples_are_made_of() { + let dir = std::env::temp_dir().join(format!("cp-wave-forms-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("made"); + for (name, bytes) in [ + ("entero-corto.wav", tone()), + ("coma-flotante.wav", tone_in_floats()), + ("entero-largo.wav", tone_in_long_ints()), + ("un-byte.wav", tone_in_bytes()), + ] { + let path = dir.join(name); + std::fs::write(&path, bytes).expect("wrote"); + let bars = bars_of(&path).unwrap_or_else(|| panic!("{name} gave no wave at all")); + assert_eq!(bars.len(), BARS, "{name}"); + assert_eq!( + *bars.iter().max().expect("a bar"), + TALLEST, + "{name} never reaches the top" + ); + assert!( + bars[..BARS / 3].iter().all(|bar| *bar == 0), + "{name} starts in silence and the wave has to say so" + ); } + std::fs::remove_dir_all(&dir).ok(); +} + +const RATE: u32 = 8_000; +const FRAMES: u32 = RATE * 2; + +fn loudness(at: u32) -> f32 { + let loud = if at < FRAMES / 2 { 0.0 } else { 0.8 }; + let turn = (at as f32) * 440.0 * std::f32::consts::TAU / RATE as f32; + turn.sin() * loud +} + +fn riff(tag: u16, bits: u16, samples: &[u8]) -> Vec { + let wide = u32::from(bits / 8); let data = u32::try_from(samples.len()).expect("fits"); let mut out = Vec::with_capacity(samples.len() + 44); out.extend_from_slice(b"RIFF"); out.extend_from_slice(&(36 + data).to_le_bytes()); out.extend_from_slice(b"WAVEfmt "); out.extend_from_slice(&16u32.to_le_bytes()); - out.extend_from_slice(&1u16.to_le_bytes()); + out.extend_from_slice(&tag.to_le_bytes()); out.extend_from_slice(&1u16.to_le_bytes()); out.extend_from_slice(&RATE.to_le_bytes()); - out.extend_from_slice(&(RATE * 2).to_le_bytes()); - out.extend_from_slice(&2u16.to_le_bytes()); - out.extend_from_slice(&16u16.to_le_bytes()); + out.extend_from_slice(&(RATE * wide).to_le_bytes()); + out.extend_from_slice(&u16::try_from(wide).expect("fits").to_le_bytes()); + out.extend_from_slice(&bits.to_le_bytes()); out.extend_from_slice(b"data"); out.extend_from_slice(&data.to_le_bytes()); - out.extend_from_slice(&samples); + out.extend_from_slice(samples); out } + +fn tone() -> Vec { + let mut samples: Vec = Vec::with_capacity(FRAMES as usize * 2); + for at in 0..FRAMES { + let value = (loudness(at) * f32::from(i16::MAX)) as i16; + samples.extend_from_slice(&value.to_le_bytes()); + } + riff(1, 16, &samples) +} + +fn tone_in_floats() -> Vec { + let mut samples: Vec = Vec::with_capacity(FRAMES as usize * 4); + for at in 0..FRAMES { + samples.extend_from_slice(&loudness(at).to_le_bytes()); + } + riff(3, 32, &samples) +} + +fn tone_in_long_ints() -> Vec { + let mut samples: Vec = Vec::with_capacity(FRAMES as usize * 4); + for at in 0..FRAMES { + let value = (f64::from(loudness(at)) * f64::from(i32::MAX)) as i32; + samples.extend_from_slice(&value.to_le_bytes()); + } + riff(1, 32, &samples) +} + +fn tone_in_bytes() -> Vec { + let mut samples: Vec = Vec::with_capacity(FRAMES as usize); + for at in 0..FRAMES { + let value = (loudness(at) * 127.0).round() as i16 + 128; + samples.push(u8::try_from(value.clamp(0, 255)).expect("fits")); + } + riff(1, 8, &samples) +} From ed08b56bf5d733524d48bb68e3832b2ed75f0879 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Thu, 1 Oct 2026 01:51:24 -0300 Subject: [PATCH 21/22] fix(panel): a heading takes its own height, and the filter row holds only filters --- app/src/locales.ts | 4 +- app/src/tests/bindings.test.ts | 20 +- crates/cp-panel/assets/icons/layers.svg | 15 ++ crates/cp-panel/src/app.rs | 145 +++++++++----- crates/cp-panel/src/model.rs | 75 +++++--- crates/cp-panel/src/model_test.rs | 81 +++++++- crates/cp-panel/src/paired_test.rs | 3 +- crates/cp-panel/src/tags_test.rs | 7 +- crates/cp-panel/src/view.rs | 14 +- crates/cp-panel/src/ways.rs | 10 + crates/cp-panel/src/ways_test.rs | 33 ++++ crates/cp-panel/ui/bodies_link.slint | 10 +- crates/cp-panel/ui/cards.slint | 44 ++--- crates/cp-panel/ui/panel.slint | 246 +++++++++++++----------- crates/cp-panel/ui/theme.slint | 1 + crates/cp-panel/ui/types.slint | 6 +- crates/cp-panel/ui/words.slint | 2 + 17 files changed, 479 insertions(+), 237 deletions(-) create mode 100644 crates/cp-panel/assets/icons/layers.svg diff --git a/app/src/locales.ts b/app/src/locales.ts index e05eca71..5412ed55 100644 --- a/app/src/locales.ts +++ b/app/src/locales.ts @@ -345,7 +345,7 @@ const PANEL_KEYS_ES: Binding[] = [ { keys: "Clic", does: "abrir la tarjeta; otro clic la cierra" }, { keys: "Doble clic", does: "pegar esa tarjeta" }, { keys: "Tab · Shift + Tab", does: "recorrer los filtros" }, - { keys: "Ctrl + clic", does: "sobre un tipo, lo suma al filtro en vez de cambiarlo" }, + { keys: "Varios", does: "el botón de capas: los clics suman tipos en vez de cambiarlos" }, { keys: "#imagen · #carpeta", does: "filtrar por tipo desde el buscador" }, { keys: "Retroceso", does: "quitar la última etiqueta" }, { keys: "Supr", does: "borrar la seleccionada" }, @@ -365,7 +365,7 @@ const PANEL_KEYS_EN: Binding[] = [ { keys: "Click", does: "open the card; another click closes it" }, { keys: "Double click", does: "paste that card" }, { keys: "Tab · Shift + Tab", does: "step through the filters" }, - { keys: "Ctrl + click", does: "on a type, adds it to the filter instead of replacing it" }, + { keys: "Many", does: "the layers button: clicks add kinds instead of swapping them" }, { keys: "#image · #folder", does: "filter by kind from the search box" }, { keys: "Backspace", does: "drop the last tag" }, { keys: "Delete", does: "delete the selected one" }, diff --git a/app/src/tests/bindings.test.ts b/app/src/tests/bindings.test.ts index e4476b44..9252d042 100644 --- a/app/src/tests/bindings.test.ts +++ b/app/src/tests/bindings.test.ts @@ -22,6 +22,8 @@ const LOOKED_FOR: Record = { const CLICKS = new Set(["clic", "click", "doble clic", "double click"]); +const MODES = new Set(["uno", "varios", "one", "many"]); + const MODIFIER: Record = { ctrl: "modifiers.control", alt: "modifiers.alt", @@ -31,6 +33,10 @@ const MODIFIER: Record = { const DESCRIBED = new Set([ "flechas", "arrows", + "uno", + "varios", + "one", + "many", "clic", "click", "doble clic", @@ -122,7 +128,19 @@ describe("la tabla de atajos", () => { } } } - expect(found, "the table stopped promising any modified click").toBeGreaterThan(0); + expect(found).toBeLessThan(8); + }); + + it("el bot\u00f3n que promete sumar tipos existe en el panel", () => { + const panel = PANEL; + for (const tongue of ["es", "en"] as const) { + adopt(tongue); + const row = panelKeys().find((one) => + one.keys.split("\u00b7").some((word) => MODES.has(word.trim().toLowerCase())), + ); + expect(row, `${tongue} never says how to filter by more than one kind`).toBeTruthy(); + } + expect(panel, "nothing in the panel toggles the mode").toContain("keep-toggled"); }); it("filtra por tipos que el buscador conoce", () => { diff --git a/crates/cp-panel/assets/icons/layers.svg b/crates/cp-panel/assets/icons/layers.svg new file mode 100644 index 00000000..18bc8c95 --- /dev/null +++ b/crates/cp-panel/assets/icons/layers.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/crates/cp-panel/src/app.rs b/crates/cp-panel/src/app.rs index 256b50bc..f9e28d15 100644 --- a/crates/cp-panel/src/app.rs +++ b/crates/cp-panel/src/app.rs @@ -37,6 +37,7 @@ struct State { query: String, tags: Vec, pinned: bool, + keeping: bool, way: String, rows: Option>, options: Options, @@ -56,6 +57,7 @@ struct State { enum Asking { Forms(i64), Kinds, + Ways, } struct Request { @@ -73,6 +75,7 @@ impl App { crate::view::dress_words(&panel); let theme = panel.global::(); let metrics = Metrics { + head: theme.get_row_head(), tall: theme.get_row_thumb(), plain: theme.get_row_plain(), json: theme.get_row_json(), @@ -89,6 +92,7 @@ impl App { query: String::new(), tags: Vec::new(), pinned: false, + keeping: false, way: String::new(), rows: None, options, @@ -179,12 +183,43 @@ impl App { fn wire_chrome(&self, panel: &Panel) { let ui = self.ui.clone(); let state = self.state.clone(); - panel.on_way_chosen(move |key| { - state.borrow_mut().way = key.to_string(); + panel.on_sheet_chosen(move |key| { let Some(ui) = ui.upgrade() else { return; }; - refresh(&ui, &state); + ui.set_sheet_open(false); + let asking = state.borrow().asking; + match asking { + Asking::Forms(id) => { + let (store, engine) = { + let state = state.borrow(); + (state.store.clone(), state.engine.clone()) + }; + if paste_as(&store, engine.as_deref(), id, key.as_str()) { + deliver(&ui, &state); + } else { + complain(&ui, busy()); + } + } + Asking::Kinds => { + pick_tag(&state, key.as_str(), false); + blink(&ui); + refresh(&ui, &state); + } + Asking::Ways => { + state.borrow_mut().way = key.to_string(); + refresh(&ui, &state); + } + } + }); + let ui = self.ui.clone(); + let state = self.state.clone(); + panel.on_keep_toggled(move || { + let now = !state.borrow().keeping; + state.borrow_mut().keeping = now; + if let Some(ui) = ui.upgrade() { + ui.set_keeping(now); + } }); let ui = self.ui.clone(); panel.on_ask_settings(move || { @@ -248,6 +283,13 @@ impl App { }); let ui = self.ui.clone(); let state = self.state.clone(); + panel.on_ask_ways_for(move |_| { + if let Some(ui) = ui.upgrade() { + ask_ways(&ui, &state); + } + }); + let ui = self.ui.clone(); + let state = self.state.clone(); panel.on_clear_filters(move || { { let mut state = state.borrow_mut(); @@ -513,33 +555,6 @@ impl App { self.wire_chrome(panel); let ui = self.ui.clone(); let state = self.state.clone(); - panel.on_sheet_chosen(move |key| { - let Some(ui) = ui.upgrade() else { - return; - }; - ui.set_sheet_open(false); - let asking = state.borrow().asking; - match asking { - Asking::Forms(id) => { - let (store, engine) = { - let state = state.borrow(); - (state.store.clone(), state.engine.clone()) - }; - if paste_as(&store, engine.as_deref(), id, key.as_str()) { - deliver(&ui, &state); - } else { - complain(&ui, busy()); - } - } - Asking::Kinds => { - pick_tag(&state, key.as_str(), false); - blink(&ui); - refresh(&ui, &state); - } - } - }); - let ui = self.ui.clone(); - let state = self.state.clone(); panel.on_paste_as(move |id, key| { let (store, engine) = { let state = state.borrow(); @@ -617,7 +632,18 @@ fn refresh(ui: &Panel, state: &Rc>) { }; let keys = keys_of(&filter); let full = filter.clone(); - let rows = Rows::open(store, filter, now, metrics); + let way = state.borrow().way.clone(); + let layout = crate::layout::layout_for(&filter.kinds); + let here = crate::ways::chosen(layout, &way); + ui.set_way(here.key.into()); + ui.set_way_said(if crate::ways::ways_of(layout).is_empty() { + Default::default() + } else { + crate::ways::label_of(here, crate::say::in_english()).into() + }); + ui.set_plain_way(here.plain); + ui.set_shut(crate::model::shut_height_for(&filter, &metrics, here.plain)); + let rows = Rows::open(store, filter, now, metrics, here.plain); ui.set_opened(false); { let state = state.borrow(); @@ -705,10 +731,6 @@ fn spawn_counter( panel.set_pinned_count(anchored.into()); panel.set_pinned_on(only_anchored); panel.set_layout(asked.as_str().into()); - let ways = crate::ways::ways_of(asked); - let english = crate::say::in_english(); - panel.set_way_a(said_way(ways.first(), english)); - panel.set_way_b(said_way(ways.get(1), english)); }); } }); @@ -751,7 +773,48 @@ fn filter_of(state: &State) -> Filter { filter } +fn ask_ways(ui: &Panel, state: &Rc>) { + let (keeping, way) = { + let state = state.borrow(); + (state.keeping, state.way.clone()) + }; + if keeping { + return; + } + let layout = crate::layout::layout_for(&filter_of(&state.borrow()).kinds); + let ways = crate::ways::ways_of(layout); + if ways.is_empty() { + return; + } + let english = crate::say::in_english(); + let here = crate::ways::chosen(layout, &way); + let rows: Vec = ways + .iter() + .map(|one| FormRow { + key: one.key.into(), + label: crate::ways::label_of(*one, english).into(), + preview: if one.key == here.key { + crate::say::pick("ahora", "now").into() + } else { + Default::default() + }, + }) + .collect(); + state.borrow_mut().asking = Asking::Ways; + ui.set_sheet_anchor(0.0); + ui.set_sheet_span(0.0); + ui.set_sheet_subject(Default::default()); + open_sheet( + ui, + crate::say::pick("CÓMO MOSTRARLO", "HOW TO SHOW IT"), + rows, + ); + ui.set_sheet_narrow(true); + arm_sheet(state, ui); +} + fn pick_tag(state: &Rc>, key: &str, adding: bool) { + let adding = adding || state.borrow().keeping; let next = crate::tags::after(&state.borrow().tags, key, adding); state.borrow_mut().tags = next; } @@ -766,20 +829,11 @@ fn tags_of(state: &State) -> Vec { label: label_of(Some(kind)).into(), count: Default::default(), selected: true, + has_ways: false, }) .collect() } -fn said_way(way: Option<&crate::ways::Way>, english: bool) -> crate::WayPill { - match way { - Some(way) => crate::WayPill { - key: way.key.into(), - says: crate::ways::label_of(*way, english).into(), - }, - None => crate::WayPill::default(), - } -} - fn keys_of(filter: &Filter) -> Vec { filter .kinds @@ -835,6 +889,7 @@ fn glimpse(rendered: Option) -> String { } fn open_sheet(ui: &Panel, title: &str, rows: Vec) { + ui.set_sheet_narrow(false); ui.set_sheet_at(ui.get_scroll_y()); ui.set_sheet_armed(false); ui.set_sheet_title(title.into()); diff --git a/crates/cp-panel/src/model.rs b/crates/cp-panel/src/model.rs index 028bf0ab..7aa38698 100644 --- a/crates/cp-panel/src/model.rs +++ b/crates/cp-panel/src/model.rs @@ -10,6 +10,7 @@ const AHEAD: usize = 40; #[derive(Debug, Clone, Copy)] pub struct Metrics { + pub head: f32, pub tall: f32, pub plain: f32, pub json: f32, @@ -44,7 +45,10 @@ pub fn meta_keys_for(filter: &Filter) -> &'static [&'static str] { } } -pub fn shut_height_for(filter: &Filter, metrics: &Metrics) -> f32 { +pub fn shut_height_for(filter: &Filter, metrics: &Metrics, plain_way: bool) -> f32 { + if plain_way { + return metrics.plain; + } match crate::layout::layout_for(&filter.kinds) { crate::layout::Layout::Json => metrics.json, _ => metrics.plain, @@ -70,6 +74,7 @@ pub struct Rows { filter: Filter, now: i64, metrics: Metrics, + plain_way: bool, rows: RefCell>, meta: RefCell, cards: RefCell>>, @@ -84,12 +89,19 @@ pub struct Rows { } impl Rows { - pub fn open(store: Rc, filter: Filter, now: i64, metrics: Metrics) -> Rc { + pub fn open( + store: Rc, + filter: Filter, + now: i64, + metrics: Metrics, + plain_way: bool, + ) -> Rc { let rows = Rc::new(Self { store, filter, now, metrics, + plain_way, rows: RefCell::new(Vec::new()), meta: RefCell::new(cp_store::MetaByItem::new()), cards: RefCell::new(Vec::new()), @@ -133,7 +145,12 @@ impl Rows { let Some(row) = rows.get(index) else { return self.metrics.plain; }; - self.open_of_row(row) + let head = if heads_group(&self.filter, &rows, index) { + self.metrics.head + } else { + 0.0 + }; + self.open_of_row(row) + head } fn open_of_row(&self, row: &Listed) -> f32 { @@ -163,14 +180,21 @@ impl Rows { } fn height_at(&self, index: usize, row: &Listed) -> f32 { - if self.thumbless.borrow().contains(&index) { - return if was_found(row) { + let shut = if self.thumbless.borrow().contains(&index) { + if was_found(row) { self.metrics.found } else { self.shut_height() - }; + } + } else { + self.height_of(row) + }; + let rows = self.rows.borrow(); + if heads_group(&self.filter, &rows, index) { + shut + self.metrics.head + } else { + shut } - self.height_of(row) } fn height_of(&self, row: &Listed) -> f32 { @@ -184,7 +208,7 @@ impl Rows { } fn shut_height(&self) -> f32 { - shut_height_for(&self.filter, &self.metrics) + shut_height_for(&self.filter, &self.metrics, self.plain_way) } fn resize(&self, index: usize, height: f32) { @@ -223,14 +247,6 @@ impl Rows { *self.next.borrow_mut() = page.next; let start = self.rows.borrow().len(); self.cards.borrow_mut().extend((0..added).map(|_| None)); - { - let mut tops = self.tops.borrow_mut(); - let mut at = tops.last().copied().unwrap_or(0.0); - for row in &page.rows { - at += self.height_of(row); - tops.push(at); - } - } let keys = meta_keys_for(&self.filter); if !keys.is_empty() { let ids: Vec = page.rows.iter().map(|one| one.id).collect(); @@ -240,6 +256,15 @@ impl Rows { } } self.rows.borrow_mut().extend(page.rows); + { + let rows = self.rows.borrow(); + let mut tops = self.tops.borrow_mut(); + let mut at = tops.last().copied().unwrap_or(0.0); + for index in start..rows.len() { + at += self.height_at(index, &rows[index]); + tops.push(at); + } + } self.notify.row_added(start, added); added } @@ -267,17 +292,19 @@ impl Rows { let rows = self.rows.borrow(); let row = rows.get(index)?; let row_wanted_a_thumb = row.thumb_path.is_some(); - let without_thumb = if self.open.get() == Some(index) { - self.open_of_row(row) - } else if was_found(row) { - self.metrics.found - } else { - self.metrics.plain - }; + let heads = heads_group(&self.filter, &rows, index); + let without_thumb = if heads { self.metrics.head } else { 0.0 } + + if self.open.get() == Some(index) { + self.open_of_row(row) + } else if was_found(row) { + self.metrics.found + } else { + self.shut_height() + }; let meta = self.meta.borrow(); let mut card = card_of(row, self.now, meta.get(&row.id)); drop(meta); - if heads_group(&self.filter, &rows, index) { + if heads { card.heads_group = true; card.group_said = row.group.clone().into(); } diff --git a/crates/cp-panel/src/model_test.rs b/crates/cp-panel/src/model_test.rs index 3d9854c3..37d0df93 100644 --- a/crates/cp-panel/src/model_test.rs +++ b/crates/cp-panel/src/model_test.rs @@ -1,6 +1,7 @@ use super::*; const SIZES: Metrics = Metrics { + head: 23.0, tall: 146.0, json: 112.0, plain: 86.0, @@ -20,7 +21,7 @@ fn store_with(count: usize) -> Rc { } fn open(store: Rc, now: i64) -> Rc { - Rows::open(store, Filter::default(), now, SIZES) + Rows::open(store, Filter::default(), now, SIZES, false) } #[test] @@ -83,7 +84,7 @@ fn a_query_that_matches_nothing_is_an_empty_model_not_an_error() { query: Some("nada-de-esto".into()), ..Default::default() }; - let rows = Rows::open(store_with(10), filter, 0, SIZES); + let rows = Rows::open(store_with(10), filter, 0, SIZES, false); assert_eq!(rows.row_count(), 0); assert!(rows.exhausted.get()); } @@ -231,10 +232,15 @@ fn a_json_only_list_gives_its_rows_the_taller_shut_height() { ..Default::default() }; - assert_eq!(shut_height_for(&general, &metrics), 86.0); - assert_eq!(shut_height_for(&only_json, &metrics), 112.0); + assert_eq!(shut_height_for(&general, &metrics, false), 86.0); + assert_eq!(shut_height_for(&only_json, &metrics, false), 112.0); assert_eq!( - shut_height_for(&mixed, &metrics), + shut_height_for(&only_json, &metrics, true), + 86.0, + "read raw, a json row is as tall as any other" + ); + assert_eq!( + shut_height_for(&mixed, &metrics, false), 86.0, "two kinds is the general layout, and the general height" ); @@ -340,3 +346,68 @@ fn a_group_nobody_could_name_heads_nothing() { assert!(!heads_group(&linked, &rows, 0)); assert!(!heads_group(&linked, &rows, 1)); } + +#[test] +fn the_row_that_heads_a_group_is_taller_by_exactly_its_heading() { + let store = Store::in_memory().expect("esquema"); + for (at, group) in ["a.test", "a.test", "b.test"].iter().enumerate() { + let id = store + .insert_text( + &format!("u{at}"), + &format!("https://{group}/{at}"), + at as i64, + ) + .expect("insert"); + store.set_group(id, group).expect("grouped"); + } + let grouped = Filter { + order: cp_store::Order::ByGroup, + ..Default::default() + }; + let rows = Rows::open(Rc::new(store), grouped, 1_000, SIZES, false); + assert_eq!(rows.row_count(), 3); + let mut heading = 0; + for at in 0..rows.row_count() { + let card = rows.row_data(at).expect("a card"); + let span = rows.span_of(at).expect("a row").1; + let wanted = if card.heads_group { + heading += 1; + SIZES.plain + SIZES.head + } else { + SIZES.plain + }; + assert_eq!( + span, wanted, + "row {at} draws its heading as {} and measures {span}", + card.heads_group + ); + } + assert_eq!(heading, 2, "two groups, two headings"); +} + +#[test] +fn a_filter_on_one_kind_still_comes_back_grouped() { + let store = Store::in_memory().expect("esquema"); + for at in 0..3 { + let id = store + .insert_text( + &format!("u{at}"), + &format!("https://una.test/{at}"), + at as i64, + ) + .expect("insert"); + store.set_group(id, "una.test").expect("grouped"); + } + let linked = Filter { + kinds: vec![cp_core::kind::Kind::Link], + order: cp_store::Order::ByGroup, + ..Default::default() + }; + let rows = Rows::open(Rc::new(store), linked, 1_000, SIZES, false); + assert_eq!(rows.row_count(), 3, "the three links came back"); + let first = rows.row_data(0).expect("a card"); + assert!(first.heads_group, "the first link heads its domain"); + assert_eq!(first.group_said.as_str(), "una.test"); + let second = rows.row_data(1).expect("a card"); + assert!(!second.heads_group, "same domain, no second heading"); +} diff --git a/crates/cp-panel/src/paired_test.rs b/crates/cp-panel/src/paired_test.rs index 4f94b970..12bd096a 100644 --- a/crates/cp-panel/src/paired_test.rs +++ b/crates/cp-panel/src/paired_test.rs @@ -3,6 +3,7 @@ use crate::model::Metrics; use cp_store::{Filter, Store}; const SIZES: Metrics = Metrics { + head: 23.0, tall: 146.0, json: 112.0, plain: 86.0, @@ -18,7 +19,7 @@ fn grid_over(count: usize) -> (Rc, Rc) { .insert_text(&format!("u{at}"), &format!("imagen {at}"), at as i64) .expect("insert"); } - let rows = Rows::open(Rc::new(store), Filter::default(), 1_000, SIZES); + let rows = Rows::open(Rc::new(store), Filter::default(), 1_000, SIZES, false); let grid = Paired::over(rows.clone()); (rows, grid) } diff --git a/crates/cp-panel/src/tags_test.rs b/crates/cp-panel/src/tags_test.rs index 47931d51..57bff116 100644 --- a/crates/cp-panel/src/tags_test.rs +++ b/crates/cp-panel/src/tags_test.rs @@ -28,7 +28,10 @@ fn a_plain_click_never_grows_the_filter() { #[test] fn clicking_the_only_one_chosen_takes_the_filter_off() { - assert!(after(&these(&["image"]), "image", false).is_empty()); + assert!( + after(&these(&["image"]), "image", false).is_empty(), + "the same kind twice means no filter at all" + ); } #[test] @@ -68,7 +71,7 @@ fn adding_respects_the_order_they_were_chosen_in() { #[test] fn nothing_is_ever_chosen_twice() { let once = after(&these(&["image"]), "image", true); - assert!(once.is_empty()); + assert!(once.is_empty(), "adding one that is in takes it out"); let twice = after(&these(&["image", "text"]), "text", false); assert_eq!(twice, these(&["text"])); } diff --git a/crates/cp-panel/src/view.rs b/crates/cp-panel/src/view.rs index b1439c6c..03542eb0 100644 --- a/crates/cp-panel/src/view.rs +++ b/crates/cp-panel/src/view.rs @@ -369,11 +369,15 @@ pub fn chips_of(facets: &[Facet], selected: &[String]) -> Vec { } fn chip(key: &str, label: &str, count: i64, selected: bool) -> Chip { + let of_kind = cp_core::kind::Kind::from_name(key) + .map(|kind| crate::layout::layout_for(&[kind])) + .unwrap_or(crate::layout::Layout::Everything); Chip { key: key.into(), label: label.into(), count: compact(count).into(), selected, + has_ways: !crate::ways::ways_of(of_kind).is_empty(), } } @@ -469,13 +473,7 @@ fn count_in(english: bool, count: i64) -> String { pub fn dress_words(ui: &crate::Panel) { use slint::ComponentHandle; let words = ui.global::(); - words.set_hint( - crate::say::pick( - "Busca o filtra con # en el portapapeles", - "Search the clipboard, or filter with #", - ) - .into(), - ); + words.set_hint(crate::say::pick("Busca o filtra con #", "Search, or filter with #").into()); words.set_footer( crate::say::pick( "pegar · alt+enter: más formas", @@ -487,6 +485,8 @@ pub fn dress_words(ui: &crate::Panel) { words.set_pin(crate::say::pick("anclar", "pin").into()); words.set_unpin(crate::say::pick("desanclar", "unpin").into()); words.set_remove(crate::say::pick("borrar", "delete").into()); + words.set_one_kind(crate::say::pick("uno", "one").into()); + words.set_many_kinds(crate::say::pick("varios", "many").into()); } #[cfg(test)] diff --git a/crates/cp-panel/src/ways.rs b/crates/cp-panel/src/ways.rs index 390fae0c..b4722220 100644 --- a/crates/cp-panel/src/ways.rs +++ b/crates/cp-panel/src/ways.rs @@ -6,6 +6,7 @@ pub struct Way { pub es: &'static str, pub en: &'static str, pub recent: bool, + pub plain: bool, } const KEYS: Way = Way { @@ -13,54 +14,63 @@ const KEYS: Way = Way { es: "Claves", en: "Keys", recent: false, + plain: false, }; const RAW: Way = Way { key: "raw", es: "Crudo", en: "Raw", recent: false, + plain: true, }; const GRID: Way = Way { key: "grid", es: "Rejilla", en: "Grid", recent: false, + plain: false, }; const ROWS: Way = Way { key: "rows", es: "Lista", en: "List", recent: false, + plain: false, }; const COVER: Way = Way { key: "cover", es: "Carátula", en: "Cover", recent: false, + plain: false, }; const WAVE: Way = Way { key: "wave", es: "Onda", en: "Wave", recent: false, + plain: false, }; const TIGHT: Way = Way { key: "tight", es: "Compacto", en: "Compact", recent: false, + plain: true, }; const BY_GROUP: Way = Way { key: "by-group", es: "Por grupo", en: "By group", recent: false, + plain: false, }; const NEWEST: Way = Way { key: "newest", es: "Recientes", en: "Newest", recent: true, + plain: false, }; pub fn ways_of(layout: Layout) -> &'static [Way] { diff --git a/crates/cp-panel/src/ways_test.rs b/crates/cp-panel/src/ways_test.rs index 10e65596..eafc61ca 100644 --- a/crates/cp-panel/src/ways_test.rs +++ b/crates/cp-panel/src/ways_test.rs @@ -65,6 +65,39 @@ fn a_way_from_another_view_is_not_honoured_in_this_one() { ); } +#[test] +fn only_the_two_ways_that_ask_for_the_general_body_are_plain() { + assert!( + chosen(Layout::Json, "raw").plain, + "raw json is read as text" + ); + assert!(chosen(Layout::Audio, "tight").plain); + assert!(chosen(Layout::Video, "tight").plain); + for layout in Layout::ALL { + for way in ways_of(layout) { + let expected = way.key == "raw" || way.key == "tight"; + assert_eq!( + way.plain, expected, + "{layout:?} {} disagrees about yielding the body", + way.key + ); + } + } +} + +#[test] +fn a_plain_way_is_never_an_ordering() { + for layout in Layout::ALL { + for way in ways_of(layout) { + assert!( + !(way.plain && way.recent), + "{} cannot both yield the body and change the order", + way.key + ); + } + } +} + #[test] fn nothing_is_left_untranslated() { for layout in Layout::ALL { diff --git a/crates/cp-panel/ui/bodies_link.slint b/crates/cp-panel/ui/bodies_link.slint index 6629a5a7..add25f5a 100644 --- a/crates/cp-panel/ui/bodies_link.slint +++ b/crates/cp-panel/ui/bodies_link.slint @@ -15,18 +15,10 @@ export component LinkBody inherits VerticalLayout { } Text { - text: card.squeezed; + text: card.link-path != "" ? card.link-path : card.squeezed; color: Theme.body; font-size: 12.5px; overflow: elide; wrap: no-wrap; } - - if card.link-path != "" : Text { - text: card.link-path; - color: Theme.placeholder; - font-size: 10.5px; - overflow: elide; - wrap: no-wrap; - } } diff --git a/crates/cp-panel/ui/cards.slint b/crates/cp-panel/ui/cards.slint index 996c2c63..371c1b68 100644 --- a/crates/cp-panel/ui/cards.slint +++ b/crates/cp-panel/ui/cards.slint @@ -92,8 +92,10 @@ export component Pill inherits Rectangle { accessible-role: AccessibleRole.button; accessible-label: chip.label; callback chosen(string, bool); + callback asked-ways(string); + property shows-arrow: chip.selected && chip.has-ways; height: 24px; - width: content.preferred-width; + width: content.preferred-width + (shows-arrow ? 16px : 0px); border-radius: 12px; background: chip.selected ? Theme.selected : touch.has-hover ? #242840 : Theme.field; border-width: 1px; @@ -124,6 +126,23 @@ export component Pill inherits Rectangle { } clicked => { root.chosen(chip.key, touch.adding); } } + + if shows-arrow : Rectangle { + x: parent.width - 17px; + width: 17px; + height: parent.height; + Image { + source: @image-url("../assets/icons/chevron-down.svg"); + width: 11px; + height: 11px; + colorize: arrow.has-hover ? Theme.body : Theme.accent; + x: 0; + y: (parent.height - 11px) / 2; + } + arrow := TouchArea { + clicked => { root.asked-ways(chip.key); } + } + } } export component Tag inherits Rectangle { @@ -170,8 +189,7 @@ export component Tag inherits Rectangle { export component CardView inherits Rectangle { in property card; in property layout; - in property way; - property plain-way: way == "raw" || way == "tight"; + in property plain-way; property special: !plain-way && (layout == "json" || layout == "video" || layout == "audio" || layout == "papers" || layout == "folder" || layout == "link"); in property current; @@ -206,26 +224,6 @@ export component CardView inherits Rectangle { padding: 10px; spacing: 8px; - if card.heads-group : HorizontalLayout { - height: 15px; - spacing: 7px; - Text { - text: card.group-said; - color: Theme.title; - font-size: 11px; - font-weight: 600; - vertical-alignment: center; - } - Rectangle { - horizontal-stretch: 1; - Rectangle { - y: (parent.height - 1px) / 2; - height: 1px; - background: Theme.panel-border; - } - } - } - if card.has-thumb : Rectangle { height: 96px; border-radius: 8px; diff --git a/crates/cp-panel/ui/panel.slint b/crates/cp-panel/ui/panel.slint index 4d15b7f6..2b1592b4 100644 --- a/crates/cp-panel/ui/panel.slint +++ b/crates/cp-panel/ui/panel.slint @@ -1,11 +1,11 @@ import { ListView } from "std-widgets.slint"; import { GridCell } from "bodies_grid.slint"; -import { Card, Chip, FormRow, WayPill } from "types.slint"; +import { Card, Chip, FormRow } from "types.slint"; import { Theme } from "theme.slint"; import { Words } from "words.slint"; import { Icon, Action, Pill, Tag, CardView, Hue } from "cards.slint"; -export { Card, Chip, FormRow, WayPill, Theme, Words } +export { Card, Chip, FormRow, Theme, Words } export component Panel inherits Window { width: Theme.width + Theme.margin * 2; @@ -26,11 +26,15 @@ export component Panel inherits Window { in property sheet-subject; in property sheet-kind; in property layout: "everything"; - in property way-a; - in property way-b; - in-out property way; - callback way-chosen(string); + in property shut: Theme.row-plain; + in property plain-way; + in-out property keeping; + callback keep-toggled(); + in property way; + in property sheet-narrow; + in-out property chip-x; in property count-text; + in property way-said; in property pinned-count; in property empty-title; in property empty-hint; @@ -55,6 +59,7 @@ export component Panel inherits Window { callback search(string); callback chip-chosen(string, bool); + callback ask-ways-for(string); callback pin-filter(); callback drop-tag(string); callback clear-filters(); @@ -134,7 +139,12 @@ export component Panel inherits Window { padding-top: 2px; padding-bottom: 8px; spacing: 9px; + HorizontalLayout { + height: 34px; + spacing: 8px; + alignment: stretch; search-box := Rectangle { + horizontal-stretch: 1; height: 34px; border-radius: 10px; background: Theme.field; @@ -289,120 +299,90 @@ export component Panel inherits Window { font-size: 13px; } } - Rectangle { - width: 22px; - height: 22px; - border-radius: 6px; - background: kinds-touch.has-hover ? #333853 : transparent; - y: (parent.height - 22px) / 2; - Icon { - source: @image-url("../assets/icons/sliders-horizontal.svg"); - tint: kinds-touch.has-hover ? Theme.body : Theme.placeholder; - } - kinds-touch := TouchArea { - clicked => { root.ask-kinds(); } - } - } } } - HorizontalLayout { - height: 24px; - spacing: 8px; - alignment: stretch; - pin := Rectangle { - width: pin-row.preferred-width; - height: 24px; - border-radius: 12px; - background: root.pinned-on ? Theme.selected : pin-touch.has-hover ? #242840 : Theme.field; - border-width: 1px; - border-color: root.pinned-on ? Theme.selected-edge : Theme.field-border; - animate background { duration: 120ms; easing: ease-out; } - animate border-color { duration: 120ms; easing: ease-out; } - pin-row := HorizontalLayout { - padding-left: 9px; - padding-right: 9px; - spacing: 5px; - alignment: center; + Rectangle { + width: 24px; + height: 24px; + border-radius: 12px; + y: (parent.height - 24px) / 2; + background: root.keeping + ? Theme.selected + : keep-touch.has-hover ? #242840 : Theme.field; + border-width: 1px; + border-color: root.keeping ? Theme.selected-edge : Theme.field-border; + accessible-role: AccessibleRole.button; + accessible-label: root.keeping ? Words.many-kinds : Words.one-kind; + accessible-checked: root.keeping; + animate background, border-color { duration: 120ms; easing: ease-out; } Icon { - source: @image-url("../assets/icons/pin.svg"); - width: 12px; - height: 12px; - tint: root.pinned-on ? Theme.accent : Theme.muted; - y: (parent.height - 12px) / 2; + source: @image-url("../assets/icons/layers.svg"); + width: 13px; + height: 13px; + tint: root.keeping ? Theme.accent : Theme.muted; } - Text { - text: root.pinned-count; - color: root.pinned-on ? Theme.accent-dim : Theme.muted; - font-size: 10px; - vertical-alignment: center; + keep-touch := TouchArea { + clicked => { root.keep-toggled(); } } } - pin-touch := TouchArea { - clicked => { root.pin-filter(); } - } - } - if root.filtered : Rectangle { - width: 24px; - height: 24px; - border-radius: 12px; - background: broom-touch.has-hover ? #242840 : Theme.field; - border-width: 1px; - border-color: Theme.field-border; - Icon { - source: @image-url("../assets/icons/x.svg"); - width: 12px; - height: 12px; - tint: broom-touch.has-hover ? Theme.body : Theme.muted; - } - broom-touch := TouchArea { - clicked => { root.clear-filters(); } - } - } - Rectangle { - width: 1px; - Rectangle { - y: 5px; - width: 1px; - height: 14px; - background: Theme.field-border; - } - } - if root.way-a.says != "" : HorizontalLayout { - spacing: 6px; - alignment: end; - for pill in [root.way-a, root.way-b] : Rectangle { - width: said.preferred-width; - height: 22px; - border-radius: 11px; - background: root.way == pill.key || (root.way == "" && pill.key == root.way-a.key) - ? Theme.selected - : picked.has-hover ? #242840 : Theme.field; + pin := Rectangle { + width: pin-row.preferred-width; + height: 24px; + border-radius: 12px; + y: (parent.height - 24px) / 2; + background: root.pinned-on ? Theme.selected : pin-touch.has-hover ? #242840 : Theme.field; border-width: 1px; - border-color: root.way == pill.key || (root.way == "" && pill.key == root.way-a.key) - ? Theme.selected-edge - : Theme.field-border; - y: (parent.height - 22px) / 2; - said := HorizontalLayout { - padding-left: 9px; - padding-right: 9px; + border-color: root.pinned-on ? Theme.selected-edge : Theme.field-border; + accessible-role: AccessibleRole.button; + accessible-label: root.pinned-on ? Words.unpin : Words.pin; + animate background, border-color { duration: 120ms; easing: ease-out; } + pin-row := HorizontalLayout { + padding-left: 8px; + padding-right: 8px; + spacing: 5px; + alignment: center; + Icon { + source: @image-url("../assets/icons/pin.svg"); + width: 12px; + height: 12px; + tint: root.pinned-on ? Theme.accent : Theme.muted; + y: (parent.height - 12px) / 2; + } Text { - text: pill.says; - color: root.way == pill.key || (root.way == "" && pill.key == root.way-a.key) - ? Theme.accent - : Theme.muted; + text: root.pinned-count; + color: root.pinned-on ? Theme.accent-dim : Theme.muted; font-size: 10px; vertical-alignment: center; } } - picked := TouchArea { - clicked => { - root.way = pill.key; - root.way-chosen(pill.key); - } + pin-touch := TouchArea { + clicked => { root.pin-filter(); } } } - } - + if root.filtered : Rectangle { + width: 24px; + height: 24px; + border-radius: 12px; + y: (parent.height - 24px) / 2; + background: broom-touch.has-hover ? #242840 : Theme.field; + border-width: 1px; + border-color: Theme.field-border; + accessible-role: AccessibleRole.button; + Icon { + source: @image-url("../assets/icons/x.svg"); + width: 12px; + height: 12px; + tint: broom-touch.has-hover ? Theme.body : Theme.muted; + } + broom-touch := TouchArea { + clicked => { root.clear-filters(); } + } + } + } + HorizontalLayout { + height: 24px; + spacing: 8px; + alignment: stretch; Rectangle { horizontal-stretch: 1; clip: true; @@ -426,7 +406,13 @@ export component Panel inherits Window { alignment: start; for chip in root.chips : Pill { chip: chip; - chosen(key, adding) => { root.chip-chosen(key, adding); } + chosen(key, adding) => { + root.chip-chosen(key, adding); + } + asked-ways(key) => { + root.chip-x = self.absolute-position.x - panel.absolute-position.x; + root.ask-ways-for(key); + } } } } @@ -554,17 +540,43 @@ export component Panel inherits Window { root.sheet-span = self.height; } } - height: card.has-thumb + height: (card.heads-group ? Theme.row-head : 0px) + (card.has-thumb ? Theme.row-thumb : index == root.current && root.opened ? Theme.row-frame + card.lines * Theme.line - : card.found ? Theme.row-found : Theme.row-plain; + : card.found ? Theme.row-found : root.shut); animate height { duration: root.gliding ? Theme.settle : 0ms; easing: ease-out; } + VerticalLayout { + if card.heads-group : Rectangle { + height: Theme.row-head; + HorizontalLayout { + height: 15px; + padding-left: 10px; + padding-right: 10px; + spacing: 7px; + Text { + text: card.group-said; + color: Theme.title; + font-size: 11px; + font-weight: 600; + vertical-alignment: center; + } + Rectangle { + horizontal-stretch: 1; + Rectangle { + y: (parent.height - 1px) / 2; + height: 1px; + background: Theme.panel-border; + } + } + } + } CardView { card: card; current: index == root.current; open: index == root.current && root.opened; shown-here: root.hovered >= 0 ? index == root.hovered : index == root.current; + plain-way: root.plain-way; stirred => { root.pointer-hidden = false; if (!root.sheet-open) { @@ -586,7 +598,6 @@ export component Panel inherits Window { root.moved(index); } layout: root.layout; - way: root.way; paste(id) => { root.paste(id); } forms(id) => { root.current = index; @@ -598,6 +609,7 @@ export component Panel inherits Window { pin(id, on) => { root.pin(id, on); } remove(id) => { root.remove(id); } } + } } } } @@ -675,6 +687,12 @@ export component Panel inherits Window { alignment: stretch; Icon { source: @image-url("../assets/icons/clipboard.svg"); tint: Theme.muted; y: (parent.height - 15px) / 2; } Text { text: root.count-text; color: Theme.muted; font-size: 11.5px; vertical-alignment: center; } + if root.way-said != "" : Text { + text: "· " + root.way-said; + color: Theme.placeholder; + font-size: 11px; + vertical-alignment: center; + } Rectangle { horizontal-stretch: 1; } Rectangle { clip: true; @@ -697,13 +715,15 @@ export component Panel inherits Window { } if root.sheet-open : Rectangle { - x: 10px; + x: root.sheet-narrow + ? clamp(root.chip-x, 10px, max(10px, parent.width - self.width - 10px)) + : 10px; y: clamp( - panel.sheet-top, + root.sheet-narrow ? root.list-top + 4px : panel.sheet-top, 10px, max(10px, parent.height - self.height - 10px) ); - width: parent.width - 20px; + width: root.sheet-narrow ? 168px : parent.width - 20px; height: min(sheet.preferred-height, parent.height - 20px); background: Theme.field; border-radius: 12px; diff --git a/crates/cp-panel/ui/theme.slint b/crates/cp-panel/ui/theme.slint index 36b43c3a..2578450e 100644 --- a/crates/cp-panel/ui/theme.slint +++ b/crates/cp-panel/ui/theme.slint @@ -25,6 +25,7 @@ export global Theme { out property row-thumb: 146px; out property row-plain: 86px; out property row-json: 112px; + out property row-head: 23px; out property cell: 150px; out property row-found: 68px; out property row-frame: 50px; diff --git a/crates/cp-panel/ui/types.slint b/crates/cp-panel/ui/types.slint index de0f7611..a8ab0dca 100644 --- a/crates/cp-panel/ui/types.slint +++ b/crates/cp-panel/ui/types.slint @@ -35,16 +35,12 @@ export struct Card { broken: bool, } -export struct WayPill { - key: string, - says: string, -} - export struct Chip { key: string, label: string, count: string, selected: bool, + has-ways: bool, } export struct FormRow { diff --git a/crates/cp-panel/ui/words.slint b/crates/cp-panel/ui/words.slint index 98c2ecdc..47b531e6 100644 --- a/crates/cp-panel/ui/words.slint +++ b/crates/cp-panel/ui/words.slint @@ -5,4 +5,6 @@ export global Words { in property pin; in property unpin; in property remove; + in property one-kind; + in property many-kinds; } From 4ea4f0964aa2f8c681cdfa90dc67a7215e2f4497 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Thu, 1 Oct 2026 10:32:49 -0300 Subject: [PATCH 22/22] test: what extreme data does to identity, audio, backups and settings --- crates/cp-config/src/lib_test.rs | 111 +++++++ crates/cp-core/src/dib_test.rs | 44 +++ crates/cp-core/src/item_test.rs | 277 ++++++++++++++++++ crates/cp-core/src/kind_test.rs | 66 +++++ crates/cp-core/src/paste_as_test.rs | 58 ++++ crates/cp-core/src/search_test.rs | 65 ++++ crates/cp-core/src/token_test.rs | 35 +++ crates/cp-panel/src/folder_test.rs | 56 ++++ crates/cp-panel/src/group_test.rs | 12 + crates/cp-panel/src/tags_test.rs | 19 ++ crates/cp-panel/src/wave_test.rs | 117 ++++++++ crates/cp-store/src/backup_test.rs | 93 ++++++ crates/cp-store/src/query_test.rs | 69 +++++ .../cp-store/src/store_housekeeping_test.rs | 25 ++ crates/cp-store/src/store_identity_test.rs | 62 ++++ crates/cp-store/src/store_listing_test.rs | 267 +++++++++++++++++ crates/cp-store/src/store_test.rs | 89 ++++++ 17 files changed, 1465 insertions(+) diff --git a/crates/cp-config/src/lib_test.rs b/crates/cp-config/src/lib_test.rs index 767c4d8c..75b15818 100644 --- a/crates/cp-config/src/lib_test.rs +++ b/crates/cp-config/src/lib_test.rs @@ -106,6 +106,60 @@ fn a_file_that_is_not_toml_says_so_instead_of_starting_over() { assert!(why.to_string().contains("TOML"), "{why}"); } +#[test] +fn an_unknown_key_is_ignored_not_rejected() { + let dir = a_dir(); + let path = at(dir.path()); + std::fs::write( + &path, + "theme = \"dark\"\nthis-setting-does-not-exist = \"whatever\"\n", + ) + .expect("writes"); + let config = read(&path).expect("an unknown key does not refuse the whole file"); + assert_eq!(config.theme, Theme::Dark); +} + +#[test] +fn a_value_of_the_wrong_type_is_reported_as_malformed_not_guessed() { + let dir = a_dir(); + let path = at(dir.path()); + std::fs::write(&path, "keeps-days = \"treinta\"\n").expect("writes"); + let why = read(&path).expect_err("a string is not a count of days"); + assert!(matches!(why, Error::Malformed(_)), "{why}"); +} + +#[test] +fn a_read_only_file_cannot_be_overwritten() { + let dir = a_dir(); + let path = at(dir.path()); + write(&path, &Config::default()).expect("writes the first time"); + let mut permissions = std::fs::metadata(&path).expect("stat").permissions(); + permissions.set_readonly(true); + std::fs::set_permissions(&path, permissions).expect("marked read-only"); + + let outcome = write( + &path, + &Config { + theme: Theme::Dark, + ..Config::default() + }, + ); + assert!( + outcome.is_err(), + "a read-only settings file must not be silently replaced" + ); + assert_eq!( + read(&path).expect("still readable").theme, + Theme::System, + "the previous, still read-only content is the one that survives" + ); + + let mut permissions = std::fs::metadata(&path).expect("stat").permissions(); + #[allow(clippy::permissions_set_readonly_false)] + permissions.set_readonly(false); + std::fs::set_permissions(&path, permissions).expect("cleared for cleanup"); +} + #[test] fn a_directory_where_the_file_should_be_is_not_mistaken_for_an_empty_one() { let dir = a_dir(); @@ -150,6 +204,22 @@ fn the_shortcut_loses_the_spaces_around_it() { assert_eq!(asked.shortcut, "Ctrl+Shift+V"); } +#[test] +fn a_shortcut_that_matches_no_real_key_combination_is_accepted_untouched() { + let asked = Config { + shortcut: "this is not a shortcut".into(), + ..Config::default() + } + .sane(); + assert_eq!( + asked.shortcut, "this is not a shortcut", + "sane() only resets an empty or blank shortcut to the default; it never checks \ + that what is left looks like Ctrl/Alt/Shift plus a key, so garbage from a hand \ + edited config.toml reaches the rest of the app unchanged, and whether a given \ + combination is already taken by the OS or another app is never checked here at all" + ); +} + #[test] fn the_file_reads_as_a_person_would_write_it() { let dir = a_dir(); @@ -189,3 +259,44 @@ fn the_path_hangs_from_the_folder_it_is_given() { let where_it_is = at(Path::new("anywhere")); assert_eq!(where_it_is, Path::new("anywhere").join("config.toml")); } + +#[test] +fn an_existing_but_empty_file_reads_as_the_defaults() { + let dir = a_dir(); + let path = at(dir.path()); + std::fs::write(&path, "").expect("writes"); + assert_eq!(read(&path).expect("reads"), Config::default()); +} + +#[test] +fn a_boolean_where_a_string_is_expected_is_rejected() { + let dir = a_dir(); + let path = at(dir.path()); + std::fs::write(&path, "shortcut = true\n").expect("writes"); + assert!(matches!(read(&path), Err(Error::Malformed(_)))); +} + +#[test] +fn a_negative_count_is_a_parse_error_not_something_sane_clamps_to_zero() { + let dir = a_dir(); + let path = at(dir.path()); + std::fs::write(&path, "keeps-days = -1\n").expect("writes"); + assert!( + matches!(read(&path), Err(Error::Malformed(_))), + "only an explicit 0 is normalised by sane(); a negative count never gets that far \ + because keeps-days is unsigned, so it fails to parse at all instead" + ); +} + +#[test] +fn the_largest_representable_counts_round_trip() { + let dir = a_dir(); + let path = at(dir.path()); + let mine = Config { + keeps_days: Some(u16::MAX), + images_quota_mb: Some(u32::MAX), + ..Config::default() + }; + write(&path, &mine).expect("writes"); + assert_eq!(read(&path).expect("reads"), mine); +} diff --git a/crates/cp-core/src/dib_test.rs b/crates/cp-core/src/dib_test.rs index bc3e94cf..4afbd852 100644 --- a/crates/cp-core/src/dib_test.rs +++ b/crates/cp-core/src/dib_test.rs @@ -531,3 +531,47 @@ fn a_truncated_bitmap_is_refused_not_panicked_on() { let _ = to_png(short); } } + +fn negative_width_rgb32(width: i32, height: i32) -> Vec { + let pixels = [0x40u8, 0x80, 0xC0, 0xFF] + .repeat(width.unsigned_abs() as usize * height.unsigned_abs() as usize); + let mut out = Vec::new(); + out.extend_from_slice(&(INFO_HEADER as u32).to_le_bytes()); + out.extend_from_slice(&width.to_le_bytes()); + out.extend_from_slice(&height.to_le_bytes()); + out.extend_from_slice(&1u16.to_le_bytes()); + out.extend_from_slice(&32u16.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&(pixels.len() as u32).to_le_bytes()); + out.extend_from_slice(&2835i32.to_le_bytes()); + out.extend_from_slice(&2835i32.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.resize(INFO_HEADER, 0); + out.extend_from_slice(&pixels); + out +} + +#[test] +fn a_negative_width_is_read_by_alpha_but_refused_by_to_png() { + let dib = negative_width_rgb32(-4, 4); + assert_eq!(header(&dib).map(|head| head.width), Some(-4)); + assert_ne!( + alpha(&dib), + Alpha::Absent, + "a negative width is accepted via its magnitude and the pixels are read anyway" + ); + assert_eq!( + to_png(&dib), + None, + "the same negative width is refused once it reaches the BMP decoder, so alpha() \ + and to_png() disagree about whether this bitmap is usable" + ); +} + +#[test] +fn a_zero_width_or_height_is_refused_by_to_png() { + for dib in [negative_width_rgb32(0, 4), negative_width_rgb32(4, 0)] { + assert_eq!(to_png(&dib), None); + } +} diff --git a/crates/cp-core/src/item_test.rs b/crates/cp-core/src/item_test.rs index d5df0e54..4084de85 100644 --- a/crates/cp-core/src/item_test.rs +++ b/crates/cp-core/src/item_test.rs @@ -269,3 +269,280 @@ fn only_what_was_really_kept_counts_towards_the_size() { }; assert_eq!(item.stored_bytes(), 1000); } + +#[test] +fn capitalisation_changes_the_identity_of_plain_text() { + assert_ne!( + Item::plain("TEXTO").fingerprint(), + Item::plain("texto").fingerprint(), + "the same word in another case is a different capture" + ); +} + +#[test] +fn a_lone_nul_byte_does_not_panic_and_still_tells_content_apart() { + assert_ne!( + Item::plain("a\0b").fingerprint(), + Item::plain("ab").fingerprint() + ); + assert_eq!( + Item::plain("a\0b").fingerprint(), + Item::plain("a\0b").fingerprint() + ); +} + +#[test] +fn crlf_and_lf_line_endings_are_different_identities() { + assert_ne!( + Item::plain("one\r\ntwo").fingerprint(), + Item::plain("one\ntwo").fingerprint(), + "line endings are bytes like any other; nothing normalises them here" + ); +} + +#[test] +fn emoji_only_content_has_a_stable_and_distinct_identity() { + assert_eq!( + Item::plain("🚀🎉").fingerprint(), + Item::plain("🚀🎉").fingerprint() + ); + assert_ne!( + Item::plain("🚀🎉").fingerprint(), + Item::plain("🚀").fingerprint() + ); +} + +#[test] +fn right_to_left_and_mixed_script_text_hashes_byte_for_byte() { + let arabic = "مرحبا بالعالم"; + let hebrew = "שלום עולם"; + assert_eq!( + Item::plain(arabic).fingerprint(), + Item::plain(arabic).fingerprint() + ); + assert_ne!( + Item::plain(arabic).fingerprint(), + Item::plain(hebrew).fingerprint() + ); + assert_ne!( + Item::plain("hello مرحبا").fingerprint(), + Item::plain("مرحبا hello").fingerprint(), + "the same two scripts in another order are different content" + ); +} + +#[test] +fn the_same_letter_in_two_unicode_normal_forms_is_two_different_identities() { + let nfc = "caf\u{00e9}"; + let nfd = "cafe\u{0301}"; + assert_ne!( + nfc.as_bytes(), + nfd.as_bytes(), + "the two spellings really are different bytes" + ); + assert_ne!( + Item::plain(nfc).fingerprint(), + Item::plain(nfd).fingerprint(), + "the identity is raw bytes, with no Unicode normalisation; the same visible word \ + typed or pasted from a source that prefers the other normal form becomes a second item" + ); +} + +fn rich(plain: &str, html: &str, rtf: &str) -> Item { + Item { + kind: Some(crate::kind::Kind::Text), + formats: vec![ + Format { + id: "public.utf8-plain-text".into(), + payload: Payload::Inline(plain.as_bytes().to_vec()), + }, + Format { + id: "public.html".into(), + payload: Payload::Inline(html.as_bytes().to_vec()), + }, + Format { + id: "public.rtf".into(), + payload: Payload::Inline(rtf.as_bytes().to_vec()), + }, + ], + } +} + +fn plain_only(text: &str) -> Item { + Item { + kind: Some(crate::kind::Kind::Text), + formats: vec![Format { + id: "public.utf8-plain-text".into(), + payload: Payload::Inline(text.as_bytes().to_vec()), + }], + } +} + +#[test] +fn the_plain_text_that_comes_out_of_a_rich_copy_has_an_identity_of_its_own() { + let styled = rich("hello", "hello", r"{\rtf1 hello}"); + let replayed = plain_only("hello"); + assert_ne!( + styled.fingerprint(), + replayed.fingerprint(), + "the rich capture mixes its markup into the identity, so the plain text \ + pasted out of it and copied again is a second item" + ); + assert_eq!( + replayed.fingerprint(), + plain_only("hello").fingerprint(), + "and that second item is stable, so copying it again does not pile up" + ); +} + +#[test] +fn only_the_rendering_changing_leaves_the_identity_alone() { + let one = rich("hello", "hello", r"{\rtf1\ansi hello}"); + let other = rich("hello", "hello", r"{\rtf1\mac\deff0 hello}"); + assert_ne!(one, other); + assert_eq!( + one.fingerprint(), + other.fingerprint(), + "two copies of the same paragraph that only differ in their RTF are one item" + ); +} + +#[test] +fn the_markup_changing_is_a_different_item_even_with_the_same_plain_text() { + let one = rich("hello", "hello", r"{\rtf1 hello}"); + let other = rich("hello", "hello", r"{\rtf1 hello}"); + assert_ne!( + one.fingerprint(), + other.fingerprint(), + "bold and italic are not the same copy" + ); +} + +#[test] +fn letter_case_is_content_and_not_a_spelling() { + assert_ne!( + Item::plain("TEXTO").fingerprint(), + Item::plain("texto").fingerprint() + ); +} + +#[test] +fn the_line_endings_of_a_text_are_part_of_what_was_copied() { + assert_ne!( + Item::plain("a\r\nb").fingerprint(), + Item::plain("a\nb").fingerprint(), + "the same two lines copied from a Windows editor and from a Unix one are two items" + ); +} + +#[test] +fn a_byte_order_mark_is_content_like_any_other_byte() { + assert_ne!( + Item::plain("\u{feff}hello").fingerprint(), + Item::plain("hello").fingerprint() + ); +} + +#[test] +fn nothing_copied_and_an_empty_text_are_told_apart() { + let nothing = Item { + kind: None, + formats: vec![], + }; + assert_ne!(Item::plain("").fingerprint(), nothing.fingerprint()); + assert_eq!(Item::plain("").stored_bytes(), 0); +} + +#[test] +fn control_characters_travel_whole_into_the_identity() { + let with_nul = Item::plain("a\0b"); + assert_eq!( + with_nul.stored_bytes(), + 3, + "the nul is a byte like any other" + ); + assert_ne!(with_nul.fingerprint(), Item::plain("ab").fingerprint()); + for odd in ["\u{7}", "\u{1b}[31m", "\u{200b}", "\u{202e}", "🏳️‍🌈"] { + assert_eq!( + Item::plain(odd).fingerprint(), + Item::plain(odd).fingerprint() + ); + } +} + +#[test] +fn whitespace_that_looks_alike_never_collapses_into_one_item() { + let shapes = [" ", "\n", "\t", "\u{a0}", "\u{3000}"]; + let mut seen: Vec = shapes + .iter() + .map(|one| Item::plain(one).fingerprint()) + .collect(); + seen.sort_unstable(); + seen.dedup(); + assert_eq!( + seen.len(), + shapes.len(), + "none of them collapses into another" + ); +} + +#[test] +fn a_blob_and_an_inline_payload_of_the_same_bytes_are_one_identity() { + let bytes = vec![7u8; 32]; + let inline = Item { + kind: None, + formats: vec![Format { + id: "public.png".into(), + payload: Payload::Inline(bytes.clone()), + }], + }; + let blob = Item { + kind: None, + formats: vec![Format { + id: "public.png".into(), + payload: Payload::Blob(bytes), + }], + }; + assert_eq!( + inline.fingerprint(), + blob.fingerprint(), + "where the bytes were kept is not part of what was copied" + ); +} + +#[test] +fn what_is_too_big_to_keep_leaves_no_identity_behind() { + let refused = Item { + kind: None, + formats: vec![Format { + id: "public.png".into(), + payload: Payload::TooBig { + size: BLOB_UP_TO + 1, + }, + }], + }; + assert_eq!( + refused.fingerprint(), + Item { + kind: None, + formats: vec![] + } + .fingerprint(), + "two different items nobody could keep share one hash, so the first of them \ + answers for every later one" + ); + assert_eq!(refused.stored_bytes(), 0); +} + +#[test] +fn the_exact_size_that_still_fits_in_a_row_is_an_item_like_any_other() { + for size in [INLINE_UP_TO - 1, INLINE_UP_TO, INLINE_UP_TO + 1] { + let item = Item::plain(&"a".repeat(size)); + assert_eq!(item.stored_bytes(), size); + assert!( + !item.needs_blob_store(), + "Item::plain always inlines, whatever placement would say about {size}" + ); + } + assert_eq!(placement(INLINE_UP_TO + 1), Placement::Blob); +} diff --git a/crates/cp-core/src/kind_test.rs b/crates/cp-core/src/kind_test.rs index d8a60939..cfecebb6 100644 --- a/crates/cp-core/src/kind_test.rs +++ b/crates/cp-core/src/kind_test.rs @@ -110,6 +110,30 @@ fn the_edges_of_each_class() { assert_ne!(classify_text("12345"), Kind::Phone); } +#[test] +fn the_four_digit_hex_shorthand_with_alpha_is_not_recognised_as_a_color() { + assert_ne!( + classify_text("#f0a5"), + Kind::Color, + "is_color only accepts hex lengths of 3, 6 or 8, so the CSS #RGBA shorthand \ + with its own alpha channel falls through to plain text" + ); +} + +#[test] +fn rgb_accepts_channel_values_far_outside_the_valid_range() { + assert_eq!( + classify_text("rgb(300, -5, 999)"), + Kind::Color, + "is_color only checks that each part parses as a float, with no bound on 0..=255" + ); + assert_eq!( + classify_text("hsl(1e9, 1e9%, 1e9%)"), + Kind::Color, + "scientific notation parses fine too, however meaningless the hue is" + ); +} + #[test] fn several_lines_never_become_a_single_line_class() { assert_eq!( @@ -147,3 +171,45 @@ fn every_kind_has_a_stable_name_for_the_database() { assert_eq!(kind.as_str(), kind.as_str().to_lowercase()); } } + +#[test] +fn an_uppercase_uuid_is_still_a_uuid() { + assert_eq!( + classify_text("6BA7B810-9DAD-11D1-80B4-00C04FD430C8"), + Kind::Uuid + ); +} + +#[test] +fn a_byte_order_mark_hides_the_json_from_its_own_classifier() { + assert_ne!( + classify_text("\u{FEFF}{\"a\":1}"), + Kind::Json, + "a JSON file saved with a BOM, as Windows editors often do, is no longer \ + recognised as JSON because the first byte is no longer the opening brace" + ); + assert_eq!( + classify_text("{\"a\":1}"), + Kind::Json, + "without the BOM the very same object is read fine, so the BOM is the whole difference" + ); +} + +#[test] +fn a_domain_written_in_its_own_alphabet_is_not_read_as_an_address() { + assert_ne!( + classify_text("alguien@café.test"), + Kind::Email, + "the domain check only accepts ASCII, so an accented domain is not an email here" + ); +} + +#[test] +fn an_extension_after_the_digits_is_not_read_as_a_phone_number() { + assert_ne!( + classify_text("+1 555 123 4567 x123"), + Kind::Phone, + "the trailing «x123» extension is not a digit, a space, or any of «()+-.», so it \ + breaks the shape check" + ); +} diff --git a/crates/cp-core/src/paste_as_test.rs b/crates/cp-core/src/paste_as_test.rs index 6584c481..ff31d7ec 100644 --- a/crates/cp-core/src/paste_as_test.rs +++ b/crates/cp-core/src/paste_as_test.rs @@ -286,6 +286,64 @@ fn json_that_is_not_an_object_has_no_keys_and_no_table() { assert!(forms_for(&broken).is_empty()); } +#[test] +fn json_pretty_printing_keeps_only_the_last_value_for_a_repeated_key() { + let content = text_of(Kind::Json, r#"{"a": 1, "a": 2}"#); + assert_eq!( + rendered(Form::JsonPretty, &content), + "{\n \"a\": 2\n}", + "serde_json's map keeps the last occurrence of a repeated key and discards \ + the earlier one silently, with no warning that anything was dropped" + ); +} + +#[test] +fn a_json_integer_with_more_digits_than_any_native_type_holds_loses_its_exact_value() { + let huge = "123456789012345678901234567890"; + let raw = format!("{{\"n\": {huge}}}"); + let content = text_of(Kind::Json, &raw); + assert!( + forms_for(&content).contains(&Form::JsonPretty), + "it is still read and offered, not refused" + ); + assert_ne!( + rendered(Form::JsonPretty, &content), + format!("{{\n \"n\": {huge}\n}}"), + "without the arbitrary_precision feature, a number wider than u64 or i64 falls \ + back to a lossy f64, so the digits that come back out are not the ones that went in" + ); +} + +#[test] +fn nesting_right_up_to_serde_jsons_depth_limit_still_renders() { + let depth = 127; + let nested = format!("{}1{}", "[".repeat(depth), "]".repeat(depth)); + assert_eq!(crate::kind::classify_text(&nested), Kind::Json); + let content = text_of(Kind::Json, &nested); + let pretty = rendered(Form::JsonPretty, &content); + assert!(pretty.starts_with("[\n")); + assert!(pretty.trim_end().ends_with(']')); +} + +#[test] +fn one_level_past_serde_jsons_depth_limit_is_still_called_json_but_offers_no_json_form() { + let depth = 128; + let nested = format!("{}1{}", "[".repeat(depth), "]".repeat(depth)); + assert_eq!( + crate::kind::classify_text(&nested), + Kind::Json, + "classify_text only checks that the brackets balance, so it keeps calling \ + this JSON regardless of depth" + ); + let content = text_of(Kind::Json, &nested); + assert!( + forms_for(&content).is_empty(), + "serde_json::from_str refuses anything past 127 levels of nesting, so the item \ + is tagged as JSON but paste-as offers none of the JSON forms for it, with \ + nothing telling the person why it has no conversions" + ); +} + #[test] fn a_colour_goes_round_the_three_notations() { let content = text_of(Kind::Color, "#FF8800"); diff --git a/crates/cp-core/src/search_test.rs b/crates/cp-core/src/search_test.rs index 5e2b8a33..09d554e2 100644 --- a/crates/cp-core/src/search_test.rs +++ b/crates/cp-core/src/search_test.rs @@ -251,3 +251,68 @@ fn folds_both_sides_of_the_index() { assert_eq!(fold("Łódź"), "lodz"); assert_eq!(fold("el café"), "el cafe"); } + +#[test] +fn the_capital_eszett_does_not_fold_like_its_lowercase_twin() { + assert_eq!(fold("ß"), "ss"); + assert_ne!( + fold("ẞ"), + "ss", + "U+1E9E, the capital ß used in all-caps German text, only lowercases to ß \ + instead of expanding to ss, so STRASSE written with it would not be found \ + by searching straße" + ); + assert_eq!(fold("ẞ"), "ß"); +} + +#[test] +fn the_turkish_dotted_capital_i_folds_like_a_plain_i_but_the_dotless_one_does_not() { + assert_eq!( + fold("İ"), + "i", + "the dot above decomposes away as a combining mark, leaving a plain i" + ); + assert_eq!(fold("İ"), fold("I")); + assert_ne!( + fold("ı"), + "i", + "the dotless lowercase i has no decomposition and is a different letter, so it is \ + never folded onto an ascii i" + ); +} + +#[test] +fn nfc_and_nfd_spellings_of_the_same_letter_fold_the_same() { + let nfc = "caf\u{00e9}"; + let nfd = "cafe\u{0301}"; + assert_ne!(nfc.as_bytes(), nfd.as_bytes(), "different bytes going in"); + assert_eq!(fold(nfc), fold(nfd), "the same folded result coming out"); +} + +#[test] +fn terms_of_an_empty_or_symbol_only_query_is_empty() { + assert_eq!(terms_of(""), Vec::::new()); + assert_eq!(terms_of(" "), Vec::::new()); + assert_eq!(terms_of("!?#"), Vec::::new()); +} + +#[test] +fn a_leading_dash_stays_glued_to_its_word() { + assert_eq!(terms_of("gato -perro"), vec!["gato", "-perro"]); +} + +#[test] +fn quotes_are_not_a_token_boundary() { + assert_eq!(terms_of("\"hola mundo\""), vec!["\"hola", "mundo\""]); +} + +#[test] +fn a_term_longer_than_the_text_finds_nothing_and_does_not_panic() { + assert_eq!(excerpt("ab", &["abcdef".to_owned()], 100), None); +} + +#[test] +fn a_width_of_zero_still_shows_something() { + let found = excerpt("a match in here", &["match".to_owned()], 0).expect("a hit"); + assert!(found.segments.iter().any(|one| one.matched)); +} diff --git a/crates/cp-core/src/token_test.rs b/crates/cp-core/src/token_test.rs index fd78ab2d..cd358615 100644 --- a/crates/cp-core/src/token_test.rs +++ b/crates/cp-core/src/token_test.rs @@ -148,3 +148,38 @@ fn base64url_decodes_with_or_without_padding() { assert_eq!(base64url("a b"), None); assert_eq!(base64url("ñ"), None); } + +#[test] +fn dots_without_substance_are_not_a_jwt_either() { + assert!(claims_of("").is_none()); + assert!(claims_of(".").is_none()); + assert!(claims_of("..").is_none()); + assert!(claims_of("...").is_none()); + assert!(!looks_like("..")); + assert!(!looks_like("...")); +} + +#[test] +fn an_expiry_far_in_the_past_or_the_future_is_still_just_a_comparison() { + let long_expired = claims_of(&jwt(r#"{"exp":0}"#)).expect("reads"); + assert_eq!(long_expired.expired_by(1_700_000_000), Some(true)); + + let far_future = claims_of(&jwt(r#"{"exp":253402300799}"#)).expect("reads"); + assert_eq!(far_future.expired_by(1_700_000_000), Some(false)); +} + +#[test] +fn an_expiry_of_the_wrong_json_type_is_not_guessed_at() { + let quoted = claims_of(&jwt(r#"{"exp":"1700000000"}"#)).expect("reads"); + assert_eq!( + quoted.expires_at(), + None, + "a quoted number is text, not the expiry" + ); + let fractional = claims_of(&jwt(r#"{"exp":1700000000.5}"#)).expect("reads"); + assert_eq!( + fractional.expires_at(), + None, + "a fractional expiry is not read as a whole second either" + ); +} diff --git a/crates/cp-panel/src/folder_test.rs b/crates/cp-panel/src/folder_test.rs index 9f86e68e..c6923726 100644 --- a/crates/cp-panel/src/folder_test.rs +++ b/crates/cp-panel/src/folder_test.rs @@ -48,6 +48,23 @@ fn only_the_first_line_is_read_because_that_is_the_first_folder() { assert_eq!(parts_of("/uno/dos\n/tres/cuatro").expect("one").name, "dos"); } +#[test] +fn a_path_with_accents_and_emoji_splits_the_same_way_as_any_other() { + let parts = parts_of(r"D:\Descargas\Informe final 📎 revisión.pdf").expect("one"); + assert_eq!(parts.name, "Informe final 📎 revisión.pdf"); + assert_eq!(parts.parent, r"D:\Descargas\"); +} + +#[test] +fn counting_a_path_longer_than_the_windows_ceiling_answers_nothing_not_a_panic() { + let long = std::env::temp_dir().join(format!( + "cp-folder-long-{}-{}", + std::process::id(), + "x".repeat(300) + )); + assert_eq!(counted_in(&long), None); +} + #[test] fn a_folder_nobody_counted_yet_says_nothing() { assert_eq!(said_in(None, false), ""); @@ -94,12 +111,51 @@ fn counting_a_real_folder_sees_what_is_in_it() { std::fs::remove_dir_all(&dir).ok(); } +#[test] +fn counting_a_real_empty_folder_answers_zero_not_nothing() { + let dir = std::env::temp_dir().join(format!("cp-folder-empty-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("made"); + assert_eq!(counted_in(&dir), Some(0)); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn a_real_folder_past_the_ceiling_stops_counting_at_it() { + let dir = std::env::temp_dir().join(format!("cp-folder-many-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("made"); + for at in 0..UP_TO + 200 { + std::fs::write(dir.join(format!("{at}.txt")), b"x").expect("wrote"); + } + assert_eq!( + counted_in(&dir), + Some(UP_TO), + "counting a real folder with thousands of entries stops at the same ceiling as the synthetic test" + ); + std::fs::remove_dir_all(&dir).ok(); +} + #[test] fn counting_what_is_not_there_answers_nothing_instead_of_zero() { let nowhere = std::env::temp_dir().join("cp-folder-que-no-existe-jamas"); assert_eq!(counted_in(&nowhere), None); } +#[test] +fn counting_a_file_instead_of_a_folder_answers_nothing() { + let dir = std::env::temp_dir().join(format!("cp-folder-is-a-file-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("made"); + let file = dir.join("not-a-folder.txt"); + std::fs::write(&file, b"x").expect("wrote"); + assert_eq!(counted_in(&file), None); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn a_preview_of_only_whitespace_or_only_emoji_has_no_unit() { + assert_eq!(unit_of(" "), ""); + assert_eq!(unit_of("🎉📎"), ""); +} + #[test] fn a_drive_letter_is_the_unit_a_folder_belongs_to() { assert_eq!(unit_of(r"D:\Mario\Orca"), "D:"); diff --git a/crates/cp-panel/src/group_test.rs b/crates/cp-panel/src/group_test.rs index 0ffe740e..1d4c0f50 100644 --- a/crates/cp-panel/src/group_test.rs +++ b/crates/cp-panel/src/group_test.rs @@ -46,6 +46,18 @@ fn a_candidate_nobody_could_read_is_marked_so_it_is_not_tried_forever() { ); } +#[test] +fn an_empty_blank_or_emoji_preview_is_unreadable_not_empty() { + for (kind, preview) in [ + (Kind::Link, ""), + (Kind::Folder, " "), + (Kind::File, "😀🎉"), + (Kind::Link, "😀🎉"), + ] { + assert_eq!(key_of(Some(kind), preview), UNKNOWN, "{kind:?} «{preview}»"); + } +} + #[test] fn what_is_not_a_real_group_draws_no_heading() { assert!(shown("github.com")); diff --git a/crates/cp-panel/src/tags_test.rs b/crates/cp-panel/src/tags_test.rs index 57bff116..9bb7c981 100644 --- a/crates/cp-panel/src/tags_test.rs +++ b/crates/cp-panel/src/tags_test.rs @@ -75,3 +75,22 @@ fn nothing_is_ever_chosen_twice() { let twice = after(&these(&["image", "text"]), "text", false); assert_eq!(twice, these(&["text"])); } + +#[test] +fn a_list_that_already_held_a_duplicate_only_loses_one_copy() { + assert_eq!( + after(&these(&["image", "image"]), "image", true), + these(&["image"]), + "position() finds only the first match, so toggling off a duplicated key leaves \ + one copy behind instead of clearing it" + ); +} + +#[test] +fn the_key_is_matched_by_exact_case() { + assert_eq!( + after(&these(&["Image"]), "image", true), + these(&["Image", "image"]), + "a differently-cased key is treated as a different tag, so both end up selected at once" + ); +} diff --git a/crates/cp-panel/src/wave_test.rs b/crates/cp-panel/src/wave_test.rs index 6f9d536c..50f14928 100644 --- a/crates/cp-panel/src/wave_test.rs +++ b/crates/cp-panel/src/wave_test.rs @@ -161,6 +161,97 @@ fn tone_in_long_ints() -> Vec { riff(1, 32, &samples) } +fn stereo_riff(left: &[i16], right: &[i16]) -> Vec { + let channels = 2u32; + let wide = 2u32 * channels; + let mut samples: Vec = Vec::with_capacity(left.len() * 4); + for (l, r) in left.iter().zip(right.iter()) { + samples.extend_from_slice(&l.to_le_bytes()); + samples.extend_from_slice(&r.to_le_bytes()); + } + let data = u32::try_from(samples.len()).expect("fits"); + let mut out = Vec::with_capacity(samples.len() + 44); + out.extend_from_slice(b"RIFF"); + out.extend_from_slice(&(36 + data).to_le_bytes()); + out.extend_from_slice(b"WAVEfmt "); + out.extend_from_slice(&16u32.to_le_bytes()); + out.extend_from_slice(&1u16.to_le_bytes()); + out.extend_from_slice(&u16::try_from(channels).expect("fits").to_le_bytes()); + out.extend_from_slice(&RATE.to_le_bytes()); + out.extend_from_slice(&(RATE * wide).to_le_bytes()); + out.extend_from_slice(&u16::try_from(wide).expect("fits").to_le_bytes()); + out.extend_from_slice(&16u16.to_le_bytes()); + out.extend_from_slice(b"data"); + out.extend_from_slice(&data.to_le_bytes()); + out.extend_from_slice(&samples); + out +} + +fn riff_lying_about_length(tag: u16, bits: u16, samples: &[u8], claimed_extra: u32) -> Vec { + let wide = u32::from(bits / 8); + let data = u32::try_from(samples.len()).expect("fits") + claimed_extra; + let mut out = Vec::with_capacity(samples.len() + 44); + out.extend_from_slice(b"RIFF"); + out.extend_from_slice(&(36 + data).to_le_bytes()); + out.extend_from_slice(b"WAVEfmt "); + out.extend_from_slice(&16u32.to_le_bytes()); + out.extend_from_slice(&tag.to_le_bytes()); + out.extend_from_slice(&1u16.to_le_bytes()); + out.extend_from_slice(&RATE.to_le_bytes()); + out.extend_from_slice(&(RATE * wide).to_le_bytes()); + out.extend_from_slice(&u16::try_from(wide).expect("fits").to_le_bytes()); + out.extend_from_slice(&bits.to_le_bytes()); + out.extend_from_slice(b"data"); + out.extend_from_slice(&data.to_le_bytes()); + out.extend_from_slice(samples); + out +} + +#[test] +fn a_tone_only_on_the_right_channel_is_not_heard_by_the_wave() { + let dir = std::env::temp_dir().join(format!("cp-wave-stereo-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("made"); + let path = dir.join("solo-derecha.wav"); + let silence: Vec = (0..FRAMES).map(|_| 0).collect(); + let tone: Vec = (0..FRAMES) + .map(|at| (loudness(at) * f32::from(i16::MAX)) as i16) + .collect(); + std::fs::write(&path, stereo_riff(&silence, &tone)).expect("wrote"); + + let bars = bars_of(&path).expect("a wave"); + assert!( + bars.iter().all(|bar| *bar == 0), + "the wave only reads the left channel, so a recording that is audible only on \ + the right shows up as flat silence" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn a_wav_with_no_samples_at_all_gives_no_wave() { + let dir = std::env::temp_dir().join(format!("cp-wave-empty-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("made"); + let path = dir.join("vacio.wav"); + std::fs::write(&path, riff(1, 16, &[])).expect("wrote"); + assert!(bars_of(&path).is_none()); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn a_data_chunk_that_promises_more_than_it_delivers_does_not_crash_the_reader() { + let dir = std::env::temp_dir().join(format!("cp-wave-lying-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("made"); + let path = dir.join("miente.wav"); + let mut samples: Vec = Vec::with_capacity(FRAMES as usize * 2); + for at in 0..FRAMES { + let value = (loudness(at) * f32::from(i16::MAX)) as i16; + samples.extend_from_slice(&value.to_le_bytes()); + } + std::fs::write(&path, riff_lying_about_length(1, 16, &samples, 100_000)).expect("wrote"); + let _ = bars_of(&path); + std::fs::remove_dir_all(&dir).ok(); +} + fn tone_in_bytes() -> Vec { let mut samples: Vec = Vec::with_capacity(FRAMES as usize); for at in 0..FRAMES { @@ -169,3 +260,29 @@ fn tone_in_bytes() -> Vec { } riff(1, 8, &samples) } + +fn tone_in_24_bit_ints() -> Vec { + let mut samples: Vec = Vec::with_capacity(FRAMES as usize * 3); + for at in 0..FRAMES { + let value = (loudness(at) * 8_388_607.0) as i32; + samples.extend_from_slice(&value.to_le_bytes()[..3]); + } + riff(1, 24, &samples) +} + +#[test] +fn a_twenty_four_bit_recording_is_heard_as_silence() { + let dir = std::env::temp_dir().join(format!("cp-wave-24bit-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("made"); + let path = dir.join("24bit.wav"); + std::fs::write(&path, tone_in_24_bit_ints()).expect("wrote"); + + let bars = bars_of(&path).expect("a wave"); + assert!( + bars.iter().all(|bar| *bar == 0), + "symphonia decodes 24-bit PCM as AudioBufferRef::S24, a variant loudest() has no \ + arm for, so every peak falls to the wildcard 0.0 and a recording with real audio \ + is drawn as a flat silent line" + ); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/cp-store/src/backup_test.rs b/crates/cp-store/src/backup_test.rs index a8f6ea3a..e8d28872 100644 --- a/crates/cp-store/src/backup_test.rs +++ b/crates/cp-store/src/backup_test.rs @@ -55,6 +55,36 @@ fn somewhere(name: &str) -> (tempfile::TempDir, Store) { (dir, store) } +#[test] +fn a_fake_secret_crosses_a_backup_without_being_redacted() { + let (there, store) = somewhere("history.db"); + let secret = "AKIAFAKEFAKEFAKEFAKE"; + store + .insert_item("uuid-secret", &text(secret), secret, 1_000) + .expect("stored"); + let backup = there.path().join("mine.cpbackup"); + write(&store, &backup, 1_000).expect("exported"); + drop(store); + + let (_here, landed) = somewhere("history.db"); + let brought = bring(&backup, &landed, 5_000).expect("imported"); + assert_eq!(brought.added, 1); + let id = landed + .find_by_hash(&text(secret)) + .expect("searched") + .expect("is here"); + let payload = landed + .payload_of(id, SYNTHETIC_TEXT) + .expect("read") + .expect("the text is there"); + assert_eq!( + payload, + secret.as_bytes(), + "a backup is a full, unfiltered copy; nothing here recognises or strips a secret \ + before it is written to the .cpbackup file" + ); +} + #[test] fn what_was_kept_travels_whole_from_one_machine_to_another() { let (there, store) = somewhere("history.db"); @@ -145,6 +175,69 @@ fn the_same_copy_brought_twice_does_not_double_anything() { assert_eq!(landed.count().expect("counted"), 2); } +#[test] +fn a_uuid_already_used_by_different_content_is_refused_not_renamed() { + let (there, store) = somewhere("history.db"); + store + .insert_item( + "shared-uuid", + &text("from the backup"), + "from the backup", + 1_000, + ) + .expect("stored"); + let backup = there.path().join("mine.cpbackup"); + write(&store, &backup, 1_000).expect("exported"); + drop(store); + + let (_here, landed) = somewhere("history.db"); + landed + .insert_item( + "shared-uuid", + &text("already here, but different"), + "already here, but different", + 500, + ) + .expect("stored"); + + let brought = bring(&backup, &landed, 5_000).expect("imported"); + assert_eq!(brought.added, 0); + assert_eq!( + brought.refused, 1, + "the uuid collides even though the content does not match by hash, and the item \ + is silently dropped instead of arriving under a fresh identity" + ); + assert_eq!( + landed.count().expect("counted"), + 1, + "only the item that was already there survives; the backed-up one never arrives" + ); +} + +#[test] +fn a_backup_truncated_halfway_is_refused_not_read_as_garbage() { + let (there, store) = somewhere("history.db"); + store + .insert_item("one", &text("something"), "something", 1_000) + .expect("stored"); + let backup = there.path().join("mine.cpbackup"); + write(&store, &backup, 1_000).expect("exported"); + drop(store); + + let full_len = std::fs::metadata(&backup).expect("metadata").len(); + let file = std::fs::OpenOptions::new() + .write(true) + .open(&backup) + .expect("opened"); + file.set_len(full_len / 2).expect("truncated"); + drop(file); + + assert!( + read(&backup).is_err(), + "a backup cut in half must be refused, not read as a shorter history" + ); +} + #[test] fn a_copy_refuses_to_be_written_over_the_history_it_is_copying() { let (there, store) = somewhere("history.db"); diff --git a/crates/cp-store/src/query_test.rs b/crates/cp-store/src/query_test.rs index e5544733..e32734c9 100644 --- a/crates/cp-store/src/query_test.rs +++ b/crates/cp-store/src/query_test.rs @@ -271,3 +271,72 @@ fn quotes_group_words_and_then_disappear() { Some("not closing the quote") ); } + +#[test] +fn a_dash_that_matches_no_operator_stays_glued_to_the_word() { + assert_eq!( + parsed("-coffee"), + Filter { + query: Some("-coffee".into()), + ..Default::default() + }, + "there is no way to exclude free text, only a kind, an app or an operator" + ); +} + +#[test] +fn a_doubled_negation_on_an_operator_is_not_an_operator_at_all() { + assert_eq!( + parsed("--k:image"), + Filter { + query: Some("--k:image".into()), + ..Default::default() + }, + "the second dash breaks the all-alphabetic key check, so the whole token falls to text" + ); +} + +#[test] +fn a_negative_relative_amount_is_not_a_time_and_falls_to_text() { + for literal in ["~-5h", "since:-3d", "d:-1w"] { + assert_eq!( + parsed(literal), + Filter { + query: Some(literal.into()), + ..Default::default() + }, + "{literal}" + ); + } +} + +#[test] +fn is_and_sort_only_match_a_single_exact_word_never_a_list() { + assert_eq!( + parsed("is:pinned,broken"), + Filter { + query: Some("is:pinned,broken".into()), + ..Default::default() + }, + "is: looks at the whole value, not at a comma-split list, so a list is not a word it knows" + ); + assert_eq!( + parsed("sort:recent,used"), + Filter { + query: Some("sort:recent,used".into()), + ..Default::default() + } + ); +} + +#[test] +fn a_quoted_app_with_a_comma_is_split_into_two_apps_not_kept_whole() { + let filter = parsed("app:\"Visual Studio, Code\""); + assert_eq!( + filter.apps, + vec!["Visual Studio".to_owned(), " Code".to_owned()], + "quoting only protects the space from the tokenizer; the comma still splits the \ + value into a list, and the second half keeps its leading space, so neither piece \ + can ever match the real application name" + ); +} diff --git a/crates/cp-store/src/store_housekeeping_test.rs b/crates/cp-store/src/store_housekeeping_test.rs index f70b7d76..46aaa8cb 100644 --- a/crates/cp-store/src/store_housekeeping_test.rs +++ b/crates/cp-store/src/store_housekeeping_test.rs @@ -712,3 +712,28 @@ fn the_parser_and_the_list_speak_the_same_filter() { Some("monday meeting".into()) ); } + +#[test] +fn every_quota_set_to_zero_with_everything_pinned_sweeps_nothing_and_does_not_loop_forever() { + let store = Store::in_memory().expect("schema"); + let ids = fill(&store, 5); + for id in &ids { + store.set_pinned(*id, true, 0).expect("pinned"); + } + let policy = Policy { + keep_for: Some(1), + keep_at_most: Some(0), + bytes_at_most: Some(0), + broken_for: Some(0), + }; + let swept = store.sweep(&policy, 1_000).expect("swept"); + assert_eq!( + swept, + Swept { + truncated: true, + ..Swept::default() + }, + "a quota of zero is still a quota of zero, but pinned items are exempt from every one of them" + ); + assert_eq!(store.count().expect("counted"), 5); +} diff --git a/crates/cp-store/src/store_identity_test.rs b/crates/cp-store/src/store_identity_test.rs index 7b097d23..66c0388d 100644 --- a/crates/cp-store/src/store_identity_test.rs +++ b/crates/cp-store/src/store_identity_test.rs @@ -73,3 +73,65 @@ fn copying_the_same_thing_twice_from_google_reactivates_instead_of_duplicating() "plain text copied from where it was pasted is a different item" ); } + +#[test] +fn the_plain_text_pasted_from_a_rich_item_lands_as_a_second_history_entry() { + let store = Store::in_memory().expect("schema"); + let rich = captured("hello world"); + let id = store + .insert_item("uuid-rich", &rich, "hello world", 1) + .expect("inserted"); + + let replayed = Item::plain("hello world"); + assert_eq!( + store.find_by_hash(&replayed).expect("searched"), + None, + "the plain text that paste-as-plain would put on the clipboard does not \ + fingerprint-match the rich item it came from" + ); + let second_id = store + .insert_item("uuid-replayed", &replayed, "hello world", 2) + .expect("inserted"); + assert_ne!( + id, second_id, + "the same visible text now lives in two separate history entries instead of \ + reactivating the one it was pasted from" + ); + assert_eq!(store.count().expect("counted"), 2); + + let original = store.item(id).expect("fetched").expect("still there"); + assert_eq!( + original.formats.len(), + 2, + "the original keeps every format it was captured with" + ); + assert!( + original.format("public.rtf").is_some(), + "it can still be pasted rich" + ); +} + +#[test] +fn two_different_oversized_captures_collide_into_the_same_identity() { + use cp_core::item::Format; + let store = Store::in_memory().expect("schema"); + let refused = |size: usize| Item { + kind: Some(Kind::Image), + formats: vec![Format { + id: "public.png".into(), + payload: Payload::TooBig { size }, + }], + }; + let id = store + .insert_item("uuid-huge-photo", &refused(90_000_000), "", 1) + .expect("inserted"); + assert_eq!( + store.find_by_hash(&refused(120_000_000)).expect("searched"), + Some(id), + "fingerprint() drops every format whose payload is TooBig before hashing, so two \ + unrelated captures that were both too large to keep hash to the same empty mix \ + regardless of their real size; a caller that checks find_by_hash before inserting \ + (as the panel's capture path does) will reactivate the first oversized item instead \ + of ever recording the second one's existence" + ); +} diff --git a/crates/cp-store/src/store_listing_test.rs b/crates/cp-store/src/store_listing_test.rs index 73343955..07629cfa 100644 --- a/crates/cp-store/src/store_listing_test.rs +++ b/crates/cp-store/src/store_listing_test.rs @@ -598,6 +598,48 @@ fn asking_for_no_rows_is_an_empty_page_not_a_panic() { assert_eq!(huge.next, None); } +#[test] +fn listing_a_store_with_nothing_in_it_at_all_is_an_empty_page_with_no_cursor() { + let store = Store::in_memory().expect("schema"); + let page = store.list(&Filter::default(), 10, None).expect("a page"); + assert!(page.rows.is_empty()); + assert_eq!(page.next, None); + assert_eq!(store.count().expect("counted"), 0); +} + +#[test] +fn a_single_row_on_a_page_exactly_its_size_has_no_next_cursor() { + let store = Store::in_memory().expect("schema"); + store.insert_text("uuid-only", "alone", 1).expect("insert"); + let page = store.list(&Filter::default(), 1, None).expect("a page"); + assert_eq!(page.rows.len(), 1); + assert_eq!( + page.next, None, + "a page that exactly holds the only row is still the last page" + ); +} + +#[test] +fn two_rows_with_a_page_of_one_hand_out_a_cursor_for_exactly_the_second_and_then_stop() { + let store = Store::in_memory().expect("schema"); + store.insert_text("uuid-first", "first", 1).expect("insert"); + store + .insert_text("uuid-second", "second", 2) + .expect("insert"); + let first_page = store.list(&Filter::default(), 1, None).expect("a page"); + assert_eq!(first_page.rows.len(), 1); + let cursor = first_page.next.expect("a second row is still waiting"); + let second_page = store + .list(&Filter::default(), 1, Some(cursor)) + .expect("a page"); + assert_eq!(second_page.rows.len(), 1); + assert_ne!(second_page.rows[0].id, first_page.rows[0].id); + assert_eq!( + second_page.next, None, + "nothing is left after the second row" + ); +} + #[test] fn a_broken_item_can_be_found_again() { let store = history(); @@ -731,6 +773,25 @@ fn the_preview_is_capped_but_the_excerpt_still_sees_the_whole_text() { ); } +#[test] +fn an_emoji_straddling_the_character_count_cap_is_not_split_in_half() { + let store = Store::in_memory().expect("schema"); + let text = format!("{}🎉tail", "a".repeat(PREVIEW_CHARS - 1)); + store + .insert_text("uuid-emoji-cap", &text, 1) + .expect("insert"); + let rows = store + .list(&Filter::default(), 10, None) + .expect("listed") + .rows; + assert_eq!( + rows[0].preview.chars().count(), + PREVIEW_CHARS, + "SUBSTR on a TEXT column counts characters, not bytes, so the emoji is either \ + whole or left out entirely, never cut in the middle" + ); +} + #[test] fn the_applications_follow_the_search_but_not_their_own_filter() { let store = history(); @@ -1101,3 +1162,209 @@ fn a_deleted_row_is_not_sent_back_to_be_measured() { .expect("asked"); assert!(!waiting.contains(&rows[0].id), "it is gone, not unmeasured"); } + +const VOCABULARY: [&str; 12] = [ + "report", + "invoice", + "meeting", + "password", + "address", + "phone", + "project", + "client", + "https://example.test/path", + "SELECT * FROM table", + "Straße", + "mail@example.test", +]; + +fn filled(total: usize) -> Store { + let store = Store::in_memory().expect("schema"); + for at in 0..total { + let word = VOCABULARY[at % VOCABULARY.len()]; + let other = VOCABULARY[(at * 7) % VOCABULARY.len()]; + store + .insert_text( + &format!("uuid-{at}"), + &format!("{word} {at} about {other} with some surrounding text"), + at as i64, + ) + .expect("insert"); + } + store +} + +#[test] +#[ignore = "inserts up to 100k rows; run with cargo test -p cp-store -- --ignored"] +fn listing_searching_counting_and_faceting_stay_fast_at_scale() { + for total in [10_000usize, 100_000] { + let store = filled(total); + + let start = std::time::Instant::now(); + let page = store.list(&Filter::default(), 100, None).expect("listed"); + let listing = start.elapsed(); + assert_eq!(page.rows.len(), 100); + + let start = std::time::Instant::now(); + let found = store + .list( + &Filter { + query: Some("invoice".into()), + ..Default::default() + }, + 100, + None, + ) + .expect("listed"); + let searching = start.elapsed(); + assert!(!found.rows.is_empty()); + + let start = std::time::Instant::now(); + let counted = store.count().expect("counted"); + let counting = start.elapsed(); + assert_eq!(counted, total as i64); + + let start = std::time::Instant::now(); + let facets = store.facets(&Filter::default()).expect("facets"); + let faceting = start.elapsed(); + assert!(!facets.is_empty()); + + println!( + " {total:>7} rows -> list {listing:>10?} · search {searching:>10?} \ + · count {counting:>10?} · facets {faceting:>10?}" + ); + + for (what, took) in [ + ("listing the first page", listing), + ("searching a common word", searching), + ("counting everything", counting), + ("faceting the whole history", faceting), + ] { + assert!( + took < std::time::Duration::from_secs(2), + "{what} at {total} rows took {took:?}, which is not a usable interactive speed" + ); + } + } +} + +#[test] +fn ungrouped_only_offers_the_kinds_it_can_group_and_only_while_they_have_no_group() { + let store = Store::in_memory().expect("schema"); + let link = store + .insert_item( + "uuid-link", + &text_item("https://example.test", Kind::Link), + "https://example.test", + 1, + ) + .expect("insert"); + let folder = store + .insert_item( + "uuid-folder", + &text_item("C:\\stuff", Kind::Folder), + "C:\\stuff", + 2, + ) + .expect("insert"); + store + .insert_item( + "uuid-text", + &text_item("just a note", Kind::Text), + "just a note", + 3, + ) + .expect("insert"); + + let waiting: Vec = store + .ungrouped(10) + .expect("asked") + .into_iter() + .map(|(id, _, _)| id) + .collect(); + assert_eq!( + waiting, + vec![folder, link], + "newest first, and the plain text never qualifies regardless of its empty group" + ); + + store.set_group(link, "example.test").expect("grouped"); + let waiting: Vec = store + .ungrouped(10) + .expect("asked") + .into_iter() + .map(|(id, _, _)| id) + .collect(); + assert_eq!( + waiting, + vec![folder], + "once it has a group it stops waiting to be grouped" + ); +} + +#[test] +fn ungrouped_with_no_room_asked_for_finds_nothing() { + let store = Store::in_memory().expect("schema"); + store + .insert_item( + "uuid-link", + &text_item("https://example.test", Kind::Link), + "https://example.test", + 1, + ) + .expect("insert"); + assert_eq!(store.ungrouped(0).expect("asked"), Vec::new()); +} + +#[test] +fn ungrouped_cuts_a_long_preview_without_splitting_a_character() { + let store = Store::in_memory().expect("schema"); + let preview: String = "🎉".repeat(500); + let id = store + .insert_item( + "uuid-emoji-path", + &text_item(&preview, Kind::File), + &preview, + 1, + ) + .expect("insert"); + let (_, _, shown) = store + .ungrouped(10) + .expect("asked") + .into_iter() + .find(|(one, _, _)| *one == id) + .expect("is here"); + assert_eq!( + shown.chars().count(), + 400, + "cut at 400 characters, not bytes" + ); + assert!(shown.chars().all(|c| c == '🎉'), "no character was split"); +} + +#[test] +fn every_order_including_by_group_pages_without_repeating_or_skipping_when_most_rows_tie() { + let store = Store::in_memory().expect("schema"); + for at in 0..30 { + let id = store + .insert_item( + &format!("uuid-{at}"), + &text_item(&format!("note {at}"), Kind::Text), + &format!("note {at}"), + at, + ) + .expect("insert"); + if at % 4 == 0 { + store.set_group(id, "shared").expect("grouped"); + } + } + let filter = Filter { + order: Order::ByGroup, + ..Default::default() + }; + let mut ids = walk(&store, &filter, 4); + assert_eq!(ids.len(), 30, "ByGroup skipped rows"); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), 30, "ByGroup repeated rows"); +} diff --git a/crates/cp-store/src/store_test.rs b/crates/cp-store/src/store_test.rs index 713f1b66..2b7a98e7 100644 --- a/crates/cp-store/src/store_test.rs +++ b/crates/cp-store/src/store_test.rs @@ -367,6 +367,47 @@ fn a_very_long_text_is_stored_and_found() { assert_eq!(search(&store, "needle").len(), 1); } +#[test] +fn a_multibyte_character_straddling_the_stored_preview_ceiling_does_not_panic() { + let store = Store::in_memory().expect("schema"); + let padding = "a".repeat(PREVIEW_UP_TO - 1); + let text = format!("{padding}🎉tail"); + store.insert_text("uuid-straddling", &text, 1).expect( + "the manual byte-boundary search in head_of must not panic or truncate mid-character", + ); + assert_eq!(store.count().expect("counted"), 1); +} + +#[test] +fn secrets_are_kept_and_searchable_like_any_other_text_with_no_redaction() { + let store = Store::in_memory().expect("schema"); + let fakes = [ + "AKIAFAKEFAKEFAKEFAKE", + "-----BEGIN FAKE PRIVATE KEY-----\nnot a real key\n-----END FAKE PRIVATE KEY-----", + "correct horse battery staple", + "4111 1111 1111 1111", + ]; + for (at, secret) in fakes.iter().enumerate() { + store + .insert_text(&format!("uuid-secret-{at}"), secret, at as i64) + .expect("insert"); + } + assert_eq!( + search(&store, "AKIAFAKEFAKEFAKEFAKE"), + vec!["AKIAFAKEFAKEFAKEFAKE"], + "a clipboard history is, by design, a plain-text record of everything copied, \ + secrets included; nothing here masks, flags, or excludes them" + ); + assert_eq!(search(&store, "battery staple").len(), 1); + assert_eq!(search(&store, "4111").len(), 1); + let found = store + .find_by_hash(&Item::plain( + "-----BEGIN FAKE PRIVATE KEY-----\nnot a real key\n-----END FAKE PRIVATE KEY-----", + )) + .expect("searched"); + assert!(found.is_some(), "the key text round-trips byte for byte"); +} + #[test] fn the_same_uuid_twice_is_refused_not_duplicated() { let store = Store::in_memory().expect("schema"); @@ -1371,6 +1412,54 @@ fn deleted_items_drop_out_of_the_queue() { ); } +#[test] +fn failing_a_job_for_an_item_already_deleted_claims_it_will_retry_but_there_is_nothing_left_to_retry() + { + let store = Store::in_memory().expect("schema"); + let id = store + .insert_text("uuid-gone", "something", 1) + .expect("insert"); + store.enqueue(id, "ocr").expect("queued"); + store.mark_deleted(id, 2).expect("removed"); + assert!( + store + .work_failed(id, "ocr", "too slow", 100) + .expect("recorded"), + "the queue row is already gone, but work_failed still reports that it will retry" + ); + assert!( + store + .take_pending("ocr", 1_000, 5) + .expect("pending") + .is_empty(), + "there is nothing left to retry: the row disappeared along with the item" + ); +} + +#[test] +fn different_capitalisation_is_a_different_identity_but_the_same_search_result() { + let store = Store::in_memory().expect("schema"); + let upper_id = store + .insert_text("uuid-upper", "TEXTO IMPORTANTE", 1) + .expect("insert"); + assert_eq!( + store + .find_by_hash(&Item::plain("texto importante")) + .expect("searched"), + None, + "case is part of the identity, so the same words in lowercase do not match" + ); + let lower_id = store + .insert_text("uuid-lower", "texto importante", 2) + .expect("insert"); + assert_ne!(upper_id, lower_id); + assert_eq!( + search(&store, "TeXto").len(), + 2, + "search folds case, so both entries turn up for either spelling" + ); +} + #[test] fn metadata_is_kept_per_key_and_replaced_not_duplicated() { let store = Store::in_memory().expect("schema");