Files
lightcraft/crates/ui-egui/src/sidebar.rs
T
Nathaniel LedfordandClaude Opus 5.5 5e68d92c9f LR-BEHAV-SIDEBAR-STICKY: a long section's header stays in sight; sections are easier to tell apart
Pinned header at the top of the sidebar, hairlines between sections, the fold
chevron in the rows' triangle column, a count on folded sections, fainter zero
counts. From review: a move nobody could see is no move, ui.set refuses
`sidebar` together with `collapsedSidebar` and unknown names in the latter.
Translations and What's New for the sidebar work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:44:46 -04:00

456 lines
20 KiB
Rust

//! The left sidebar's sections and how the user arranged them: their order, which are hidden and
//! which are folded shut. No drawing here (that is `panels/left.rs`); this is the arrangement
//! itself, saved with the UI state and changed by the `view.sidebar*` commands.
//!
//! My Photos (All Photos, Recently Added, Picks, Missing Photos, Recently Deleted) is not a
//! section: it stays at the top and can't be hidden, moved or folded.
use serde::{Deserialize, Serialize};
/// A section of the left sidebar below My Photos.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SidebarSection {
Albums,
Local,
ByDate,
Folders,
Keywords,
}
impl SidebarSection {
/// Every section, in the order a new library shows them.
pub const ALL: [SidebarSection; 5] =
[SidebarSection::Albums, SidebarSection::Local, SidebarSection::ByDate, SidebarSection::Folders, SidebarSection::Keywords];
/// The name commands, the saved UI state and widget ids (`sidebarSection:<id>`) use.
pub const fn id(self) -> &'static str {
match self {
SidebarSection::Albums => "albums",
SidebarSection::Local => "local",
SidebarSection::ByDate => "byDate",
SidebarSection::Folders => "folders",
SidebarSection::Keywords => "keywords",
}
}
/// The heading, in English (translated where it is shown).
pub const fn title(self) -> &'static str {
match self {
SidebarSection::Albums => "Albums",
SidebarSection::Local => "Local",
SidebarSection::ByDate => "By Date",
SidebarSection::Folders => "Folders",
SidebarSection::Keywords => "Keywords",
}
}
/// What the section says while it has nothing to list, in English.
pub const fn empty_hint(self) -> &'static str {
match self {
SidebarSection::Albums => "No albums yet",
SidebarSection::Local => "No folders to browse",
SidebarSection::ByDate => "No photos yet",
SidebarSection::Folders => "No imported folders yet",
SidebarSection::Keywords => "No keywords yet",
}
}
pub fn from_id(id: &str) -> Option<SidebarSection> {
Self::ALL.into_iter().find(|s| s.id() == id)
}
/// Whether this build has the section at all (Local browses folders on disk: not on the web).
pub const fn available(self) -> bool {
!(cfg!(target_arch = "wasm32") && matches!(self, SidebarSection::Local))
}
/// The ids of every section, for error messages (`albums|local|…`).
pub fn ids() -> String {
Self::ALL.map(SidebarSection::id).join("|")
}
}
/// Where one section stands: shown or hidden, open or folded.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub struct SectionState {
pub id: SidebarSection,
pub hidden: bool,
pub collapsed: bool,
}
impl SectionState {
const fn new(id: SidebarSection) -> Self {
Self { id, hidden: false, collapsed: false }
}
}
/// The arrangement of the sidebar's sections, top to bottom. Every section is in it exactly once,
/// whatever was read from disk or sent by an agent: unknown and repeated entries are dropped and
/// sections not named come last, in their usual order.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "serde_json::Value", into = "Vec<SectionState>")]
pub struct SidebarLayout(Vec<SectionState>);
impl Default for SidebarLayout {
fn default() -> Self {
Self(SidebarSection::ALL.map(SectionState::new).to_vec())
}
}
impl From<SidebarLayout> for Vec<SectionState> {
fn from(layout: SidebarLayout) -> Self {
layout.0
}
}
/// Lenient on purpose: a settings file edited by hand, written by a newer version, or damaged
/// must still open, so whatever can't be understood is left out instead of failing the load.
impl From<serde_json::Value> for SidebarLayout {
fn from(saved: serde_json::Value) -> Self {
let entries = saved.as_array().map(Vec::as_slice).unwrap_or_default();
let flag = |entry: &serde_json::Value, key: &str| entry.get(key).and_then(serde_json::Value::as_bool).unwrap_or(false);
let read = entries.iter().filter_map(|entry| {
// an entry is `{id, hidden?, collapsed?}`, or just the id
let id = entry.as_str().or_else(|| entry.get("id").and_then(serde_json::Value::as_str))?;
Some(SectionState { id: SidebarSection::from_id(id)?, hidden: flag(entry, "hidden"), collapsed: flag(entry, "collapsed") })
});
Self::from_states(read)
}
}
impl SidebarLayout {
/// The arrangement an agent or script asks for (`ui.set` `sidebar`), read strictly, unlike a
/// settings file: a list whose entries are a section id or `{id, hidden?, collapsed?}`, each
/// section at most once. Sections left out follow in the usual order, shown and open. What is
/// wrong is said, so a typo doesn't quietly rearrange the sidebar.
pub fn parse(asked: &serde_json::Value) -> Result<Self, String> {
let entries = asked.as_array().ok_or_else(|| format!("`sidebar` must be a list of sections ({}), not {asked}", SidebarSection::ids()))?;
let mut states: Vec<SectionState> = Vec::new();
for entry in entries {
let name = entry
.as_str()
.or_else(|| entry.get("id").and_then(serde_json::Value::as_str))
.ok_or_else(|| format!("`sidebar`: {entry} names no section (an id, or {{id, hidden, collapsed}})"))?;
let id = SidebarSection::from_id(name).ok_or_else(|| format!("`sidebar`: unknown section `{name}` ({})", SidebarSection::ids()))?;
if states.iter().any(|s| s.id == id) {
return Err(format!("`sidebar`: `{name}` is listed twice"));
}
let flag = |key: &str| match entry.get(key) {
None | Some(serde_json::Value::Null) => Ok(false),
Some(serde_json::Value::Bool(b)) => Ok(*b),
Some(other) => Err(format!("`sidebar`: `{key}` of `{name}` must be true or false, not {other}")),
};
states.push(SectionState { id, hidden: flag("hidden")?, collapsed: flag("collapsed")? });
}
Ok(Self::from_states(states))
}
/// The layout these states describe, made whole (see the type).
fn from_states(states: impl IntoIterator<Item = SectionState>) -> Self {
let mut all: Vec<SectionState> = Vec::with_capacity(SidebarSection::ALL.len());
for state in states.into_iter().chain(SidebarSection::ALL.map(SectionState::new)) {
if !all.iter().any(|s| s.id == state.id) {
all.push(state);
}
}
Self(all)
}
/// Every section top to bottom, hidden ones included.
pub fn sections(&self) -> &[SectionState] {
&self.0
}
/// The sections that are shown, top to bottom.
pub fn shown(&self) -> impl Iterator<Item = SidebarSection> + '_ {
self.0.iter().filter(|s| !s.hidden && s.id.available()).map(|s| s.id)
}
fn state(&self, section: SidebarSection) -> SectionState {
self.0.iter().copied().find(|s| s.id == section).unwrap_or(SectionState::new(section))
}
fn state_mut(&mut self, section: SidebarSection) -> Option<&mut SectionState> {
self.0.iter_mut().find(|s| s.id == section)
}
pub fn is_hidden(&self, section: SidebarSection) -> bool {
self.state(section).hidden
}
pub fn is_collapsed(&self, section: SidebarSection) -> bool {
self.state(section).collapsed
}
/// Show or hide a section. It keeps its place and whether it was folded.
pub fn set_hidden(&mut self, section: SidebarSection, hidden: bool) {
if let Some(s) = self.state_mut(section) {
s.hidden = hidden;
}
}
/// Fold a section shut or open it.
pub fn set_collapsed(&mut self, section: SidebarSection, collapsed: bool) {
if let Some(s) = self.state_mut(section) {
s.collapsed = collapsed;
}
}
pub fn toggle_collapsed(&mut self, section: SidebarSection) {
self.set_collapsed(section, !self.is_collapsed(section));
}
/// Make sure a section can be seen: shown and open (something in it is being pointed out).
pub fn reveal(&mut self, section: SidebarSection) {
self.set_hidden(section, false);
self.set_collapsed(section, false);
}
/// Put `section` just above `before`, or last when `before` is `None`. Returns whether it
/// moved. Moving a section above itself or to where it already is changes nothing, and neither
/// does a move of a shown section that would only take it past hidden ones: the sidebar would
/// look the same, so the arrangement stays the same too.
pub fn move_before(&mut self, section: SidebarSection, before: Option<SidebarSection>) -> bool {
if before == Some(section) {
return false;
}
let was = self.clone();
let moved = self.state(section);
self.0.retain(|s| s.id != section);
let at = before.and_then(|b| self.0.iter().position(|s| s.id == b)).unwrap_or(self.0.len());
self.0.insert(at, moved);
let seen = !moved.hidden && section.available();
if seen && self.shown().eq(was.shown()) {
*self = was;
return false;
}
*self != was
}
/// Move `section` one place up or down among the sections that are shown (hidden ones are
/// stepped over, so every move is one the user can see). Returns whether it moved: the first
/// can't go up, the last can't go down, and a hidden section stays where it is.
pub fn move_by(&mut self, section: SidebarSection, up: bool) -> bool {
let shown: Vec<SidebarSection> = self.shown().collect();
let Some(at) = shown.iter().position(|s| *s == section) else { return false };
if up {
let Some(above) = at.checked_sub(1).and_then(|i| shown.get(i)) else { return false };
self.move_before(section, Some(*above))
} else {
if at + 1 >= shown.len() {
return false;
}
// below the next one: above the one after it, or last
self.move_before(section, shown.get(at + 2).copied())
}
}
/// Whether `section` can move up (or down) among the shown sections.
pub fn can_move(&self, section: SidebarSection, up: bool) -> bool {
let shown: Vec<SidebarSection> = self.shown().collect();
match shown.iter().position(|s| *s == section) {
Some(at) if up => at > 0,
Some(at) => at + 1 < shown.len(),
None => false,
}
}
/// Whether this is the arrangement a new library starts with.
pub fn is_default(&self) -> bool {
*self == Self::default()
}
/// Fold shut the sections an older settings file listed as collapsed (`collapsedSidebar`).
pub fn adopt_collapsed(&mut self, ids: &[String]) {
for section in ids.iter().filter_map(|id| SidebarSection::from_id(id)) {
self.set_collapsed(section, true);
}
}
}
#[cfg(test)]
mod tests {
use super::SidebarSection::{Albums, ByDate, Folders, Keywords, Local};
use super::*;
use serde_json::json;
fn order(layout: &SidebarLayout) -> Vec<SidebarSection> {
layout.sections().iter().map(|s| s.id).collect()
}
fn read(saved: serde_json::Value) -> SidebarLayout {
serde_json::from_value(saved).unwrap()
}
/// Given a new library, the sidebar lists every section, shown and open, in the usual order.
#[test]
fn a_new_sidebar_shows_every_section_open_in_the_usual_order() {
let layout = SidebarLayout::default();
assert_eq!(order(&layout), [Albums, Local, ByDate, Folders, Keywords]);
assert!(layout.sections().iter().all(|s| !s.hidden && !s.collapsed));
assert!(layout.is_default());
}
/// Given an arrangement the user made, when it is saved and read back, then it is the same.
#[test]
fn an_arrangement_survives_saving() {
let mut layout = SidebarLayout::default();
layout.move_before(Keywords, Some(Albums));
layout.set_hidden(Local, true);
layout.set_collapsed(ByDate, true);
let saved = serde_json::to_value(&layout).unwrap();
assert_eq!(saved[0], json!({"id": "keywords", "hidden": false, "collapsed": false}));
assert_eq!(read(saved), layout);
}
/// Given a saved arrangement that is incomplete, repeats itself or names sections this version
/// doesn't know, when it is read, then every section is there exactly once: the readable
/// entries first, in their order, and the rest after them.
#[test]
fn a_damaged_arrangement_is_made_whole() {
let layout = read(json!([
{"id": "keywords", "collapsed": true},
{"id": "map"},
"folders",
{"id": "keywords", "hidden": true},
{"id": 7},
null,
{"id": "albums", "hidden": "yes", "collapsed": 1}
]));
assert_eq!(order(&layout), [Keywords, Folders, Albums, Local, ByDate]);
assert!(layout.is_collapsed(Keywords) && !layout.is_hidden(Keywords), "the first entry for a section counts");
assert!(!layout.is_hidden(Albums) && !layout.is_collapsed(Albums), "a flag that isn't true or false is off");
}
/// Given a saved arrangement that is not a list at all, when it is read, then the sidebar is
/// the usual one (and the rest of the settings still load).
#[test]
fn an_unreadable_arrangement_is_the_usual_one() {
for saved in [json!(null), json!("albums"), json!(3), json!({"albums": true}), json!([])] {
assert!(read(saved.clone()).is_default(), "{saved}");
}
}
/// Hiding a section takes it off the sidebar and nothing else: it keeps its place and stays
/// folded or open, so showing it again puts it back as it was.
#[test]
fn a_hidden_section_comes_back_where_and_how_it_was() {
let mut layout = SidebarLayout::default();
layout.set_collapsed(ByDate, true);
layout.set_hidden(ByDate, true);
assert_eq!(layout.shown().collect::<Vec<_>>(), [Albums, Local, Folders, Keywords]);
assert!(!layout.is_default());
layout.set_hidden(ByDate, false);
assert_eq!(layout.shown().collect::<Vec<_>>(), [Albums, Local, ByDate, Folders, Keywords]);
assert!(layout.is_collapsed(ByDate));
}
/// Revealing a section shows it and opens it.
#[test]
fn revealing_a_section_shows_and_opens_it() {
let mut layout = SidebarLayout::default();
layout.set_collapsed(Albums, true);
layout.set_hidden(Albums, true);
layout.reveal(Albums);
assert!(!layout.is_hidden(Albums) && !layout.is_collapsed(Albums));
}
/// A section can be put above any other, or last; the others keep their order.
#[test]
fn a_section_moves_above_another_or_to_the_end() {
let mut layout = SidebarLayout::default();
assert!(layout.move_before(Keywords, Some(Local)));
assert_eq!(order(&layout), [Albums, Keywords, Local, ByDate, Folders]);
assert!(layout.move_before(Albums, None));
assert_eq!(order(&layout), [Keywords, Local, ByDate, Folders, Albums]);
// nothing to do: above itself, where it already is
assert!(!layout.move_before(Local, Some(Local)));
assert!(!layout.move_before(Local, Some(ByDate)));
assert!(!layout.move_before(Albums, None));
assert_eq!(order(&layout), [Keywords, Local, ByDate, Folders, Albums]);
}
/// Given a hidden section between two shown ones, when the upper one is dropped right where
/// it already appears (above the lower one), then nothing changes: no move that can't be seen.
#[test]
fn a_move_that_cannot_be_seen_is_no_move() {
let mut layout = SidebarLayout::default();
layout.set_hidden(Local, true);
let was = layout.clone();
assert!(!layout.move_before(Albums, Some(ByDate)), "Albums is already right above By Date, as far as anyone can see");
assert_eq!(layout, was);
// a hidden section can still be put elsewhere (it shows there when it is shown again)
assert!(layout.move_before(Local, None));
assert_eq!(order(&layout), [Albums, ByDate, Folders, Keywords, Local]);
}
/// Moving a section keeps whether it is hidden or folded.
#[test]
fn a_moved_section_stays_folded() {
let mut layout = SidebarLayout::default();
layout.set_collapsed(Folders, true);
layout.move_before(Folders, Some(Albums));
assert!(layout.is_collapsed(Folders));
}
/// Move Up and Move Down go one shown section at a time and stop at the ends.
#[test]
fn a_section_moves_one_place_among_the_shown_ones() {
let mut layout = SidebarLayout::default();
layout.set_hidden(ByDate, true);
// down past the hidden By Date in one step
assert!(layout.can_move(Local, false));
assert!(layout.move_by(Local, false));
assert_eq!(layout.shown().collect::<Vec<_>>(), [Albums, Folders, Local, Keywords]);
assert!(layout.move_by(Local, false));
assert_eq!(layout.shown().collect::<Vec<_>>(), [Albums, Folders, Keywords, Local]);
assert!(!layout.can_move(Local, false) && !layout.move_by(Local, false), "the last can't go down");
assert!(layout.move_by(Folders, true));
assert_eq!(layout.shown().collect::<Vec<_>>(), [Folders, Albums, Keywords, Local]);
assert!(!layout.can_move(Folders, true) && !layout.move_by(Folders, true), "the first can't go up");
assert!(!layout.can_move(ByDate, true) && !layout.move_by(ByDate, true), "a hidden section doesn't move");
}
/// Sections an older settings file listed as collapsed are folded; names it got wrong are ignored.
#[test]
fn collapsed_sections_of_an_older_settings_file_stay_folded() {
let mut layout = SidebarLayout::default();
layout.adopt_collapsed(&["byDate".into(), "nonsense".into(), "keywords".into()]);
assert!(layout.is_collapsed(ByDate) && layout.is_collapsed(Keywords) && !layout.is_collapsed(Albums));
}
/// Given an arrangement asked for by an agent, when it is right, then it is taken (sections it
/// leaves out follow, shown and open); when anything in it is wrong, then it is refused with
/// what is wrong, instead of being made whole as a settings file is.
#[test]
fn an_arrangement_that_is_asked_for_is_read_strictly() {
let layout = SidebarLayout::parse(&json!(["keywords", {"id": "albums", "hidden": true, "collapsed": null}])).unwrap();
assert_eq!(order(&layout), [Keywords, Albums, Local, ByDate, Folders]);
assert!(layout.is_hidden(Albums) && !layout.is_collapsed(Albums));
assert!(SidebarLayout::parse(&json!([])).unwrap().is_default());
for (asked, says) in [
(json!(null), "must be a list"),
(json!({"albums": 1}), "must be a list"),
(json!([{"id": "keywrods", "hidden": true}]), "unknown section `keywrods`"),
(json!([7]), "names no section"),
(json!(["albums", {"id": "albums"}]), "`albums` is listed twice"),
(json!([{"id": "albums", "hidden": "yes"}]), "`hidden` of `albums` must be true or false"),
(json!([{"id": "albums", "collapsed": 1}]), "`collapsed` of `albums` must be true or false"),
] {
let e = SidebarLayout::parse(&asked).unwrap_err();
assert!(e.contains(says), "{asked}: {e}");
}
}
#[test]
fn sections_are_named_by_their_ids() {
for s in SidebarSection::ALL {
assert_eq!(SidebarSection::from_id(s.id()), Some(s));
assert_eq!(serde_json::to_value(s).unwrap(), json!(s.id()));
}
assert_eq!(SidebarSection::from_id("Albums"), None);
assert_eq!(SidebarSection::ids(), "albums|local|byDate|folders|keywords");
}
}