mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 22:17:53 -06:00
LR-BEHAV-SIDEBAR-SECTIONS: a section declares its header button; Keywords gets + (Create Keyword Tag)
Albums' + is no longer a special case of the header: header_button says which section has a button and whether it acts or opens a menu, and the frame draws it in the header and its pinned copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9cd9e29614
commit
d331829770
@@ -364,8 +364,14 @@ fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str, selection: &[Pho
|
||||
|
||||
/// Create Keyword Tag: inside the keyword picked in the list, else the default parent.
|
||||
pub(crate) fn create_dialog(app: &LightcraftApp) -> Dialog {
|
||||
let parent =
|
||||
app.ui.keyword_list_selected.clone().filter(|k| app.session.catalog.has_keyword(k)).or_else(|| app.session.catalog.default_keyword_parent());
|
||||
let picked = app.ui.keyword_list_selected.clone().filter(|k| app.session.catalog.has_keyword(k));
|
||||
create_dialog_inside(app, picked)
|
||||
}
|
||||
|
||||
/// Create Keyword Tag for a keyword inside `parent`, else inside the default parent (Put New
|
||||
/// Keywords Inside This Keyword), else at the top level.
|
||||
pub(crate) fn create_dialog_inside(app: &LightcraftApp, parent: Option<String>) -> Dialog {
|
||||
let parent = parent.or_else(|| app.session.catalog.default_keyword_parent());
|
||||
let d = KeywordInfo::default();
|
||||
Dialog::KeywordTag {
|
||||
editing: None,
|
||||
|
||||
@@ -396,22 +396,88 @@ fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSe
|
||||
let col = if hovered { t.text } else { t.text_dim };
|
||||
ui.painter().add(egui::Shape::convex_polygon(triangle_points(c, open), col, egui::Stroke::NONE));
|
||||
ui.painter().text(pos2(r.left() + 18.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.5), t.text_label);
|
||||
let has_actions = section == SidebarSection::Albums;
|
||||
// (an album being dragged puts "Top Level" there)
|
||||
let room = !(has_actions && app.ui.dragging_album.is_some());
|
||||
let button = header_button(section);
|
||||
// (an album being dragged puts "Top Level" in the Albums header)
|
||||
let room = !(section == SidebarSection::Albums && app.ui.dragging_album.is_some());
|
||||
if !open && rows > 0 && app.ui.show_counts && room {
|
||||
// folded: how much is in there (left of the header's own buttons)
|
||||
let right = edge - 18.0 - if has_actions { 30.0 } else { 0.0 };
|
||||
// folded: how much is in there (left of the header's own button)
|
||||
let right = edge - 18.0 - if button.is_some() { HEADER_BUTTON + 4.0 } else { 0.0 };
|
||||
let count = ui.painter().text(pos2(right, r.center().y), Align2::RIGHT_CENTER, rows.to_string(), t.font(12.5), t.text_dim);
|
||||
register(ui.ctx(), format!("{widget}Count:{id}"), count);
|
||||
}
|
||||
if has_actions {
|
||||
if section == SidebarSection::Albums {
|
||||
top_level_drop_target(app, ui, r);
|
||||
albums_header_actions(app, ui, r);
|
||||
}
|
||||
if let Some(button) = button {
|
||||
show_header_button(app, ui, r, &button);
|
||||
}
|
||||
HeaderOutcome { open, toggled }
|
||||
}
|
||||
|
||||
/// Side of a header's button.
|
||||
const HEADER_BUTTON: f32 = 26.0;
|
||||
|
||||
/// A button at the end of a section's header (see [`header_button`]).
|
||||
struct HeaderButton {
|
||||
/// Its widget is `icon:<id>`.
|
||||
id: &'static str,
|
||||
icon: Icon,
|
||||
/// What it does, in English: its tooltip and spoken name.
|
||||
tooltip: &'static str,
|
||||
action: HeaderAction,
|
||||
}
|
||||
|
||||
/// What a header's button does when clicked.
|
||||
enum HeaderAction {
|
||||
/// Does it at once (usually: opens a dialog).
|
||||
Run(fn(&mut LightcraftApp, &egui::Context)),
|
||||
/// Opens a menu with these items.
|
||||
Menu(fn(&mut LightcraftApp, &mut egui::Ui)),
|
||||
}
|
||||
|
||||
/// The button a section has at the end of its header, if it has one. This is the one place that
|
||||
/// says so: the header (its own and the pinned copy) draws the button at the visible edge, keeps
|
||||
/// its clicks from folding the section and leaves it room beside a folded section's count.
|
||||
fn header_button(section: SidebarSection) -> Option<HeaderButton> {
|
||||
match section {
|
||||
SidebarSection::Albums => {
|
||||
Some(HeaderButton { id: "albumNew", icon: Icon::Plus, tooltip: "Create Album", action: HeaderAction::Menu(new_album_menu) })
|
||||
}
|
||||
SidebarSection::Keywords => {
|
||||
Some(HeaderButton { id: "keywordNew", icon: Icon::Plus, tooltip: "Create Keyword Tag", action: HeaderAction::Run(new_keyword) })
|
||||
}
|
||||
SidebarSection::Local | SidebarSection::ByDate | SidebarSection::Folders => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw `button` at the end of `header` and act on it.
|
||||
fn show_header_button(app: &mut LightcraftApp, ui: &mut egui::Ui, header: Rect, button: &HeaderButton) {
|
||||
// it stays at the visible edge when the sidebar is scrolled sideways
|
||||
let right = visible_right(ui).min(header.right());
|
||||
let mut hdr = ui.new_child(
|
||||
egui::UiBuilder::new()
|
||||
.max_rect(Rect::from_min_max(pos2(right - 50.0, header.top()), pos2(right, header.bottom())))
|
||||
.layout(egui::Layout::right_to_left(egui::Align::Center)),
|
||||
);
|
||||
let resp = icon_button(&mut hdr, button.id, button.icon, vec2(HEADER_BUTTON, HEADER_BUTTON), false, true, button.tooltip);
|
||||
match button.action {
|
||||
HeaderAction::Run(run) => {
|
||||
if resp.clicked() {
|
||||
run(app, ui.ctx());
|
||||
}
|
||||
}
|
||||
HeaderAction::Menu(items) => {
|
||||
egui::Popup::menu(&resp).show(|ui| items(app, ui));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keywords' +: Create Keyword Tag, for a keyword at the usual place for new ones (not inside
|
||||
/// whatever is picked in the Keyword List, which may not even be on screen).
|
||||
fn new_keyword(app: &mut LightcraftApp, _ctx: &egui::Context) {
|
||||
app.ui.dialog = Some(crate::panels::keyword_list::create_dialog_inside(app, None));
|
||||
}
|
||||
|
||||
/// The menu of a section's header (`on`) and of the My Photos title (`None`): hide this section,
|
||||
/// show or hide any section, and go back to the usual arrangement. Its items are widgets
|
||||
/// `sidebarMenu:hide`, `sidebarMenu:section:<id>` and `sidebarMenu:reset`.
|
||||
@@ -453,42 +519,32 @@ fn sections_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, on: Option<SidebarS
|
||||
}
|
||||
}
|
||||
|
||||
/// The + at the end of the Albums header: Create Album, Smart Album, Folder…
|
||||
fn albums_header_actions(app: &mut LightcraftApp, ui: &mut egui::Ui, header: Rect) {
|
||||
// the + stays at the visible edge when the sidebar is scrolled sideways
|
||||
let plus_right = visible_right(ui).min(header.right());
|
||||
let mut hdr = ui.new_child(
|
||||
egui::UiBuilder::new()
|
||||
.max_rect(Rect::from_min_max(pos2(plus_right - 50.0, header.top()), pos2(plus_right, header.bottom())))
|
||||
.layout(egui::Layout::right_to_left(egui::Align::Center)),
|
||||
);
|
||||
let plus = icon_button(&mut hdr, "albumNew", Icon::Plus, vec2(26.0, 26.0), false, true, "Create Album");
|
||||
egui::Popup::menu(&plus).show(|ui| {
|
||||
if ui.button(crate::i18n::tr("Create Album…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false, parent: None });
|
||||
/// The menu of Albums' +: Create Album, Smart Album, Folder…
|
||||
fn new_album_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
if ui.button(crate::i18n::tr("Create Album…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false, parent: None });
|
||||
}
|
||||
if ui.button(crate::i18n::tr("Create Smart Album…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::SmartRules {
|
||||
id: None,
|
||||
name: String::new(),
|
||||
rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() },
|
||||
parent: None,
|
||||
});
|
||||
}
|
||||
if ui.button(crate::i18n::tr("Create Smart Album from Filter…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: String::new(), parent: None });
|
||||
}
|
||||
if ui.button(crate::i18n::tr("Create Folder…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true, parent: None });
|
||||
}
|
||||
// only once the albums were put in an order by hand
|
||||
if app.session.catalog.album_children_are_ordered(None) {
|
||||
ui.separator();
|
||||
if ui.button(crate::i18n::tr("Sort Albums A–Z")).on_hover_text(crate::i18n::tr("Go back to listing them by name")).clicked() {
|
||||
let _ = app.run("album.sort", json!({}));
|
||||
}
|
||||
if ui.button(crate::i18n::tr("Create Smart Album…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::SmartRules {
|
||||
id: None,
|
||||
name: String::new(),
|
||||
rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() },
|
||||
parent: None,
|
||||
});
|
||||
}
|
||||
if ui.button(crate::i18n::tr("Create Smart Album from Filter…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: String::new(), parent: None });
|
||||
}
|
||||
if ui.button(crate::i18n::tr("Create Folder…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true, parent: None });
|
||||
}
|
||||
// only once the albums were put in an order by hand
|
||||
if app.session.catalog.album_children_are_ordered(None) {
|
||||
ui.separator();
|
||||
if ui.button(crate::i18n::tr("Sort Albums A–Z")).on_hover_text(crate::i18n::tr("Go back to listing them by name")).clicked() {
|
||||
let _ = app.run("album.sort", json!({}));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
|
||||
@@ -791,6 +791,43 @@ fn the_next_section_takes_over_the_pinned_header() {
|
||||
assert_eq!(seen, ["albums", "keywords"]);
|
||||
}
|
||||
|
||||
/// Given the Keywords header, when its + is clicked, then Create Keyword Tag opens (for a keyword
|
||||
/// at the usual place, whatever is picked in the Keyword List) and the section does not fold;
|
||||
/// and the keyword made there is listed in the section.
|
||||
#[test]
|
||||
fn the_keywords_header_creates_a_keyword() {
|
||||
let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true}));
|
||||
// something picked in the Keyword List: not where a keyword made from the sidebar goes
|
||||
h.app.ui.keyword_list_selected = Some("aurora".into());
|
||||
let (button, head) = (widget(&h, "icon:keywordNew"), header(&h, "keywords"));
|
||||
assert!(head.contains_rect(button), "the + is in the header: {button:?} in {head:?}");
|
||||
click(&mut h, "icon:keywordNew");
|
||||
assert!(!h.app.ui.sidebar_section_collapsed("keywords"), "the + does not fold Keywords");
|
||||
let Some(crate::state::Dialog::KeywordTag { editing, name, parent, .. }) = h.app.ui.dialog.as_mut() else { panic!("{:?}", h.app.ui.dialog) };
|
||||
assert_eq!((editing.clone(), parent.clone()), (None, None));
|
||||
*name = "zebra crossing".into();
|
||||
let r = h.request("ui.dialog.confirm", json!({}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(has(&h, "source:keyword:zebra crossing"), "the new keyword is listed");
|
||||
}
|
||||
|
||||
/// A section's header button is part of every form of the header: folded (its row count sits to
|
||||
/// the left of the button) and pinned.
|
||||
#[test]
|
||||
fn a_header_button_stays_with_a_folded_header() {
|
||||
let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true}));
|
||||
for (section, button) in [("keywords", "icon:keywordNew"), ("albums", "icon:albumNew")] {
|
||||
click(&mut h, &format!("sidebarSection:{section}"));
|
||||
assert!(h.app.ui.sidebar_section_collapsed(section));
|
||||
let (count, plus) = (widget(&h, &format!("sidebarSectionCount:{section}")), widget(&h, button));
|
||||
assert!(count.right() <= plus.left(), "{section}: the count {count:?} is left of the button {plus:?}");
|
||||
}
|
||||
// sections without a button have none, and their count goes to the edge
|
||||
assert!(!h.app.widgets.iter().any(|(w, _)| w == "icon:byDateNew" || w == "icon:foldersNew"));
|
||||
}
|
||||
|
||||
/// A folded section says how many rows it holds (unless photo counts are off); an open one
|
||||
/// doesn't need to.
|
||||
#[test]
|
||||
|
||||
+1
-1
@@ -526,7 +526,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-BEHAV-DRAGDROP | Drag and drop | P1 | ✅ | `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels/grid.rs` (`drag_feedback`), `crates/ui-egui/src/panels/left.rs` (`drop_target`, `album_drag_over`, `top_level_drop_target`, `drag_auto_scroll`) | files → app (import); grid photos → an album row (adds the selection, with a count badge while dragging); sidebar album / folder rows → a folder (moves it; its name follows the pointer); a drag of albums or grid photos near the top or bottom edge of the sidebar scrolls it (faster nearer the edge, only while the pointer is over the sidebar) |
|
||||
| LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` | |
|
||||
| LR-BEHAV-SIDEBAR-COLLAPSE | Collapsible left-sidebar sections | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`section`, `section_header`), `crates/ui-egui/src/sidebar.rs` (`SidebarLayout`), `crates/ui-egui/src/tests_panels.rs` | click the Albums, Local, By Date, Folders or Keywords header (or drive `ui.clickWidget` `sidebarSection:<albums\|local\|byDate\|folders\|keywords>`) to fold or unfold the section; a chevron after the title shows the state; the choice is kept across restarts (`sidebar` in the UI state; the `collapsedSidebar` list of older settings files is still read). UI-only (no command), like the By Date / Keywords disclosure triangles; photos dropped on a folded Albums section have no target |
|
||||
| LR-BEHAV-SIDEBAR-SECTIONS | Left-sidebar sections share one frame and one arrangement | P2 | ✅ | `crates/ui-egui/src/sidebar.rs` (`SidebarSection`, `SidebarLayout`), `crates/ui-egui/src/panels/left.rs` (`section`, `section_header`, `tree_toggle`), `crates/ui-egui/src/tests_panels.rs` | Albums, Local, By Date, Folders and Keywords are drawn by one `section` frame (the same gap above, header, fold and accessibility name) from one arrangement, `sidebar` in the UI state: a list of `{id, hidden, collapsed}` top to bottom, also settable with `ui.set`. Whatever is read, every section is in it exactly once (unknown and repeated entries are dropped, the rest follow in the usual order), so a damaged or newer settings file never loses the other settings. Rows that open (album folders, Local folders, dates, library folders, keywords) share `tree_toggle`: the same triangle, the same remembered open state, announced as Expand / Collapse. A section with nothing to list (Folders before anything is imported, Keywords before any is added) stays, open, with a one-line note in place of rows (`sidebarEmpty:<id>`) instead of disappearing. `ui.set` reads `sidebar` strictly (`SidebarLayout::parse`): an unknown or repeated section, or a flag that is not true / false, is refused and changes nothing; sections left out follow in the usual order, shown and open; the older `collapsedSidebar` list still folds and unfolds. My Photos (All Photos, Recently Added, Picks, Missing Photos, Recently Deleted) is not a section: it stays on top |
|
||||
| LR-BEHAV-SIDEBAR-SECTIONS | Left-sidebar sections share one frame and one arrangement | P2 | ✅ | `crates/ui-egui/src/sidebar.rs` (`SidebarSection`, `SidebarLayout`), `crates/ui-egui/src/panels/left.rs` (`section`, `section_header`, `tree_toggle`), `crates/ui-egui/src/tests_panels.rs` | Albums, Local, By Date, Folders and Keywords are drawn by one `section` frame (the same gap above, header, fold and accessibility name) from one arrangement, `sidebar` in the UI state: a list of `{id, hidden, collapsed}` top to bottom, also settable with `ui.set`. Whatever is read, every section is in it exactly once (unknown and repeated entries are dropped, the rest follow in the usual order), so a damaged or newer settings file never loses the other settings. Rows that open (album folders, Local folders, dates, library folders, keywords) share `tree_toggle`: the same triangle, the same remembered open state, announced as Expand / Collapse. A section with nothing to list (Folders before anything is imported, Keywords before any is added) stays, open, with a one-line note in place of rows (`sidebarEmpty:<id>`) instead of disappearing. `ui.set` reads `sidebar` strictly (`SidebarLayout::parse`): an unknown or repeated section, or a flag that is not true / false, is refused and changes nothing; sections left out follow in the usual order, shown and open; the older `collapsedSidebar` list still folds and unfolds. A section may have one button at the end of its header, declared in one place (`header_button`: an icon, a tooltip, and either an action or a menu): the frame draws it at the visible edge in the header and its pinned copy, keeps its click from folding the section and leaves it room beside a folded section's count. Albums has + (`icon:albumNew`: Create Album / Smart Album / Folder…), Keywords has + (`icon:keywordNew`: Create Keyword Tag, for a keyword inside the default parent, not inside what the Keyword List has picked). My Photos (All Photos, Recently Added, Picks, Missing Photos, Recently Deleted) is not a section: it stays on top |
|
||||
| LR-BEHAV-SIDEBAR-HIDE | Show and hide left-sidebar sections | P2 | ✅ | `cmd:view.sidebarSection`, `cmd:view.sidebarReset`, `crates/ui-egui/src/sidebar.rs`, `crates/ui-egui/src/panels/left.rs` (`sections_menu`), `crates/ui-egui/src/menubar.rs`, `crates/ui-egui/src/tests_panels.rs` | View ▸ Sidebar Sections lists Albums, Local, By Date, Folders and Keywords in the sidebar's order, each checked while shown; unchecking one takes its header and rows off the sidebar, checking it puts it back where and how it was (still folded if it was). The same list opens with a right-click on any section header (with Hide This Section first; widgets `sidebarMenu:hide`, `sidebarMenu:section:<id>`, `sidebarMenu:reset`) and on the My Photos title (`sidebarTitle`), which is always there, so hidden sections can always come back. Reset Sidebar Sections (enabled once something changed) shows and opens every section in the usual order. `view.sidebarSection` `{section, show?, collapsed?}` flips shown / hidden when neither flag is given and refuses an unknown section or a flag that is not true / false. Hiding the section of what the grid shows (an album, a keyword filter) leaves the grid as it is; an album made while Albums is hidden or folded brings Albums back, open. Kept with the UI state (`sidebar`). My Photos itself can't be hidden |
|
||||
| LR-BEHAV-SIDEBAR-REORDER | Reorder left-sidebar sections | P2 | ✅ | `cmd:view.sidebarMoveSection`, `crates/ui-egui/src/sidebar.rs` (`move_before`, `move_by`), `crates/ui-egui/src/panels/left.rs` (`section_drag`, `section_drop_before`, `section_drag_feedback`), `crates/ui-egui/src/tests_panels.rs` | drag a section by its header: its name follows the pointer, a line (`sidebarSectionDrop`) shows the gap it would go to (above the section whose upper half the pointer is over, or below the last), releasing over the sidebar moves it there, Esc or releasing elsewhere leaves it; the sidebar scrolls when the drag nears its top or bottom edge. A click on the header still folds the section, and an album dragged onto the Albums header is still an album drag. The header's menu has Move Up / Move Down (`sidebarMenu:moveUp` / `moveDown`), one shown section at a time, stepping over hidden ones, disabled at the ends. `view.sidebarMoveSection` `{section, direction: up\|down}` or `{section, before: <section>\|null}` (null: last) answers `{moved, order}`. The order is kept with the UI state (`sidebar`). Recently Deleted is listed with My Photos (below Picks / Missing Photos), not after the sections. Not here: dragging My Photos, or rows from one section into another |
|
||||
| LR-BEHAV-SIDEBAR-STICKY | Pinned section header and section look | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`pinned_header`, `section_header`, `empty_hint`), `crates/ui-egui/src/tests_panels.rs` | while the rows of an open section are in view but its header has scrolled away, a copy of the header (`sidebarSectionPinned:<id>`, with the section's buttons and menu) stays at the top of the sidebar; the next section's header pushes it out; folding from it brings the section's own header into view. Rows scrolled beneath the pinned header are covered by it: an album or photos dropped on the pinned Albums header go to the top level / nowhere, never to the row under it; folding from it keeps the sideways scroll. Sections are told apart by a hairline in the gap above each; the fold chevron sits in the gutter, in the column of the rows' triangles (`sidebarSectionChevron:<id>`); a header highlights under the pointer; a folded section shows how many top-level rows it holds (`sidebarSectionCount:<id>`, with Show Photo Counts); a count of 0 is fainter. Not here: a height of its own per section (one scroll area serves the whole sidebar), a count for Recently Added, Local's Browse Folder… as a header button |
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
triangles, a highlight under the pointer. A folded section shows how many rows it holds, counts of 0 are fainter,
|
||||
and a section with nothing in it says so instead of disappearing.
|
||||
- Recently Deleted is listed with All Photos, Recently Added and Picks under My Photos.
|
||||
- Keywords has a + in its header, like Albums: Create Keyword Tag without opening the Keyword List.
|
||||
- Agents: `view.sidebarSection`, `view.sidebarMoveSection`, `view.sidebarReset`; `ui.set` takes `sidebar`.
|
||||
|
||||
### Activity stack
|
||||
|
||||
Reference in New Issue
Block a user