mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 03:57:57 -06:00
refactor(lightcraft-ui-egui): split panels/left.rs into panels/left/
This commit is contained in:
+25
-2233
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,415 @@
|
||||
use super::folders::{folder_menu, is_within};
|
||||
use super::rows::{INDENT_STEP, indent_x, row_sensed, set_tree_open, tree_open, tree_toggle};
|
||||
use super::*;
|
||||
|
||||
/// Albums: the tree of albums and the folders they are sorted into. `reveal`: an album just made,
|
||||
/// whose folders open down to it.
|
||||
pub(super) fn albums_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, reveal: Option<u64>) {
|
||||
let kids: AlbumKids = app.session.catalog.album_children_by_parent().into_iter().map(|(k, v)| (k, v.into_iter().cloned().collect())).collect();
|
||||
let cat = &app.session.catalog;
|
||||
let mut open_to = Vec::new();
|
||||
let mut cur = reveal.map(AlbumId).and_then(|id| cat.album(id)).and_then(|a| a.parent);
|
||||
while let Some(p) = cur.filter(|p| !open_to.contains(p) && open_to.len() < 64) {
|
||||
open_to.push(p);
|
||||
cur = cat.album(p).and_then(|a| a.parent);
|
||||
}
|
||||
albums_tree(app, ui, &kids, None, 0.0, &open_to);
|
||||
}
|
||||
|
||||
/// The albums inside each folder (`None`: the top level), in the order they are listed.
|
||||
type AlbumKids = std::collections::HashMap<Option<AlbumId>, Vec<Album>>;
|
||||
|
||||
/// `open_to`: folders forced open this frame (the way down to an album that was just made).
|
||||
fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, parent: Option<AlbumId>, indent: f32, open_to: &[AlbumId]) {
|
||||
// 64 folders deep at most (16 points a level): a catalog that loops must not overflow the stack
|
||||
if indent > 64.0 * INDENT_STEP {
|
||||
return;
|
||||
}
|
||||
let Some(kids) = all.get(&parent) else { return };
|
||||
for a in kids {
|
||||
if a.folder {
|
||||
let open_id = egui::Id::unique(("folder-open", a.id.0));
|
||||
let mut open = tree_open(ui, open_id, true);
|
||||
if open_to.contains(&a.id) && !open {
|
||||
open = true;
|
||||
set_tree_open(ui, open_id, true);
|
||||
}
|
||||
let resp =
|
||||
row_sensed(app, ui, &format!("folder:{}", a.id.0), Icon::Folder, &a.name, None, None, false, indent, Sense::click_and_drag(), None);
|
||||
// a folder is no source, so its row folds it too; the triangle is the same click, aimed
|
||||
let has_children = all.get(&Some(a.id)).is_some_and(|v| !v.is_empty());
|
||||
if resp.drag_started() {
|
||||
app.ui.dragging_album = Some(a.id.0);
|
||||
}
|
||||
if album_drag_over(app, ui, &resp, a, has_children.then_some(&mut open), indent) {
|
||||
set_tree_open(ui, open_id, true);
|
||||
}
|
||||
// what this frame draws; a click shows from the next one
|
||||
let draw_open = open;
|
||||
let mut toggled = false;
|
||||
if has_children {
|
||||
let tri = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("albumToggle:{}", a.id.0));
|
||||
toggled = tri.clicked();
|
||||
// the triangle sits on the row and takes its clicks: the menu opens from it too
|
||||
folder_menu(app, &tri, a);
|
||||
}
|
||||
// one click, one toggle: the triangle's is not the row's as well
|
||||
if resp.clicked() && !toggled {
|
||||
set_tree_open(ui, open_id, !open);
|
||||
}
|
||||
folder_menu(app, &resp, a);
|
||||
if draw_open {
|
||||
albums_tree(app, ui, all, Some(a.id), indent + INDENT_STEP, open_to);
|
||||
}
|
||||
} else {
|
||||
let sel = app.session.source == LibrarySource::Album(a.id);
|
||||
let icon = if a.is_smart() { Icon::SmartAlbum } else { Icon::Album };
|
||||
// cached: a smart album's count scans the catalog
|
||||
let now = (app.session.clock)();
|
||||
let n = app.caches.album_counts(&app.session.catalog, &now).get(&a.id).copied().unwrap_or(0);
|
||||
// the album B adds to is marked "+"
|
||||
let target =
|
||||
app.session.target_album.filter(|t| app.session.catalog.album(*t).is_some()).or_else(|| app.session.catalog.quick_collection());
|
||||
let label = if target == Some(a.id) { format!("{} +", a.name) } else { a.name.clone() };
|
||||
// a smart album whose rules no longer check (an album they test was deleted) says so
|
||||
let problems = app.caches.smart_album_problems(&app.session.catalog).get(&a.id).cloned().unwrap_or_default();
|
||||
let label = if problems.is_empty() { label } else { format!("⚠ {label}") };
|
||||
let mut resp = row_sensed(app, ui, &format!("album:{}", a.id.0), icon, &label, None, Some(n), sel, indent, Sense::click_and_drag(), None);
|
||||
if !problems.is_empty() {
|
||||
crate::widgets::register(ui, format!("albumProblem:{}", a.id.0), resp.rect);
|
||||
}
|
||||
if resp.drag_started() {
|
||||
app.ui.dragging_album = Some(a.id.0);
|
||||
}
|
||||
album_drag_over(app, ui, &resp, a, None, indent);
|
||||
if !a.is_smart() {
|
||||
drop_target(app, ui, &resp, a);
|
||||
}
|
||||
if let Some(rules) = &a.smart {
|
||||
// one tooltip: the rules, then what needs fixing in them
|
||||
let mut tip = crate::i18n::tr_format!("Smart album: {}", crate::i18n::filter_label(rules, &app.session.catalog));
|
||||
if !problems.is_empty() {
|
||||
tip.push_str("\n\n");
|
||||
tip.push_str(crate::i18n::tr("Some rules need fixing (Edit Smart Album…):"));
|
||||
for p in &problems {
|
||||
tip.push('\n');
|
||||
tip.push_str(&crate::i18n::problem_line(p));
|
||||
}
|
||||
}
|
||||
resp = resp.on_hover_text(tip);
|
||||
}
|
||||
if resp.clicked() {
|
||||
let _ = app.run("library.source", json!({"kind": "album", "id": a.id.0}));
|
||||
}
|
||||
folder_menu(app, &resp, a);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the pointer is over `rect` and over what the panel shows of it (a row scrolled out of
|
||||
/// sight is not a drop target).
|
||||
fn pointer_over(ui: &egui::Ui, rect: Rect) -> bool {
|
||||
crate::docking::drop_target(ui, rect, ui.input(|i| i.pointer.latest_pos()))
|
||||
}
|
||||
|
||||
/// [`pointer_over`] for a row: a row scrolled beneath the pinned header is covered by it, so the
|
||||
/// pointer there is over the header, not over the row.
|
||||
pub(super) fn pointer_over_row(ui: &egui::Ui, rect: Rect) -> bool {
|
||||
pointer_over(ui, rect) && !pointer_on_pinned_header(ui)
|
||||
}
|
||||
|
||||
/// Where the pinned header was drawn last frame, if one was (rows are laid out before it).
|
||||
fn pinned_rect(ui: &egui::Ui) -> Option<Rect> {
|
||||
ui.data(|d| d.get_temp::<Option<Rect>>(egui::Id::unique("left-pinned-rect"))).flatten()
|
||||
}
|
||||
|
||||
fn pointer_on_pinned_header(ui: &egui::Ui) -> bool {
|
||||
let pos = ui.input(|i| i.pointer.latest_pos());
|
||||
pinned_rect(ui).zip(pos).is_some_and(|(r, p)| r.contains(p))
|
||||
}
|
||||
|
||||
/// How close to the top or bottom edge of the sidebar (points) a dragged album starts scrolling it.
|
||||
const SCROLL_EDGE: f32 = 36.0;
|
||||
|
||||
/// The fastest the sidebar scrolls for a drag (points per second).
|
||||
const SCROLL_MAX_SPEED: f32 = 700.0;
|
||||
|
||||
/// How fast the sidebar scrolls (points per second, positive = down) for a dragged item with the
|
||||
/// pointer at `y`, the visible part being `top..bottom`: none in the middle, faster the nearer to
|
||||
/// the edge, the top speed at and past it.
|
||||
pub fn auto_scroll_speed(y: f32, top: f32, bottom: f32) -> f32 {
|
||||
let depth = |dist: f32| ((SCROLL_EDGE - dist) / SCROLL_EDGE).clamp(0.0, 1.0);
|
||||
SCROLL_MAX_SPEED * (depth(bottom - y) - depth(y - top))
|
||||
}
|
||||
|
||||
/// While an album, or photos from the grid, are dragged over the sidebar: scrolls it when the
|
||||
/// pointer is near its top or bottom edge, so rows beyond the visible part can be reached. Call
|
||||
/// inside the scroll area.
|
||||
pub(super) fn drag_auto_scroll(app: &LightcraftApp, ui: &egui::Ui) {
|
||||
if app.ui.dragging_album.is_none() && app.ui.dragging_photos.is_none() && app.ui.dragging_section.is_none() {
|
||||
return;
|
||||
}
|
||||
let view = ui.clip_rect();
|
||||
let Some(p) = ui.input(|i| i.pointer.latest_pos()).filter(|p| p.x >= view.left() && p.x <= view.right()) else { return };
|
||||
// a section picked up by its pinned header starts inside the top edge: it scrolls once it has
|
||||
// left the header. Any other drag scrolls there as at any edge, to reach what is above
|
||||
let from_pinned = egui::Id::unique("left-section-drag-from-pinned");
|
||||
if app.ui.dragging_section.is_some() && ui.data(|d| d.get_temp::<bool>(from_pinned)) == Some(true) {
|
||||
if pointer_on_pinned_header(ui) {
|
||||
return;
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(from_pinned, false));
|
||||
}
|
||||
let speed = auto_scroll_speed(p.y, view.top(), view.bottom());
|
||||
if speed != 0.0 {
|
||||
// a positive delta moves the content down: the view goes up
|
||||
ui.scroll_with_delta_animation(vec2(0.0, -speed * ui.input(|i| i.stable_dt).min(0.1)), egui::style::ScrollAnimation::none());
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// Room a colour label's dot takes before a row's count.
|
||||
pub(super) const MARK_W: f32 = 12.0;
|
||||
|
||||
/// How long a dragged album must rest on a closed folder before it opens.
|
||||
const HOVER_OPEN_SECS: f64 = 0.6;
|
||||
|
||||
/// Whether the album `dragged` may be dropped into the folder `target` (`None`: the top level):
|
||||
/// a folder other than where it already is, and not itself or something inside it.
|
||||
fn can_drop_album(app: &LightcraftApp, dragged: AlbumId, target: Option<AlbumId>) -> bool {
|
||||
let Some(d) = app.session.catalog.album(dragged) else { return false };
|
||||
match target {
|
||||
None => d.parent.is_some(),
|
||||
Some(t) => app.session.catalog.album(t).is_some_and(|f| f.folder) && d.parent != Some(t) && !is_within(app, t, dragged),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends a drag that ran `result`: the folders down to the album open so it shows in its new place.
|
||||
fn finish_drop(app: &mut LightcraftApp, ui: &egui::Ui, dragged: AlbumId, result: Result<serde_json::Value, String>) {
|
||||
forget_hover(app, ui.ctx());
|
||||
match result {
|
||||
Ok(_) => {
|
||||
app.ui.reveal_album = Some(dragged.0);
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
Err(e) => app.toast(ui.ctx(), e),
|
||||
}
|
||||
app.ui.dragging_album = None;
|
||||
}
|
||||
|
||||
/// Forgets how long the pointer rested on each folder (a new drag starts counting again).
|
||||
fn forget_hover(app: &LightcraftApp, ctx: &egui::Context) {
|
||||
let ids: Vec<u64> = app.session.catalog.albums().filter(|a| a.folder).map(|a| a.id.0).collect();
|
||||
ctx.data_mut(|d| {
|
||||
for id in ids {
|
||||
d.remove_temp::<f64>(egui::Id::unique(("album-hover", id)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Moves the dragged album into a folder (`album.move`, one undo step; `None`: the top level).
|
||||
fn drop_album(app: &mut LightcraftApp, ui: &egui::Ui, dragged: AlbumId, target: Option<AlbumId>) {
|
||||
let r = app.run("album.move", json!({"id": dragged.0, "parent": target.map(|t| t.0)}));
|
||||
finish_drop(app, ui, dragged, r);
|
||||
}
|
||||
|
||||
/// Places the dragged album in `parent` before the sibling `before` (`None`: last of its kind), by
|
||||
/// hand (`album.reorder`, one undo step).
|
||||
fn place_album(app: &mut LightcraftApp, ui: &egui::Ui, dragged: AlbumId, parent: Option<AlbumId>, before: Option<AlbumId>) {
|
||||
let r = app.run("album.reorder", json!({"id": dragged.0, "parent": parent.map(|p| p.0), "before": before.map(|b| b.0)}));
|
||||
finish_drop(app, ui, dragged, r);
|
||||
}
|
||||
|
||||
/// What dropping the dragged album on a row would do.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum AlbumDrop {
|
||||
/// Into the folder.
|
||||
Into(AlbumId),
|
||||
/// Beside the row: in `parent`, before `before` (`None`: last of its kind); the line is drawn
|
||||
/// above the row (`above`) or below it.
|
||||
Place { parent: Option<AlbumId>, before: Option<AlbumId>, above: bool },
|
||||
}
|
||||
|
||||
/// What dropping `dragged` at `frac` (0 = top edge, 1 = bottom edge) of the row of `target` does,
|
||||
/// if anything. An album over a folder goes inside it; a folder over a folder goes inside by its
|
||||
/// middle and beside it by its edges; an album over an album goes before it by its top half and
|
||||
/// after it by its bottom half; a folder over an album does nothing. Placing where it already
|
||||
/// is, or inside itself, is nothing too. `expanded`: `target` is a folder shown open.
|
||||
fn album_drop_at(app: &LightcraftApp, dragged: AlbumId, target: &Album, frac: f32, expanded: bool) -> Option<AlbumDrop> {
|
||||
let cat = &app.session.catalog;
|
||||
let d = cat.album(dragged)?;
|
||||
let into = || can_drop_album(app, dragged, Some(target.id)).then_some(AlbumDrop::Into(target.id));
|
||||
if d.folder != target.folder {
|
||||
return if target.folder { into() } else { None };
|
||||
}
|
||||
if target.id == dragged {
|
||||
return None;
|
||||
}
|
||||
let after = frac >= if target.folder { 0.7 } else { 0.5 };
|
||||
// the middle of a folder, and the bottom of an open one (its first child is next), mean inside
|
||||
if target.folder && frac >= 0.3 && (!after || expanded) {
|
||||
return into();
|
||||
}
|
||||
let parent = target.parent;
|
||||
if parent.is_some_and(|p| is_within(app, p, dragged)) {
|
||||
return None;
|
||||
}
|
||||
let sibs = cat.album_children(parent);
|
||||
let at = sibs.iter().position(|a| a.id == target.id)?;
|
||||
let was = sibs.iter().position(|a| a.id == dragged);
|
||||
if !after {
|
||||
(was.map(|w| w + 1) != Some(at)).then_some(AlbumDrop::Place { parent, before: Some(target.id), above: true })
|
||||
} else {
|
||||
let next = sibs.iter().skip(at + 1).find(|a| a.id != dragged).filter(|a| a.folder == target.folder).map(|a| a.id);
|
||||
(was != Some(at + 1)).then_some(AlbumDrop::Place { parent, before: next, above: false })
|
||||
}
|
||||
}
|
||||
|
||||
/// A row while an album is dragged: a folder outlined under the pointer when the drop would go
|
||||
/// inside it, a line above or below the row when it would go beside it, and the move or placement
|
||||
/// done on release. A closed folder with something inside (`open`) opens once the pointer rests on
|
||||
/// it; returns whether it did.
|
||||
fn album_drag_over(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, row: &Album, open: Option<&mut bool>, indent: f32) -> bool {
|
||||
let hover_id = egui::Id::unique(("album-hover", row.id.0));
|
||||
let Some(dragged) = app.ui.dragging_album.map(AlbumId) else { return false };
|
||||
let pos = ui.input(|i| i.pointer.latest_pos()).filter(|_| pointer_over_row(ui, resp.rect));
|
||||
let Some(pos) = pos else {
|
||||
if ui.data(|d| d.get_temp::<f64>(hover_id)).is_some() {
|
||||
ui.data_mut(|d| d.remove_temp::<f64>(hover_id));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
let expanded = open.as_deref().copied().unwrap_or(false);
|
||||
let frac = ((pos.y - resp.rect.top()) / resp.rect.height().max(1.0)).clamp(0.0, 1.0);
|
||||
let drop = album_drop_at(app, dragged, row, frac, expanded);
|
||||
let mut opened = false;
|
||||
// only a drop that goes inside opens the folder: on its edges the rows would shift under the pointer
|
||||
let inside = matches!(drop, Some(AlbumDrop::Into(_)));
|
||||
if !inside && ui.data(|d| d.get_temp::<f64>(hover_id)).is_some() {
|
||||
ui.data_mut(|d| d.remove_temp::<f64>(hover_id));
|
||||
}
|
||||
if inside
|
||||
&& let Some(open) = open
|
||||
&& !*open
|
||||
{
|
||||
let now = ui.input(|i| i.time);
|
||||
let since = ui.data_mut(|d| *d.get_temp_mut_or_insert_with(hover_id, || now));
|
||||
if now - since >= HOVER_OPEN_SECS {
|
||||
*open = true;
|
||||
opened = true;
|
||||
} else {
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let released = ui.input(|i| i.pointer.primary_released());
|
||||
match drop {
|
||||
Some(AlbumDrop::Into(folder)) => {
|
||||
ui.painter().rect_stroke(resp.rect.shrink2(vec2(8.0, 1.0)), 4.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
|
||||
if released {
|
||||
drop_album(app, ui, dragged, Some(folder));
|
||||
}
|
||||
}
|
||||
Some(AlbumDrop::Place { parent, before, above }) => {
|
||||
let y = if above { resp.rect.top() + 0.5 } else { resp.rect.bottom() - 0.5 };
|
||||
let (x0, x1) = (resp.rect.left() + 18.0 + indent_x(ui.ctx(), indent), ui.clip_rect().right() - 8.0);
|
||||
ui.painter().circle_stroke(pos2(x0, y), 3.0, egui::Stroke::new(1.5, t.accent));
|
||||
ui.painter().line_segment([pos2(x0 + 3.0, y), pos2(x1.max(x0 + 3.0), y)], egui::Stroke::new(2.0, t.accent));
|
||||
if released {
|
||||
place_album(app, ui, dragged, parent, before);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
opened
|
||||
}
|
||||
|
||||
/// While an album that sits in a folder is dragged: the "Albums" header takes it back to the top
|
||||
/// level (outlined, with a hint). The header stays in view however long the tree is, and nothing
|
||||
/// moves when it appears.
|
||||
pub(super) fn top_level_drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, header: Rect) {
|
||||
let Some(dragged) = app.ui.dragging_album.map(AlbumId) else { return };
|
||||
if !can_drop_album(app, dragged, None) {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
register(ui, "albumDrop:top", header);
|
||||
let over = pointer_over(ui, header);
|
||||
let stroke = if over { egui::Stroke::new(1.5, t.accent) } else { egui::Stroke::new(1.0, t.text_dim.gamma_multiply(0.5)) };
|
||||
ui.painter().rect_stroke(header.shrink2(vec2(8.0, 3.0)), 4.0, stroke, egui::StrokeKind::Inside);
|
||||
// right-aligned, left of the + button
|
||||
let edge = header.right().min(ui.clip_rect().right());
|
||||
ui.painter().text(pos2(edge - 56.0, header.center().y), Align2::RIGHT_CENTER, crate::i18n::tr("Top Level"), t.font(12.5), t.text_dim);
|
||||
if over && ui.input(|i| i.pointer.primary_released()) {
|
||||
drop_album(app, ui, dragged, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// While an album is dragged in the sidebar: its name at the pointer; the drag ends when the
|
||||
/// button is up (the drop targets act on the release frame, before this runs).
|
||||
pub fn album_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(id) = app.ui.dragging_album else { return };
|
||||
let (released, down, pos, esc) =
|
||||
ctx.input(|i| (i.pointer.primary_released(), i.pointer.primary_down(), i.pointer.latest_pos(), i.key_pressed(egui::Key::Escape)));
|
||||
if released || !down || esc {
|
||||
app.ui.dragging_album = None;
|
||||
forget_hover(app, ctx);
|
||||
return;
|
||||
}
|
||||
let (Some(pos), Some(name)) = (pos, app.session.catalog.album(AlbumId(id)).map(|a| a.name.clone())) else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
egui::Area::new(egui::Id::unique("drag-album")).order(egui::Order::Tooltip).interactable(false).fixed_pos(pos + vec2(14.0, 10.0)).show(
|
||||
ctx,
|
||||
|ui| {
|
||||
egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| {
|
||||
ui.label(egui::RichText::new(name).color(egui::Color32::WHITE).font(t.semibold(12.0)));
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// An album row while photos are dragged from the grid: highlighted under the pointer; a
|
||||
/// release there adds them.
|
||||
fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, a: &Album) {
|
||||
let Some(ids) = app.ui.dragging_photos.clone() else { return };
|
||||
// only where the row can be seen: not scrolled out of the sidebar or beneath the pinned header
|
||||
let over = pointer_over_row(ui, resp.rect);
|
||||
if !over {
|
||||
return;
|
||||
}
|
||||
let released = ui.input(|i| i.pointer.any_released());
|
||||
// albums hold library photos (issue #684): Local ones can't be dropped in; the row doesn't
|
||||
// light up, and a release says why
|
||||
let photos: Vec<lightcraft_catalog::PhotoId> = ids.iter().map(|id| lightcraft_catalog::PhotoId(*id)).collect();
|
||||
if crate::panels::grid::local_only(app, &photos) {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed);
|
||||
if released {
|
||||
app.toast(ui.ctx(), crate::panels::grid::albums_hint());
|
||||
app.ui.dragging_photos = None;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.painter().rect_stroke(resp.rect.shrink2(vec2(8.0, 1.0)), 4.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
|
||||
if released {
|
||||
match app.run("album.addPhotos", json!({"id": a.id.0, "ids": ids})) {
|
||||
Ok(r) => {
|
||||
let n = r.get("added").and_then(serde_json::Value::as_u64).unwrap_or(0);
|
||||
let skipped = r.get("skipped").and_then(serde_json::Value::as_u64).unwrap_or(0);
|
||||
let mut msg = crate::i18n::tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, a.name, n = n);
|
||||
if skipped > 0 {
|
||||
msg.push_str(" · ");
|
||||
msg.push_str(&crate::i18n::tr_format!(
|
||||
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)",
|
||||
if skipped == 1 { "" } else { "s" },
|
||||
n = skipped
|
||||
));
|
||||
}
|
||||
app.toast(ui.ctx(), msg);
|
||||
}
|
||||
Err(e) => app.toast(ui.ctx(), e),
|
||||
}
|
||||
app.ui.dragging_photos = None;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use super::rows::{INDENT_STEP, row, tree_open, tree_toggle};
|
||||
use super::*;
|
||||
|
||||
/// By Date: year → month → day; a click filters by that prefix, the triangle opens a level.
|
||||
pub(super) fn date_rows(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let groups = app.caches.date_groups(&app.session.catalog);
|
||||
for g in groups.iter() {
|
||||
if date_row(app, ui, &g.year, &crate::i18n::date_group_label(&g.year, true), g.count, 0.0) {
|
||||
for (m, n) in &g.months {
|
||||
let label = crate::i18n::date_group_label(m, true);
|
||||
if date_row(app, ui, m, &label, *n, INDENT_STEP) {
|
||||
for (d, n) in g.days.iter().filter(|(d, _)| d.starts_with(m.as_str())) {
|
||||
let label = crate::i18n::date_group_label(d, true);
|
||||
date_row(app, ui, d, &label, *n, 2.0 * INDENT_STEP);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One By Date row (`key`: `YYYY`, `YYYY-MM` or `YYYY-MM-DD`); returns whether it is open.
|
||||
fn date_row(app: &mut LightcraftApp, ui: &mut egui::Ui, key: &str, label: &str, count: usize, indent: f32) -> bool {
|
||||
let open_id = egui::Id::unique(("date-open", key.to_string()));
|
||||
let mut open = tree_open(ui, open_id, false);
|
||||
let sel = app.session.filter.date.as_deref() == Some(key);
|
||||
let resp = row(app, ui, &format!("date:{key}"), Icon::Clock, label, Some(count), sel, indent);
|
||||
if key.len() < 10 {
|
||||
tree_toggle(ui, &resp, indent, open_id, &mut open, format!("dateToggle:{key}"));
|
||||
}
|
||||
if resp.clicked() {
|
||||
let v = if sel { serde_json::Value::Null } else { json!(key) };
|
||||
browse_all_photos(app, !sel);
|
||||
let _ = app.run("library.filter", json!({"date": v}));
|
||||
}
|
||||
open
|
||||
}
|
||||
|
||||
/// By Date and Keywords count every photo in the library, so choosing a row shows those photos
|
||||
/// from All Photos, not from whatever album or folder happened to be open, where they could be
|
||||
/// missing (issue #341). Only when choosing (`on`), not when clearing the row again.
|
||||
pub(crate) fn browse_all_photos(app: &mut LightcraftApp, on: bool) {
|
||||
if on && app.session.source != LibrarySource::All {
|
||||
let _ = app.run("library.source", json!({"kind": "all"}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
use super::albums::pointer_over_row;
|
||||
use super::local::{same_folder, subfolders};
|
||||
use super::rows::{INDENT_STEP, LIBFOLDER_REVEALED, out_of_sight, row, row_sensed, set_tree_open, tree_open, tree_toggle};
|
||||
use super::*;
|
||||
|
||||
/// A folder on disk with a disclosure triangle: click browses it, the triangle lists its
|
||||
/// subfolders. In the tree that holds the folder being browsed (`reveal`), the folders on the
|
||||
/// way down to it open whenever the browsed folder changes, so it shows highlighted in place.
|
||||
/// `transient`: a top-level row listed for this session only (it offers Keep in Local).
|
||||
/// `place`: the top-level folder this tree hangs from. A folder can be listed in more than one
|
||||
/// tree (Pictures on its own and inside Home): each row opens and closes by itself, and their
|
||||
/// triangles have ids of their own, else the copy drawn last takes the other's clicks (#1093).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn folder_tree(
|
||||
app: &mut LightcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
place: &str,
|
||||
name: &str,
|
||||
path: &str,
|
||||
indent: f32,
|
||||
current: Option<&str>,
|
||||
reveal: bool,
|
||||
transient: bool,
|
||||
) {
|
||||
let open_id = egui::Id::unique(("folder-open", place.to_string(), path.to_string()));
|
||||
let sel = current.is_some_and(|c| same_folder(c, path));
|
||||
let on_the_way = !sel && current.is_some_and(|c| lightcraft_catalog::query::folder_within(c, path));
|
||||
let mut open = tree_open(ui, open_id, false);
|
||||
if reveal && on_the_way {
|
||||
// opened once per browsed folder: collapsing it again afterwards sticks
|
||||
let revealed_id = egui::Id::unique(("folder-revealed", place.to_string(), path.to_string()));
|
||||
let target = current.map(str::to_string);
|
||||
if ui.data(|d| d.get_temp::<Option<String>>(revealed_id)) != Some(target.clone()) {
|
||||
open = true;
|
||||
set_tree_open(ui, open_id, true);
|
||||
ui.data_mut(|d| d.insert_temp(revealed_id, target));
|
||||
}
|
||||
}
|
||||
// at the tree's own indent, as in Albums and Folders: the triangle sits in the gutter and the
|
||||
// icon lines up with the rows above
|
||||
let resp = row(app, ui, &format!("local:{path}"), Icon::Folder, name, None, sel, indent).on_hover_text(path);
|
||||
let tr = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("folderToggle:{path}"));
|
||||
if !tr.clicked()
|
||||
&& resp.clicked()
|
||||
&& let Err(e) = app.run("library.browse", json!({"path": path}))
|
||||
{
|
||||
app.toast(ui.ctx(), e);
|
||||
}
|
||||
crate::widgets::context_menu(&resp, |ui| {
|
||||
// the library actions of the Folders tree, when this folder (or one inside it) holds library photos
|
||||
let library = app.caches.folder_tree(&app.session.catalog);
|
||||
let held = library_node_for(&library, path);
|
||||
if (transient || indent > 0.0)
|
||||
&& ui
|
||||
.button(crate::i18n::tr(if transient { "Keep in Local" } else { "Add to Local" }))
|
||||
.on_hover_text(crate::i18n::tr("List this folder in Local from now on"))
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("local.addRoot", json!({"path": path}));
|
||||
ui.close();
|
||||
}
|
||||
if indent == 0.0
|
||||
&& ui
|
||||
.button(crate::i18n::tr("Remove from Local"))
|
||||
.on_hover_text(crate::i18n::tr("Hides this shortcut only; the folder and its photos stay as they are"))
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("local.hide", json!({"path": path}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(crate::i18n::tr("Rename Folder…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::TextPrompt {
|
||||
title: crate::i18n::tr_format!("Rename “{name}”", name = name),
|
||||
hint: "Folder name (renamed on disk; its photos follow)".into(),
|
||||
value: name.to_string(),
|
||||
command: "folder.rename".into(),
|
||||
params: json!({"path": path}),
|
||||
key: "name".into(),
|
||||
});
|
||||
}
|
||||
if app.services.pick_folder.is_some() && ui.button(crate::i18n::tr("Move Folder To…")).clicked() {
|
||||
let into = app.services.pick_folder.as_mut().and_then(|f| f());
|
||||
if let Some(into) = into {
|
||||
match app.run("folder.move", json!({"path": path, "into": into})) {
|
||||
Ok(r) => app.toast(ui.ctx(), crate::i18n::tr_format!("Moved; {} photo(s) relinked", r["relinked"])),
|
||||
Err(e) => app.toast(ui.ctx(), e),
|
||||
}
|
||||
}
|
||||
}
|
||||
if app.services.reveal.is_some()
|
||||
&& ui.button(crate::i18n::tr(crate::menus::reveal_label())).clicked()
|
||||
&& let Some(f) = app.services.reveal.as_mut()
|
||||
{
|
||||
let _ = f(path);
|
||||
}
|
||||
if let Some(held) = &held {
|
||||
synchronize_item(app, ui, &held.0, &held.1);
|
||||
}
|
||||
});
|
||||
if open && indent < INDENT_STEP * 8.0 {
|
||||
for (n, p) in subfolders(ui, path) {
|
||||
folder_tree(app, ui, place, &n, &p, indent + INDENT_STEP, current, reveal, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) {
|
||||
crate::widgets::context_menu(resp, |ui| {
|
||||
if a.folder {
|
||||
// the same commands as File ▸ New…, aimed at this folder
|
||||
crate::widgets::menu_button(ui, crate::i18n::tr("New"), |ui| {
|
||||
for (label, command) in [
|
||||
("Create Album…", "dialog.newAlbum"),
|
||||
("Create Smart Album…", "dialog.smartAlbum"),
|
||||
("Create Smart Album from Filter…", "dialog.newSmartAlbum"),
|
||||
("Create Folder…", "dialog.newFolder"),
|
||||
] {
|
||||
if ui.button(crate::i18n::tr(label)).clicked() {
|
||||
let _ = app.run(command, json!({"parent": a.id.0}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
if app.session.catalog.album_children_are_ordered(Some(a.id))
|
||||
&& ui.button(crate::i18n::tr("Sort Contents A–Z")).on_hover_text(crate::i18n::tr("Go back to listing them by name")).clicked()
|
||||
{
|
||||
let _ = app.run("album.sort", json!({"parent": a.id.0}));
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
}
|
||||
if !a.folder && !a.is_smart() {
|
||||
let local_only = crate::panels::grid::local_only(app, &app.session.selected());
|
||||
let b = ui
|
||||
.add_enabled(!local_only, egui::Button::new(crate::i18n::tr("Add Selected Photos")))
|
||||
.on_disabled_hover_text(crate::panels::grid::albums_hint());
|
||||
if b.clicked() {
|
||||
match app.run("album.addPhotos", json!({"id": a.id.0})) {
|
||||
Ok(r) => crate::panels::grid::toast_skipped(app, ui.ctx(), &r),
|
||||
Err(e) => app.toast(ui.ctx(), e),
|
||||
}
|
||||
}
|
||||
// File ▸ Import's ways in; the import review opens with this album chosen
|
||||
let menu = crate::widgets::menu_button(ui, crate::i18n::tr("Import"), |ui| {
|
||||
let mut entry = |ui: &mut egui::Ui, label: String, widget: String, command: &str, params: serde_json::Value| {
|
||||
let r = ui.button(label);
|
||||
crate::widgets::register(ui, widget, r.rect);
|
||||
if r.clicked() {
|
||||
if let Err(e) = app.run(command, params) {
|
||||
app.toast(ui.ctx(), e);
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
};
|
||||
for (label, command, widget) in [
|
||||
("Import Photos…", "file.addPhotos", "button:albumImportPhotos"),
|
||||
("Import from Folder…", "file.addFolder", "button:albumImportFolder"),
|
||||
] {
|
||||
entry(ui, crate::i18n::tr(label).into(), widget.into(), command, json!({"album": a.id.0}));
|
||||
}
|
||||
// like File ▸ Import from Device: one entry per camera or card found
|
||||
let device = crate::widgets::menu_button(ui, crate::i18n::tr("Import from Device"), |ui| {
|
||||
let devices = lightcraft_engine::devices::devices();
|
||||
if devices.is_empty() {
|
||||
ui.add_enabled(false, egui::Button::new(crate::i18n::tr("No Camera or Card Found")));
|
||||
}
|
||||
for (i, d) in devices.into_iter().enumerate() {
|
||||
let params = json!({"path": d.path, "album": a.id.0});
|
||||
entry(ui, format!("{}…", d.name), format!("button:albumImportDevice:{i}"), "file.addFromDevice", params);
|
||||
}
|
||||
});
|
||||
crate::widgets::register(ui, "button:albumImportDevice", device.response.rect);
|
||||
});
|
||||
crate::widgets::register(ui, "button:albumImport", menu.response.rect);
|
||||
}
|
||||
if !a.folder && !a.is_smart() {
|
||||
let is_target = app.session.target_album == Some(a.id) || (app.session.target_album.is_none() && a.quick);
|
||||
if !is_target && ui.button(crate::i18n::tr("Set as Target Album (B adds to it)")).clicked() {
|
||||
let _ = app.run("album.setTarget", json!({"id": if a.quick { serde_json::Value::Null } else { json!(a.id.0) }}));
|
||||
}
|
||||
if is_target && !a.quick && ui.button(crate::i18n::tr("Stop Using as Target Album")).clicked() {
|
||||
let _ = app.run("album.setTarget", json!({"id": null}));
|
||||
}
|
||||
}
|
||||
if a.quick && ui.button(crate::i18n::tr("Clear Quick Collection")).clicked() {
|
||||
let _ = app.run("album.clearQuick", json!({}));
|
||||
}
|
||||
if a.is_smart() && ui.button(crate::i18n::tr("Edit Smart Album…")).clicked() {
|
||||
// the same editor as `dialog.smartAlbum` (older smart albums keep their filter fields;
|
||||
// it works on the rule set, upgraded)
|
||||
let _ = app.run("dialog.smartAlbum", json!({"id": a.id.0}));
|
||||
}
|
||||
if a.is_smart() && ui.button(crate::i18n::tr("Update Rules from Current Filter")).clicked() {
|
||||
// refused when the view would make the album test itself: say why
|
||||
if let Err(e) = app.run("album.setRules", json!({"id": a.id.0, "fromView": true})) {
|
||||
app.toast(ui.ctx(), e);
|
||||
}
|
||||
}
|
||||
if !a.folder {
|
||||
// export: show the album, select its photos, then the dialog / a preset
|
||||
let show_all = |app: &mut LightcraftApp| {
|
||||
let _ = app.run("library.source", json!({"kind": "album", "id": a.id.0}));
|
||||
let _ = app.run("library.selectAll", json!({}));
|
||||
};
|
||||
let has_photos = app.session.catalog.album_count(a.id) > 0;
|
||||
if ui.add_enabled(has_photos, egui::Button::new(crate::i18n::tr("Export Album…"))).clicked() {
|
||||
show_all(app);
|
||||
let _ = app.run("dialog.export", json!({}));
|
||||
}
|
||||
ui.add_enabled_ui(has_photos, |ui| {
|
||||
crate::widgets::menu_button(ui, crate::i18n::tr("Export Album with Preset"), |ui| {
|
||||
for (p, _) in app.session.all_export_presets() {
|
||||
if ui.button(&p.name).clicked() {
|
||||
show_all(app);
|
||||
if let Err(e) = app.run("app.export", json!({"preset": p.name, "background": true})) {
|
||||
app.toast(ui.ctx(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.separator();
|
||||
}
|
||||
// move into another folder (not into itself or one of its own subfolders)
|
||||
let mut folders: Vec<(u64, String)> =
|
||||
app.session.catalog.albums().filter(|f| f.folder && !is_within(app, f.id, a.id)).map(|f| (f.id.0, f.name.clone())).collect();
|
||||
folders.sort_by_key(|(_, n)| n.to_lowercase());
|
||||
crate::widgets::menu_button(ui, crate::i18n::tr("Move to"), |ui| {
|
||||
if ui.add_enabled(a.parent.is_some(), egui::Button::new(crate::i18n::tr("Top Level"))).clicked() {
|
||||
let _ = app.run("album.move", json!({"id": a.id.0, "parent": null}));
|
||||
}
|
||||
for (fid, name) in &folders {
|
||||
if ui.add_enabled(a.parent.map(|p| p.0) != Some(*fid), egui::Button::new(name)).clicked() {
|
||||
let _ = app.run("album.move", json!({"id": a.id.0, "parent": fid}));
|
||||
}
|
||||
}
|
||||
});
|
||||
if ui.button(crate::i18n::tr("Rename…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::RenameAlbum { id: a.id.0, name: a.name.clone() });
|
||||
}
|
||||
let delete = ui.button(crate::i18n::tr("Delete"));
|
||||
register(ui, format!("albumDelete:{}", a.id.0), delete.rect);
|
||||
let delete =
|
||||
delete.on_hover_text(crate::i18n::tr("Photos stay in All Photos. Deleting a folder also deletes its albums. You can undo this."));
|
||||
if delete.clicked() {
|
||||
if let Err(e) = app.run("album.delete", json!({"id": a.id.0})) {
|
||||
app.toast(ui.ctx(), e);
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether `id` is `ancestor` or lies inside it.
|
||||
pub(super) fn is_within(app: &LightcraftApp, id: lightcraft_catalog::AlbumId, ancestor: lightcraft_catalog::AlbumId) -> bool {
|
||||
let mut cur = Some(id);
|
||||
let mut guard = 0;
|
||||
while let Some(c) = cur {
|
||||
if c == ancestor {
|
||||
return true;
|
||||
}
|
||||
cur = app.session.catalog.album(c).and_then(|x| x.parent);
|
||||
guard += 1;
|
||||
if guard > 64 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// "Folders" (drawn in `show`): where on disk the library's photos were imported from, with photo counts (see
|
||||
// `lightcraft_catalog::folders`). A click makes that folder the source, like an album or a Local
|
||||
// folder: its photos and those of the folders inside it fill the grid. The triangle opens a
|
||||
// level. Only folders holding imported photos are listed; every folder on disk is under Local.
|
||||
|
||||
/// Whenever the shown folder changes (a click, an agent, a rename or its undo), open the rows
|
||||
/// above it so it is on screen; folding one by hand afterwards sticks until the choice changes.
|
||||
pub(super) fn reveal_chosen(app: &LightcraftApp, ui: &egui::Ui, tree: &[FolderNode]) {
|
||||
let shown = app.session.library_folder.clone().filter(|_| app.session.source == LibrarySource::LibraryFolder);
|
||||
let chosen = shown.filter(|c| !lightcraft_catalog::query::folder_key(c).is_empty());
|
||||
let seen = egui::Id::unique(LIBFOLDER_REVEALED);
|
||||
let now = chosen.as_deref().map(lightcraft_catalog::query::folder_key);
|
||||
if ui.data(|d| d.get_temp::<Option<String>>(seen)) == Some(now.clone()) {
|
||||
return;
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(seen, now));
|
||||
let Some(chosen) = chosen else { return };
|
||||
fn open_above(ui: &egui::Ui, nodes: &[FolderNode], chosen: &str) {
|
||||
for n in nodes {
|
||||
if lightcraft_catalog::query::folder_within(chosen, &n.path) && !same_folder(chosen, &n.path) {
|
||||
let key = lightcraft_catalog::query::folder_key(&n.path);
|
||||
ui.data_mut(|d| d.insert_temp(egui::Id::unique(("libfolder-open", key)), true));
|
||||
open_above(ui, &n.children, chosen);
|
||||
}
|
||||
}
|
||||
}
|
||||
open_above(ui, tree, &chosen);
|
||||
}
|
||||
|
||||
/// The Folders row that stands for folder `dir`, however it is spelled. `None` when no row is that
|
||||
/// folder.
|
||||
pub(crate) fn folder_row_of<'a>(tree: &'a [FolderNode], dir: &str) -> Option<&'a FolderNode> {
|
||||
// only the rows that hold `dir` are opened (the startup disk's holds the other disks' paths
|
||||
// too, so more than one may), and the tree is at most `MAX_DEPTH` deep
|
||||
tree.iter()
|
||||
.filter(|n| lightcraft_catalog::query::folder_within(dir, &n.path))
|
||||
.find_map(|n| if same_folder(&n.path, dir) { Some(n) } else { folder_row_of(&n.children, dir) })
|
||||
}
|
||||
|
||||
pub(super) fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode], indent: f32) {
|
||||
for n in nodes {
|
||||
let key = lightcraft_catalog::query::folder_key(&n.path);
|
||||
let open_id = egui::Id::unique(("libfolder-open", key.clone()));
|
||||
// a disk starts open, and so does a folder that holds nothing itself and leads to one
|
||||
// folder (`Users` → `me`): the first row where the library branches is what you look for
|
||||
let leads_on = n.own == 0 && n.children.len() == 1;
|
||||
let mut open = tree_open(ui, open_id, n.volume || leads_on);
|
||||
// a row whose path would cover other disks' photos too only opens and closes
|
||||
let name = if n.selectable { n.name.clone() } else { crate::i18n::tr("This Computer").to_string() };
|
||||
let id = format!("libfolder:{}", n.path);
|
||||
if !out_of_sight(app, ui, &id, &name, Some(n.count), indent, n.label.is_some()) {
|
||||
folder_row(app, ui, n, &id, &name, indent, open_id, &mut open);
|
||||
}
|
||||
if open && !n.children.is_empty() {
|
||||
folder_rows(app, ui, &n.children, indent + INDENT_STEP);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One drawn row of [`folder_rows`], shown as `name`; `open_id` and `open` are its triangle's.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn folder_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, id: &str, name: &str, indent: f32, open_id: egui::Id, open: &mut bool) {
|
||||
let sel = n.selectable
|
||||
&& app.session.source == LibrarySource::LibraryFolder
|
||||
&& app.session.library_folder.as_deref().is_some_and(|f| same_folder(f, &n.path));
|
||||
// the label is said with the name, as it is shown beside it
|
||||
let spoken = match n.label {
|
||||
Some(l) => format!("{name}, {}", crate::i18n::color_label(&app.session.catalog, l)),
|
||||
None => name.to_string(),
|
||||
};
|
||||
let mark = n.label.map(crate::theme::label_color);
|
||||
let resp = row_sensed(app, ui, id, Icon::Folder, name, Some(&spoken), Some(n.count), sel, indent, Sense::click(), mark);
|
||||
folder_drop_target(app, ui, &resp, n, open_id, open);
|
||||
let mut toggled = false;
|
||||
if !n.children.is_empty() {
|
||||
let tr = tree_toggle(ui, &resp, indent, open_id, open, format!("libraryFolderToggle:{}", n.path));
|
||||
toggled = tr.clicked();
|
||||
// the triangle sits on the row and takes its clicks: the menu opens from it too
|
||||
row_menu(app, &tr, n);
|
||||
}
|
||||
let tip = if n.children.is_empty() { n.path.clone() } else { format!("{} ({})", n.path, crate::i18n::tr("includes the folders inside it")) };
|
||||
let resp = resp.on_hover_text(tip);
|
||||
if resp.clicked() && !toggled {
|
||||
if n.selectable {
|
||||
// a source like an album or a Local folder: it replaces what the grid showed
|
||||
let _ = app.run("library.source", json!({"kind": "libraryFolder", "path": n.path}));
|
||||
} else {
|
||||
*open = !*open;
|
||||
set_tree_open(ui, open_id, *open);
|
||||
}
|
||||
}
|
||||
row_menu(app, &resp, n);
|
||||
}
|
||||
|
||||
/// How long photos dragged over a closed Folders row wait before it opens, so a folder inside it
|
||||
/// can be reached.
|
||||
const SPRING_OPEN_SECONDS: f64 = 0.7;
|
||||
|
||||
/// A Folders row while photos are dragged from the grid: a release moves their files there on
|
||||
/// disk (`photo.moveToFolder`, one undo step). The row lights up and the dragged label says where
|
||||
/// they will go, as the same gesture onto an album only adds them to it. Held over a closed row,
|
||||
/// it opens. Photos that can't go there leave it dark (a row covering several disks; Local photos,
|
||||
/// not in the library) and a release says why; photos already there say so.
|
||||
fn folder_drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, n: &FolderNode, open_id: egui::Id, open: &mut bool) {
|
||||
let Some(ids) = app.ui.dragging_photos.clone() else { return };
|
||||
// only where the row can be seen: not scrolled out of the sidebar or beneath the pinned header
|
||||
if !pointer_over_row(ui, resp.rect) {
|
||||
return;
|
||||
}
|
||||
spring_open(ui, n, open_id, open);
|
||||
let name = std::path::Path::new(&n.path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| n.name.clone());
|
||||
let photos: Vec<_> = ids.iter().filter_map(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(*id))).collect();
|
||||
let refused = if !n.selectable {
|
||||
Some(crate::i18n::tr("This row covers more than one disk: drop on a folder inside it"))
|
||||
} else if photos.iter().any(|p| p.local) {
|
||||
Some(crate::i18n::tr("Local photos aren't in the library yet: Add to My Photos first, then move them"))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let here = |p: &lightcraft_catalog::Photo| match &p.source {
|
||||
lightcraft_catalog::Source::File { path } => std::path::Path::new(path).parent().is_some_and(|d| same_folder(&d.to_string_lossy(), &n.path)),
|
||||
_ => false,
|
||||
};
|
||||
let all_here = !photos.is_empty() && photos.iter().all(|p| here(p));
|
||||
let count = photos.len();
|
||||
let released = ui.input(|i| i.pointer.any_released());
|
||||
let hint = match (&refused, all_here) {
|
||||
(Some(_), _) => crate::i18n::tr("Can't be moved here").to_string(),
|
||||
(None, true) => crate::i18n::tr_format!("Already in “{}”", name),
|
||||
(None, false) => crate::i18n::tr_format!("Move {n} photo{} to “{}”", if count == 1 { "" } else { "s" }, name, n = count),
|
||||
};
|
||||
crate::panels::grid::drag_hint(ui.ctx(), hint);
|
||||
if let Some(why) = refused {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed);
|
||||
if released {
|
||||
app.toast(ui.ctx(), why);
|
||||
app.ui.dragging_photos = None;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if all_here {
|
||||
if released {
|
||||
app.ui.dragging_photos = None;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.painter().rect_stroke(resp.rect.shrink2(vec2(8.0, 1.0)), 4.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
|
||||
if released {
|
||||
app.ui.dragging_photos = None;
|
||||
// a drop moves files on disk: asked first (as Lightroom Classic does), unless the person
|
||||
// said not to; the menu's Move Selected Photos Here is asked for by name and goes at once
|
||||
if app.ui.settings.ask_before_move {
|
||||
let library = app.session.library_generation();
|
||||
app.ui.dialog = Some(crate::state::Dialog::MovePhotos { ids, folder: n.path.clone(), name, dont_ask: false, library });
|
||||
} else {
|
||||
move_photos_here(app, ui.ctx(), Some(&ids), &n.path, &name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Photos dragged over the closed row `n` for [`SPRING_OPEN_SECONDS`] open it (a row with folders
|
||||
/// inside it). Restarts on each row the pointer comes to, and when it comes back to one after
|
||||
/// leaving it (or after another drag).
|
||||
fn spring_open(ui: &egui::Ui, n: &FolderNode, open_id: egui::Id, open: &mut bool) {
|
||||
if *open || n.children.is_empty() {
|
||||
return;
|
||||
}
|
||||
let key = egui::Id::unique("folder-spring-open");
|
||||
let (now, frame) = (ui.input(|i| i.time), ui.ctx().cumulative_frame_nr());
|
||||
// held without a break: over this row last frame too
|
||||
let since = match ui.data(|d| d.get_temp::<(String, f64, u64)>(key)) {
|
||||
Some((path, since, seen)) if path == n.path && seen.saturating_add(1) >= frame => since,
|
||||
_ => now,
|
||||
};
|
||||
ui.data_mut(|d| d.insert_temp(key, (n.path.clone(), since, frame)));
|
||||
if now - since >= SPRING_OPEN_SECONDS {
|
||||
*open = true;
|
||||
set_tree_open(ui, open_id, true);
|
||||
ui.data_mut(|d| d.remove::<(String, f64, u64)>(key));
|
||||
} else {
|
||||
// keep the clock running while the pointer rests
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
/// Move photos (`ids`, else the selection) into the folder `path` on disk (`photo.moveToFolder`).
|
||||
/// Photos that leave the view leave the selection, so nothing done next (Delete, an edit) reaches
|
||||
/// photos no longer on screen. How many moved, or why none did.
|
||||
pub(crate) fn move_photos(app: &mut LightcraftApp, ids: Option<&[u64]>, path: &str) -> Result<u64, String> {
|
||||
let mut params = json!({"folder": path});
|
||||
if let Some(ids) = ids {
|
||||
params["ids"] = json!(ids);
|
||||
}
|
||||
let r = app.run("photo.moveToFolder", params)?;
|
||||
let visible: std::collections::HashSet<lightcraft_catalog::PhotoId> = app.session.visible_cloned().into_iter().collect();
|
||||
let sel = &mut app.session.selection;
|
||||
sel.ids.retain(|id| visible.contains(id));
|
||||
if sel.active.is_some_and(|a| !visible.contains(&a)) {
|
||||
sel.active = sel.ids.first().copied();
|
||||
}
|
||||
Ok(r["moved"].as_u64().unwrap_or(0))
|
||||
}
|
||||
|
||||
/// What a move of `n` photos into the folder `name` says: how many moved and that Undo moves them
|
||||
/// back, or that they were there already.
|
||||
pub(crate) fn moved_text(n: u64, name: &str) -> String {
|
||||
if n == 0 {
|
||||
crate::i18n::tr_format!("Already in “{}”", name)
|
||||
} else {
|
||||
crate::i18n::tr_format!("Moved {n} photo{} to “{}”. Edit → Undo moves them back.", if n == 1 { "" } else { "s" }, name, n = n)
|
||||
}
|
||||
}
|
||||
|
||||
/// [`move_photos`], saying how it went in a toast.
|
||||
fn move_photos_here(app: &mut LightcraftApp, ctx: &egui::Context, ids: Option<&[u64]>, path: &str, name: &str) {
|
||||
match move_photos(app, ids, path) {
|
||||
Ok(n) => app.toast(ctx, moved_text(n, name)),
|
||||
Err(e) => app.toast(ctx, e),
|
||||
}
|
||||
}
|
||||
|
||||
/// The context menu of a Folders row: a folder's own, a disk's (remove it), none for the
|
||||
/// startup disk.
|
||||
fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) {
|
||||
if !n.volume {
|
||||
folder_menu_for_library(app, resp, n);
|
||||
} else if n.path != "/" {
|
||||
crate::widgets::context_menu(resp, |ui| {
|
||||
folder_label_menu(app, ui, n);
|
||||
locate_missing_item(app, ui, n);
|
||||
synchronize_item(app, ui, n, &n.name);
|
||||
ui.separator();
|
||||
if ui
|
||||
.button(crate::i18n::tr("Remove Disk from Library…"))
|
||||
.on_hover_text(crate::i18n::tr("Moves every photo imported from this disk to Recently Deleted; no file is touched"))
|
||||
.clicked()
|
||||
{
|
||||
app.ui.dialog = Some(crate::state::Dialog::RemoveFolder { path: n.path.clone(), name: n.name.clone(), count: n.count, disk: true });
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Locate Missing Files… of a Folders row: choose where this folder moved, then let the existing
|
||||
/// background finder relink only this folder's photos (including the folders inside it).
|
||||
fn locate_missing_item(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode) {
|
||||
let r = ui.button(crate::i18n::tr("Locate Missing Files…"));
|
||||
register(ui, "folderLocateMissing", r.rect);
|
||||
if r.clicked() {
|
||||
if let Err(e) = app.run("file.findMissing", json!({"within": n.path})) {
|
||||
app.toast(ui.ctx(), e);
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// The node Synchronize Folder… of a Local folder `path` works on, with the name its dialog shows:
|
||||
/// the Folders row for that very folder, else (a folder above rows) the folder itself, as a folder
|
||||
/// holding other folders. `None` when no library photo lies in `path` or inside it. Reads the
|
||||
/// cached Folders tree only; the disk is not touched.
|
||||
fn library_node_for(tree: &[FolderNode], path: &str) -> Option<(FolderNode, String)> {
|
||||
use lightcraft_catalog::query::folder_within;
|
||||
fn exact<'a>(tree: &'a [FolderNode], path: &str) -> Option<&'a FolderNode> {
|
||||
tree.iter().find_map(|n| if same_folder(&n.path, path) { Some(n) } else { exact(&n.children, path) })
|
||||
}
|
||||
fn any_inside(tree: &[FolderNode], path: &str) -> bool {
|
||||
tree.iter().any(|n| (n.count > 0 && folder_within(&n.path, path)) || any_inside(&n.children, path))
|
||||
}
|
||||
if let Some(n) = exact(tree, path).filter(|n| n.count > 0) {
|
||||
return Some((n.clone(), lightcraft_catalog::folders::folder_label(&n.path)));
|
||||
}
|
||||
if !any_inside(tree, path) {
|
||||
return None;
|
||||
}
|
||||
let name = std::path::Path::new(path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
let node = FolderNode {
|
||||
name: name.clone(),
|
||||
path: path.to_string(),
|
||||
count: 0,
|
||||
own: 0,
|
||||
volume: false,
|
||||
selectable: false,
|
||||
label: None,
|
||||
children: Vec::new(),
|
||||
};
|
||||
Some((node, name))
|
||||
}
|
||||
|
||||
/// Synchronize Folder… of a Folders row (widget `folderSynchronize`): opens its dialog, which
|
||||
/// scans the folder in the background.
|
||||
fn synchronize_item(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, name: &str) {
|
||||
let r = ui
|
||||
.button(crate::i18n::tr("Synchronize Folder…"))
|
||||
.on_hover_text(crate::i18n::tr("Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps"));
|
||||
crate::widgets::register(ui, "folderSynchronize", r.rect);
|
||||
if r.clicked() {
|
||||
// a disk, or a folder holding disks, was chosen as such
|
||||
if let Err(e) = crate::sync::open(app, &n.path, name, n.volume || !n.selectable) {
|
||||
app.toast(ui.ctx(), e);
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set Color Label ▸ of a Folders row (`folder.label`; the submenu is widget `folderLabelMenu`,
|
||||
/// its colours `folderLabel:<colour>`).
|
||||
fn folder_label_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode) {
|
||||
let menu = crate::widgets::menu_button(ui, crate::i18n::tr("Set Color Label"), |ui| {
|
||||
if let Some(l) = crate::panels::grid::label_items(app, ui, n.label, "folderLabel") {
|
||||
if let Err(e) = app.run("folder.label", json!({"path": n.path, "label": crate::panels::grid::label_param(l)})) {
|
||||
app.toast(ui.ctx(), e);
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
register(ui, "folderLabelMenu", menu.response.rect);
|
||||
}
|
||||
|
||||
/// The context menu of a folder row: the folder's disk actions (the same as Local's, photos
|
||||
/// follow) and taking its photos out of the library.
|
||||
fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) {
|
||||
let path = n.path.as_str();
|
||||
let name = std::path::Path::new(path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
// what dialogs call it: the last two names, so same-named folders are told apart
|
||||
let label = lightcraft_catalog::folders::folder_label(path);
|
||||
crate::widgets::context_menu(resp, |ui| {
|
||||
if ui.button(crate::i18n::tr("Rename Folder…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::TextPrompt {
|
||||
title: crate::i18n::tr_format!("Rename “{name}”", name = label),
|
||||
hint: "Folder name (renamed on disk; its photos follow)".into(),
|
||||
value: name.clone(),
|
||||
command: "folder.rename".into(),
|
||||
params: json!({"path": path}),
|
||||
key: "name".into(),
|
||||
});
|
||||
ui.close();
|
||||
}
|
||||
if app.services.pick_folder.is_some() && ui.button(crate::i18n::tr("Move Folder To…")).clicked() {
|
||||
let into = app.services.pick_folder.as_mut().and_then(|f| f());
|
||||
if let Some(into) = into {
|
||||
match app.run("folder.move", json!({"path": path, "into": into})) {
|
||||
Ok(r) => app.toast(ui.ctx(), format!("Moved; {} photo(s) relinked", r["relinked"])),
|
||||
Err(e) => app.toast(ui.ctx(), e),
|
||||
}
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(crate::i18n::tr("New Folder on Disk…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::TextPrompt {
|
||||
title: crate::i18n::tr_format!("New Folder in “{name}”", name = label),
|
||||
hint: "Folder name (created on disk)".into(),
|
||||
value: String::new(),
|
||||
command: "folder.create".into(),
|
||||
params: json!({"parent": path}),
|
||||
key: "name".into(),
|
||||
});
|
||||
ui.close();
|
||||
}
|
||||
let selected = app.session.selection.ids.len();
|
||||
if selected > 0
|
||||
&& ui
|
||||
.button(crate::i18n::tr("Move Selected Photos Here"))
|
||||
.on_hover_text(crate::i18n::tr("Moves the files and their XMP sidecars into this folder; never overwrites"))
|
||||
.clicked()
|
||||
{
|
||||
move_photos_here(app, ui.ctx(), None, path, &name);
|
||||
ui.close();
|
||||
}
|
||||
if app.services.reveal.is_some()
|
||||
&& ui.button(crate::i18n::tr(crate::menus::reveal_label())).clicked()
|
||||
&& let Some(f) = app.services.reveal.as_mut()
|
||||
{
|
||||
let _ = f(path);
|
||||
ui.close();
|
||||
}
|
||||
folder_label_menu(app, ui, n);
|
||||
locate_missing_item(app, ui, n);
|
||||
synchronize_item(app, ui, n, &label);
|
||||
ui.separator();
|
||||
if ui
|
||||
.button(crate::i18n::tr("Remove from Library…"))
|
||||
.on_hover_text(crate::i18n::tr("Moves the photos imported from this folder to Recently Deleted; no file is touched"))
|
||||
.clicked()
|
||||
{
|
||||
app.ui.dialog = Some(crate::state::Dialog::RemoveFolder { path: path.to_string(), name: label.clone(), count: n.count, disk: false });
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use super::dates::browse_all_photos;
|
||||
use super::rows::{INDENT_STEP, out_of_sight, row, tree_open, tree_toggle};
|
||||
use super::*;
|
||||
|
||||
/// "Keywords": the library's keyword tree with photo counts (`a|b|c` keywords nest). A click
|
||||
/// filters the grid by the keyword (children included), the triangle opens a level, and the
|
||||
/// context menu renames, merges or deletes the keyword across the library.
|
||||
pub(super) fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[KeywordNode], indent: f32) {
|
||||
for n in nodes {
|
||||
// leaves (most keywords) have no open state to look up
|
||||
let open_id = (!n.children.is_empty()).then(|| egui::Id::unique(("kw-open", n.path.to_lowercase())));
|
||||
let mut open = open_id.is_some_and(|k| tree_open(ui, k, false));
|
||||
let id = format!("keyword:{}", n.path);
|
||||
if !out_of_sight(app, ui, &id, &n.name, Some(n.count), indent, false) {
|
||||
keyword_row(app, ui, n, &id, indent, open_id, &mut open);
|
||||
}
|
||||
if open {
|
||||
keyword_rows(app, ui, &n.children, indent + INDENT_STEP);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One drawn row of [`keyword_rows`]; `open_id` and `open` are its triangle's when it has rows inside it.
|
||||
fn keyword_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &KeywordNode, id: &str, indent: f32, open_id: Option<egui::Id>, open: &mut bool) {
|
||||
let sel = app.session.filter.keyword.as_deref().is_some_and(|k| k.eq_ignore_ascii_case(&n.path));
|
||||
let resp = row(app, ui, id, Icon::Tag, &n.name, Some(n.count), sel, indent);
|
||||
if let Some(k) = open_id {
|
||||
tree_toggle(ui, &resp, indent, k, open, format!("keywordToggle:{}", n.path));
|
||||
}
|
||||
let resp = resp.on_hover_text(if n.children.is_empty() { n.path.clone() } else { format!("{} (includes the keywords below it)", n.path) });
|
||||
if resp.clicked() {
|
||||
let v = if sel { serde_json::Value::Null } else { json!(n.path) };
|
||||
browse_all_photos(app, !sel);
|
||||
let _ = app.run("library.filter", json!({"keyword": v}));
|
||||
}
|
||||
crate::widgets::context_menu(&resp, |ui| {
|
||||
let has_sel = app.session.active().is_some();
|
||||
if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Add to Selected Photos"))).clicked() {
|
||||
let _ = app.run("photo.setMeta", json!({"addKeywords": [n.path]}));
|
||||
}
|
||||
if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Remove from Selected Photos"))).clicked() {
|
||||
let _ = app.run("photo.setMeta", json!({"removeKeywords": [n.path]}));
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button(crate::i18n::tr("Rename Keyword…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: n.path.clone(), to: n.path.clone() });
|
||||
}
|
||||
if ui.button(crate::i18n::tr("Merge into…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::MergeKeywords { from: vec![n.path.clone()], into: String::new() });
|
||||
}
|
||||
if ui.button(crate::i18n::tr("Delete Keyword")).clicked() {
|
||||
let _ = app.run("keyword.delete", json!({"keyword": n.path}));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
use super::folders::folder_tree;
|
||||
use super::rows::row;
|
||||
use super::*;
|
||||
|
||||
/// Local's folders as they stand this frame, and the folder being browsed among them.
|
||||
pub(super) struct LocalNow {
|
||||
pub(super) places: LocalPlaces,
|
||||
current: Option<String>,
|
||||
/// The places removed from Local, as (name, path), in the order they were hidden.
|
||||
hidden: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Folders on this computer to browse without adding (Lightroom's Local): Pictures, Desktop,
|
||||
/// Downloads, the home folder, the folder being browsed, and the folders kept with Browse
|
||||
/// Folder…. Worked out every frame the sidebar shows, drawn or not: the row listed for this
|
||||
/// session only (`local_browse_root`) follows the browsing. `None` where there is no Local (web).
|
||||
pub(super) fn local_now(app: &mut LightcraftApp, ui: &egui::Ui) -> Option<LocalNow> {
|
||||
if !SidebarSection::Local.available() {
|
||||
return None;
|
||||
}
|
||||
let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_default();
|
||||
let mut builtin: Vec<(String, String)> = Vec::new();
|
||||
if !home.is_empty() {
|
||||
for (name, sub) in [("Pictures", "Pictures"), ("Desktop", "Desktop"), ("Downloads", "Downloads"), ("Home", "")] {
|
||||
// joined with the platform's separator, like the paths browsing gives back
|
||||
let p = if sub.is_empty() { home.clone() } else { std::path::Path::new(&home).join(sub).to_string_lossy().to_string() };
|
||||
// (checked off the UI thread: a home folder can be on a network share)
|
||||
if fs_cached(ui, "is-dir", &p, 5.0, |p| std::path::Path::new(p).is_dir()) == Some(true) {
|
||||
builtin.push((crate::i18n::tr(name).to_string(), p));
|
||||
}
|
||||
}
|
||||
}
|
||||
let browsing = app.session.browse.clone().filter(|_| app.session.source == LibrarySource::Folder);
|
||||
let current = browsing.as_ref().map(|b| b.path.clone());
|
||||
// a hidden built-in place keeps its name (Home, not the user's folder name)
|
||||
let name_of = |path: &str| builtin.iter().find(|(_, p)| same_folder(p, path)).map_or_else(|| folder_label(path), |(n, _)| n.clone());
|
||||
let hidden = app.ui.hidden_locations.iter().map(|h| (name_of(h), h.clone())).collect();
|
||||
let places = local_places(builtin, &app.ui.local_roots, current.as_deref(), app.ui.local_browse_root.as_deref(), &app.ui.hidden_locations);
|
||||
if current.is_some() {
|
||||
app.ui.local_browse_root = places.browse_root.clone();
|
||||
}
|
||||
Some(LocalNow { places, current, hidden })
|
||||
}
|
||||
|
||||
/// Local's rows: each place as a folder tree, Browse Folder…, and the way back for hidden places.
|
||||
pub(super) fn local_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, now: &LocalNow) {
|
||||
let (local, current) = (&now.places, now.current.as_deref());
|
||||
for (i, (name, path)) in local.places.iter().enumerate() {
|
||||
let transient = local.browse_root.as_deref() == Some(path.as_str());
|
||||
let reveal = local.owner == Some(i);
|
||||
folder_tree(app, ui, path, name, path, 0.0, current, reveal, transient);
|
||||
}
|
||||
if app.services.pick_folder.is_some() && row(app, ui, "local:browse", Icon::Plus, crate::i18n::tr("Browse Folder…"), None, false, 0.0).clicked()
|
||||
{
|
||||
let picked = app.services.pick_folder.as_mut().and_then(|f| f());
|
||||
if let Some(path) = picked {
|
||||
match app.run("library.browse", json!({"path": path})) {
|
||||
// the picked folder stays in Local (and comes back if it was hidden)
|
||||
Ok(r) => {
|
||||
let dir = r["path"].as_str().unwrap_or(&path).to_string();
|
||||
let _ = app.run("local.addRoot", json!({"path": dir}));
|
||||
}
|
||||
Err(e) => app.toast(ui.ctx(), e),
|
||||
}
|
||||
}
|
||||
}
|
||||
// the way back for hidden places: one comes back from the row itself; several are listed, so
|
||||
// each comes back on its own, or all at once (issue #2114: the row used to bring every one back)
|
||||
let hidden = now.hidden.len();
|
||||
if hidden > 0 {
|
||||
let label = crate::i18n::tr_format!("Show {hidden} hidden location{}", if hidden == 1 { "" } else { "s" }, hidden = hidden);
|
||||
let resp = row(app, ui, "local:restoreHidden", Icon::Folder, &label, None, false, 0.0)
|
||||
.on_hover_text(crate::i18n::tr("Put the locations you removed from Local back (no files change)"));
|
||||
if let [(_, only)] = now.hidden.as_slice() {
|
||||
if resp.clicked() {
|
||||
let _ = app.run("local.restoreHidden", json!({"path": only}));
|
||||
}
|
||||
} else {
|
||||
egui::Popup::menu(&resp).show(|ui| hidden_menu(app, ui, &now.hidden));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The list behind "Show N hidden locations": each place by name (its path on hover), then
|
||||
/// Restore All.
|
||||
fn hidden_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, hidden: &[(String, String)]) {
|
||||
for (name, path) in hidden {
|
||||
let r = ui.button(name).on_hover_text(path);
|
||||
register(ui, format!("local:restoreHidden:{path}"), r.rect);
|
||||
if r.clicked() {
|
||||
let _ = app.run("local.restoreHidden", json!({"path": path}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
let r = ui.button(crate::i18n::tr("Restore All"));
|
||||
register(ui, "local:restoreHidden:all", r.rect);
|
||||
if r.clicked() {
|
||||
let _ = app.run("local.restoreHidden", json!({}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether two paths name the same folder, however they are spelled (separators, trailing
|
||||
/// slash, `.`/`..`, drive-letter case; see `lightcraft_catalog::query::folder_key`).
|
||||
pub(crate) fn same_folder(a: &str, b: &str) -> bool {
|
||||
a == b || lightcraft_catalog::query::folder_key(a) == lightcraft_catalog::query::folder_key(b)
|
||||
}
|
||||
|
||||
/// Local's top-level folders and how the folder being browsed sits among them.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub(crate) struct LocalPlaces {
|
||||
/// (label, path) of each top-level folder, in order.
|
||||
pub places: Vec<(String, String)>,
|
||||
/// The top-level folder the browsed folder lies in (the innermost one): its tree opens on
|
||||
/// the way down to it. None when that way passes through a hidden folder.
|
||||
pub owner: Option<usize>,
|
||||
/// A folder listed only for this session because the browsed folder is in no saved
|
||||
/// location (browsed from a breadcrumb, the CLI…); it stays while browsing below it.
|
||||
pub browse_root: Option<String>,
|
||||
}
|
||||
|
||||
fn folder_label(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
/// Local's top-level folders, in order: the built-in places, the folders kept with Browse
|
||||
/// Folder… / Keep in Local (`saved`), then — only when the browsed folder lies in none of those —
|
||||
/// a session root for it: the previous one (`browse_root`) while browsing stays below it, else
|
||||
/// the browsed folder itself. A folder inside a listed one is shown inside that one's tree (the
|
||||
/// root stays; its siblings stay reachable), never as a root of its own. Each folder is listed
|
||||
/// once however its path is spelled (the first spelling wins), and hidden ones are left out —
|
||||
/// they stay reachable through breadcrumbs and Browse Folder….
|
||||
pub(crate) fn local_places(
|
||||
builtin: Vec<(String, String)>,
|
||||
saved: &[String],
|
||||
browsing: Option<&str>,
|
||||
browse_root: Option<&str>,
|
||||
hidden: &[String],
|
||||
) -> LocalPlaces {
|
||||
use lightcraft_catalog::query::{folder_key, folder_within};
|
||||
let is_hidden = |p: &str| hidden.iter().any(|h| same_folder(h, p));
|
||||
let mut places = builtin;
|
||||
for path in saved {
|
||||
if !places.iter().any(|(_, p)| same_folder(p, path)) {
|
||||
places.push((folder_label(path), path.clone()));
|
||||
}
|
||||
}
|
||||
places.retain(|(_, p)| !is_hidden(p));
|
||||
let mut out = LocalPlaces::default();
|
||||
if let Some(c) = browsing
|
||||
&& !places.iter().any(|(_, p)| folder_within(c, p))
|
||||
{
|
||||
let root = browse_root.filter(|r| folder_within(c, r)).unwrap_or(c);
|
||||
if !is_hidden(root) {
|
||||
places.push((folder_label(root), root.to_string()));
|
||||
out.browse_root = Some(root.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(c) = browsing {
|
||||
// A tree never opens on the way down through a hidden folder: hiding a kept folder
|
||||
// beneath Home would otherwise reveal it again inside Home's (possibly huge) tree.
|
||||
let through_hidden = |p: &str| hidden.iter().any(|h| folder_within(c, h) && folder_within(h, p));
|
||||
out.owner = places
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, (_, p))| folder_within(c, p) && !through_hidden(p))
|
||||
.max_by_key(|(_, (_, p))| folder_key(p).len())
|
||||
.map(|(i, _)| i);
|
||||
}
|
||||
out.places = places;
|
||||
out
|
||||
}
|
||||
|
||||
/// The subfolders of `path` (not hidden ones), sorted; listed on a worker thread at most every
|
||||
/// 2 s per folder (empty until the first listing).
|
||||
pub(super) fn subfolders(ui: &egui::Ui, path: &str) -> Vec<(String, String)> {
|
||||
fs_cached(ui, "subfolders", path, 2.0, list_subfolders).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn list_subfolders(path: &str) -> Vec<(String, String)> {
|
||||
let mut v: Vec<(String, String)> = std::fs::read_dir(path)
|
||||
.map(|rd| {
|
||||
rd.flatten()
|
||||
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
|
||||
.filter_map(|e| {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
(!name.starts_with('.')).then(|| (name, e.path().to_string_lossy().to_string()))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
v.sort_by_key(|(n, _)| n.to_lowercase());
|
||||
v
|
||||
}
|
||||
|
||||
/// How many [`fs_cached`] answers for `ctx` are being worked out right now. Rows appear (and
|
||||
/// the sidebar below them moves) when they land, so the headless driver counts them as pending
|
||||
/// work and waits for them before acting on widget positions.
|
||||
pub(crate) fn fs_cached_running(ctx: &egui::Context) -> usize {
|
||||
fs_running_counter(ctx).load(std::sync::atomic::Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn fs_running_counter(ctx: &egui::Context) -> std::sync::Arc<std::sync::atomic::AtomicUsize> {
|
||||
ctx.data_mut(|d| d.get_temp_mut_or_default::<std::sync::Arc<std::sync::atomic::AtomicUsize>>(egui::Id::unique("fs-cached-running")).clone())
|
||||
}
|
||||
|
||||
/// A file-system answer for `path` (`f(path)`), kept per `kind` and path and refreshed on a worker
|
||||
/// thread at most every `every` seconds: a folder on a sleeping NAS, a dropped share or a
|
||||
/// spinning-up drive never blocks a frame. `None` until the first answer arrives.
|
||||
pub(crate) fn fs_cached<T: Clone + Send + 'static>(ui: &egui::Ui, kind: &'static str, path: &str, every: f64, f: fn(&str) -> T) -> Option<T> {
|
||||
struct Entry<T> {
|
||||
value: Option<T>,
|
||||
at: Option<f64>,
|
||||
running: bool,
|
||||
}
|
||||
type Cell<T> = std::sync::Arc<std::sync::Mutex<Entry<T>>>;
|
||||
let id = egui::Id::unique(("fs-cached", kind, path.to_string()));
|
||||
let now = ui.input(|i| i.time);
|
||||
let cell: Cell<T> = match ui.data(|d| d.get_temp::<Cell<T>>(id)) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
let c: Cell<T> = std::sync::Arc::new(std::sync::Mutex::new(Entry { value: None, at: None, running: false }));
|
||||
ui.data_mut(|d| d.insert_temp(id, c.clone()));
|
||||
c
|
||||
}
|
||||
};
|
||||
let lock = |c: &Cell<T>| c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).value.clone();
|
||||
let start = {
|
||||
let mut e = cell.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let due = !e.running && e.at.is_none_or(|at| now - at >= every || now < at);
|
||||
if due {
|
||||
e.running = true;
|
||||
e.at = Some(now);
|
||||
}
|
||||
due
|
||||
};
|
||||
if start {
|
||||
use std::sync::atomic::Ordering;
|
||||
let running = fs_running_counter(ui.ctx());
|
||||
running.fetch_add(1, Ordering::AcqRel);
|
||||
let (out, path, repaint, done) = (cell.clone(), path.to_string(), ui.ctx().clone(), running.clone());
|
||||
let work = move || {
|
||||
let v = f(&path);
|
||||
let mut e = out.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
e.value = Some(v);
|
||||
e.running = false;
|
||||
drop(e);
|
||||
// after the answer is stored: a frame that sees the count drop also sees the answer
|
||||
done.fetch_sub(1, Ordering::AcqRel);
|
||||
repaint.request_repaint();
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if std::thread::Builder::new().name("lc-fs-list".into()).spawn(work).is_err() {
|
||||
cell.lock().unwrap_or_else(std::sync::PoisonError::into_inner).running = false;
|
||||
running.fetch_sub(1, Ordering::AcqRel);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
work();
|
||||
}
|
||||
lock(&cell)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::local_places;
|
||||
use crate::widgets::elide_head;
|
||||
|
||||
/// Width = characters, so a limit of 12 is "12 characters".
|
||||
fn fit(text: &str, max: usize) -> String {
|
||||
elide_head(text, max as f32, |s| s.chars().count() as f32)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_label_that_fits_is_left_alone() {
|
||||
assert_eq!(fit("/Users/me/Pictures", 18), "/Users/me/Pictures");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_path_loses_its_leading_folders_not_its_end() {
|
||||
assert_eq!(fit("/Users/me/Pictures/Lightroom", 20), "…/Pictures/Lightroom");
|
||||
assert_eq!(fit("/Users/me/Pictures/Lightroom", 12), "…/Lightroom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_name_too_long_is_cut_at_the_end() {
|
||||
assert_eq!(fit("2024-summer-holiday", 8), "2024-su…");
|
||||
assert_eq!(fit("2024-summer-holiday", 1), "…");
|
||||
assert_eq!(fit("2024-summer-holiday", 0), "…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multibyte_names_are_cut_on_character_boundaries() {
|
||||
assert_eq!(fit("/写真/夏休み旅行の記録", 6), "夏休み旅行…");
|
||||
}
|
||||
|
||||
fn names(v: &[(String, String)]) -> Vec<&str> {
|
||||
v.iter().map(|(n, _)| n.as_str()).collect()
|
||||
}
|
||||
|
||||
/// A built-in location browsed (or picked) under another spelling of its path is the same
|
||||
/// row, not a second root — for every built-in, not just named ones.
|
||||
#[test]
|
||||
fn equivalent_paths_are_one_local_location() {
|
||||
for (home, sep) in [("D:\\Example", '\\'), ("D:/Example", '/'), ("/home/example", '/')] {
|
||||
let builtin: Vec<(String, String)> =
|
||||
["Pictures", "Desktop", "Downloads"].iter().map(|s| (s.to_string(), format!("{home}{sep}{s}"))).collect();
|
||||
for (_, p) in builtin.clone() {
|
||||
// the browsed spelling: other separators, a trailing one, a different drive-letter case
|
||||
let flipped = p.replace(['/', '\\'], if sep == '/' { "\\" } else { "/" });
|
||||
for browsing in [flipped.clone(), format!("{p}{sep}"), p.replacen("D:", "d:", 1)] {
|
||||
let l = local_places(builtin.clone(), std::slice::from_ref(&browsing), Some(&browsing), None, &[]);
|
||||
assert_eq!(names(&l.places), ["Pictures", "Desktop", "Downloads"], "{p} browsed as {browsing}");
|
||||
assert_eq!(l.places[l.owner.unwrap()].1, p, "the built-in row is the one highlighted");
|
||||
}
|
||||
// hiding under one spelling hides the other
|
||||
let l = local_places(builtin.clone(), &[], None, None, std::slice::from_ref(&flipped));
|
||||
assert_eq!(l.places.len(), 2, "hidden {flipped}");
|
||||
}
|
||||
}
|
||||
// a different folder is still added
|
||||
let l = local_places(vec![("Pictures".into(), "/home/example/Pictures".into())], &[], Some("/home/example/Pictures2"), None, &[]);
|
||||
assert_eq!(names(&l.places), ["Pictures", "Pictures2"]);
|
||||
}
|
||||
|
||||
/// A folder inside a kept root is shown inside that root's tree: the root stays (its other
|
||||
/// subfolders stay reachable) and no second root appears; other kept roots stay too.
|
||||
#[test]
|
||||
fn browsing_below_a_kept_root_keeps_the_root() {
|
||||
let photos = "/data/Photos".to_string();
|
||||
let other = "/data/Scans".to_string();
|
||||
let saved = [photos.clone(), other.clone()];
|
||||
let builtin = || vec![("Home".to_string(), "/home/example".to_string())];
|
||||
for browsing in ["/data/Photos/2026/20260101", "/data/Photos/2026", "/data/Photos", "D:\\x"] {
|
||||
let l = local_places(builtin(), &saved, Some(browsing), None, &[]);
|
||||
let roots: Vec<&str> = l.places.iter().map(|(_, p)| p.as_str()).collect();
|
||||
if browsing.starts_with("/data") {
|
||||
assert_eq!(roots, ["/home/example", "/data/Photos", "/data/Scans"], "{browsing}");
|
||||
assert_eq!(l.owner, Some(1), "the Photos tree opens down to {browsing}");
|
||||
assert_eq!(l.browse_root, None);
|
||||
} else {
|
||||
assert_eq!(roots, ["/home/example", "/data/Photos", "/data/Scans", "D:\\x"], "a folder outside them gets a row");
|
||||
}
|
||||
}
|
||||
// the innermost containing root is the one that opens
|
||||
let l = local_places(builtin(), &["/home/example/Pictures".into()], Some("/home/example/Pictures/Trip"), None, &[]);
|
||||
assert_eq!(l.owner, Some(1));
|
||||
}
|
||||
|
||||
/// A browsed folder outside every kept root gets a session row, which stays while browsing
|
||||
/// below it and gives way when browsing moves elsewhere.
|
||||
#[test]
|
||||
fn session_root_stays_while_browsing_below_it() {
|
||||
let l = local_places(Vec::new(), &[], Some("/t/base"), None, &[]);
|
||||
assert_eq!(l.browse_root.as_deref(), Some("/t/base"));
|
||||
let l = local_places(Vec::new(), &[], Some("/t/base/Trip/Day 1"), l.browse_root.as_deref(), &[]);
|
||||
assert_eq!((l.places.len(), l.browse_root.as_deref(), l.owner), (1, Some("/t/base"), Some(0)), "{l:?}");
|
||||
let l = local_places(Vec::new(), &[], Some("/elsewhere"), l.browse_root.as_deref(), &[]);
|
||||
assert_eq!(l.browse_root.as_deref(), Some("/elsewhere"));
|
||||
// hidden: no row at all
|
||||
let l = local_places(Vec::new(), &[], Some("/t/base"), None, &["/t/base/".into()]);
|
||||
assert!(l.places.is_empty() && l.browse_root.is_none());
|
||||
}
|
||||
|
||||
/// A hidden folder inside a listed one (a kept folder beneath Home) is not revealed in that
|
||||
/// one's tree while it is browsed, nor are the folders below it; hiding Home itself still
|
||||
/// lets Pictures open down to a folder browsed inside it.
|
||||
#[test]
|
||||
fn hidden_folder_is_not_revealed_in_an_outer_tree() {
|
||||
let builtin = || vec![("Pictures".to_string(), "/home/example/Pictures".to_string()), ("Home".to_string(), "/home/example".to_string())];
|
||||
let kept = ["/home/example/AppData/Temp/lc".to_string()];
|
||||
let l = local_places(builtin(), &kept, Some("/home/example/AppData/Temp/lc"), None, &[]);
|
||||
assert_eq!(l.owner, Some(2), "shown as its own kept row");
|
||||
for browsing in ["/home/example/AppData/Temp/lc", "/home/example/AppData/Temp/lc/Day 1"] {
|
||||
let l = local_places(builtin(), &kept, Some(browsing), None, &["/home/example/AppData/Temp/lc/".into()]);
|
||||
assert_eq!(names(&l.places), ["Pictures", "Home"], "{browsing}");
|
||||
assert_eq!((l.owner, l.browse_root.as_deref()), (None, None), "Home does not open down to {browsing}");
|
||||
}
|
||||
let l = local_places(builtin(), &[], Some("/home/example/Pictures/Trip"), None, &["/home/example".into()]);
|
||||
assert_eq!((names(&l.places), l.owner), (vec!["Pictures"], Some(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_folder_lines_up_with_the_rows_above_and_its_triangle_stays_left_of_the_icon() {
|
||||
use crate::Services;
|
||||
use crate::headless::HeadlessView;
|
||||
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::new(), Services::default());
|
||||
let mut view = HeadlessView::new();
|
||||
let path = "/lightcraft-test/Pictures";
|
||||
let mut widgets = vec![];
|
||||
for frame in 0..3 {
|
||||
let raw = HeadlessView::raw_input(egui::vec2(300.0, 200.0), 1.0, frame as f64 / 60.0, vec![]);
|
||||
view.run(raw, |ui| {
|
||||
super::row(&mut app, ui, "all", super::Icon::Photos, "All Photos", None, false, 0.0);
|
||||
super::folder_tree(&mut app, ui, path, "Pictures", path, 0.0, None, false, false);
|
||||
});
|
||||
widgets = crate::widgets::take_registry(&view.ctx);
|
||||
}
|
||||
let rect = |id: &str| widgets.iter().find(|(w, _)| w == id).unwrap_or_else(|| panic!("{id} not registered")).1;
|
||||
let (all, local) = (rect("label:all"), rect(&format!("label:local:{path}")));
|
||||
assert_eq!(local.left(), all.left(), "a top-level Local folder's name starts where All Photos' does");
|
||||
let triangle = rect(&format!("folderToggle:{path}"));
|
||||
// the icon is 16 points wide and ends 8 points before the name
|
||||
assert!(triangle.right() <= local.left() - 24.0, "triangle {triangle:?} overlaps the icon before {local:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
use super::albums::MARK_W;
|
||||
use super::*;
|
||||
|
||||
/// How wide the sidebar's content needs to be, from the widest row of the last frame: rows are
|
||||
/// drawn at this width (or the panel's, if wider), and the sidebar scrolls sideways when it
|
||||
/// exceeds the panel. One frame behind, which needs no second layout pass.
|
||||
pub(crate) fn content_width(ctx: &egui::Context) -> f32 {
|
||||
ctx.data(|d| d.get_temp::<f32>(egui::Id::unique("left-content-width"))).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// The most a row asks for its name: longer ones are cut when there is no more room.
|
||||
const MAX_NAME_NEED: f32 = 140.0;
|
||||
|
||||
/// Height of a sidebar row.
|
||||
pub(super) const ROW_HEIGHT: f32 = 29.0;
|
||||
|
||||
/// Temp-data key of the row (its widget id after `source:`, lower-case) the sidebar scrolls to
|
||||
/// this frame: set before the sections are drawn, cleared after.
|
||||
pub(super) const SCROLL_TO_ROW: &str = "left-scroll-to-row";
|
||||
|
||||
/// Temp-data key of the Folders choice whose rows above were last opened ([`reveal_chosen`]):
|
||||
/// removed, they open again.
|
||||
pub(super) const LIBFOLDER_REVEALED: &str = "libfolder-revealed";
|
||||
|
||||
/// A row says how wide it needs to be.
|
||||
pub(super) fn note_width(ui: &egui::Ui, w: f32) {
|
||||
ui.data_mut(|d| {
|
||||
let m = d.get_temp_mut_or_insert_with::<f32>(egui::Id::unique("left-content-width-next"), || 0.0);
|
||||
*m = m.max(w);
|
||||
});
|
||||
}
|
||||
|
||||
/// One level of a sidebar tree when the panel has room: callers pass `indent` in these steps.
|
||||
pub(crate) const INDENT_STEP: f32 = 16.0;
|
||||
|
||||
/// The narrowest a level is drawn: the chevron still steps visibly right of its parent's.
|
||||
pub(crate) const MIN_INDENT_STEP: f32 = 8.0;
|
||||
|
||||
/// What a row keeps for its name when the step is chosen: the deepest row's name is not cut to
|
||||
/// less than this while a narrower step would still keep it.
|
||||
const MIN_NAME_ROOM: f32 = 80.0;
|
||||
|
||||
/// Room kept for a count at the end of the deepest row (three digits and their gap).
|
||||
const COUNT_ROOM: f32 = 40.0;
|
||||
|
||||
/// The step a level is drawn with this frame: [`INDENT_STEP`] while the deepest open row fits the
|
||||
/// panel, narrower when it would not, never below [`MIN_INDENT_STEP`]. From the last frame.
|
||||
pub(crate) fn indent_step(ctx: &egui::Context) -> f32 {
|
||||
ctx.data(|d| d.get_temp::<f32>(egui::Id::unique("left-indent-step"))).unwrap_or(INDENT_STEP)
|
||||
}
|
||||
|
||||
/// How far right a row at `indent` (in [`INDENT_STEP`]s) is drawn this frame.
|
||||
pub(super) fn indent_x(ctx: &egui::Context, indent: f32) -> f32 {
|
||||
indent / INDENT_STEP * indent_step(ctx)
|
||||
}
|
||||
|
||||
/// [`indent_x`] for a row being laid out, which also notes how deep the tree goes, for next
|
||||
/// frame's step.
|
||||
fn drawn_indent(ui: &egui::Ui, indent: f32) -> f32 {
|
||||
ui.data_mut(|d| {
|
||||
let m = d.get_temp_mut_or_insert_with::<f32>(egui::Id::unique("left-deepest-indent-next"), || 0.0);
|
||||
*m = m.max(indent);
|
||||
});
|
||||
indent_x(ui.ctx(), indent)
|
||||
}
|
||||
|
||||
/// The widest step (16, 14, 12, 10 or 8 pt) at which a row `deepest` deep (in [`INDENT_STEP`]s)
|
||||
/// keeps [`MIN_NAME_ROOM`] for its name and its count in a panel `panel_w` wide. In steps, so a
|
||||
/// row does not slide sideways at every point the panel is dragged; when even the narrowest step
|
||||
/// does not fit, the sidebar scrolls sideways as before.
|
||||
pub(crate) fn step_for(panel_w: f32, deepest: f32) -> f32 {
|
||||
let levels = deepest / INDENT_STEP;
|
||||
if levels < 1.0 {
|
||||
return INDENT_STEP;
|
||||
}
|
||||
let room = panel_w - 42.0 - MIN_NAME_ROOM - 18.0 - COUNT_ROOM;
|
||||
[16.0, 14.0, 12.0, 10.0].into_iter().find(|step| levels * step <= room).unwrap_or(MIN_INDENT_STEP)
|
||||
}
|
||||
|
||||
pub(super) fn row(
|
||||
app: &mut LightcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
id: &str,
|
||||
icon: Icon,
|
||||
label: &str,
|
||||
count: Option<usize>,
|
||||
selected: bool,
|
||||
indent: f32,
|
||||
) -> egui::Response {
|
||||
row_named(app, ui, id, icon, label, None, count, selected, indent)
|
||||
}
|
||||
|
||||
/// [`row`] whose spoken name is `spoken` when the painted `label` is a shortened form of it.
|
||||
fn row_named(
|
||||
app: &mut LightcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
id: &str,
|
||||
icon: Icon,
|
||||
label: &str,
|
||||
spoken: Option<&str>,
|
||||
count: Option<usize>,
|
||||
selected: bool,
|
||||
indent: f32,
|
||||
) -> egui::Response {
|
||||
row_sensed(app, ui, id, icon, label, spoken, count, selected, indent, Sense::click(), None)
|
||||
}
|
||||
|
||||
/// [`row_named`] with the given `sense` (rows that can be dragged add `drag`) and a colour
|
||||
/// label's dot (`mark`) before the count.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn row_sensed(
|
||||
app: &mut LightcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
id: &str,
|
||||
icon: Icon,
|
||||
label: &str,
|
||||
spoken: Option<&str>,
|
||||
count: Option<usize>,
|
||||
selected: bool,
|
||||
indent: f32,
|
||||
sense: Sense,
|
||||
mark: Option<egui::Color32>,
|
||||
) -> egui::Response {
|
||||
let label = if matches!(id, "all" | "recentlyAdded" | "picks" | "missing" | "recentlyDeleted") { crate::i18n::tr(label) } else { label };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let indent = drawn_indent(ui, indent);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), sense);
|
||||
if scroll_asked(ui, id) {
|
||||
ui.scroll_to_rect_animation(r, None, egui::style::ScrollAnimation::none());
|
||||
}
|
||||
register(ui, format!("source:{id}"), r);
|
||||
let mark_w = if mark.is_some() { MARK_W } else { 0.0 };
|
||||
let said = spoken.unwrap_or(label);
|
||||
let name = match count {
|
||||
Some(n) => crate::i18n::tr_format!("{label}, {n} photos", label = said, n = n),
|
||||
None => said.to_string(),
|
||||
};
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, &name));
|
||||
// what is visible of the row: when the sidebar is scrolled sideways its bars and counts end at
|
||||
// the panel's edge, not at the end of the (wider) content
|
||||
let visible_right: f32 = ui.data(|d| d.get_temp(egui::Id::unique("left-visible-right"))).unwrap_or(f32::MAX);
|
||||
let edge = r.right().min(visible_right);
|
||||
let inner = Rect::from_min_max(r.min + vec2(8.0, 0.0), pos2((r.right() - 8.0).min(edge - 8.0).max(r.left() + 8.0), r.bottom()));
|
||||
if selected {
|
||||
ui.painter().rect_filled(inner, 4.0, t.canvas);
|
||||
register(ui, format!("highlight:{id}"), inner);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(inner, 4.0, t.hover.gamma_multiply(0.6));
|
||||
}
|
||||
paint(
|
||||
ui.painter(),
|
||||
Rect::from_min_size(pos2(r.left() + 18.0 + indent, r.center().y - 8.0), vec2(16.0, 16.0)),
|
||||
icon,
|
||||
if selected { t.text } else { t.icon },
|
||||
);
|
||||
let font = t.font(13.5);
|
||||
let color = if selected { t.text } else { t.text_label };
|
||||
// a count of nothing is there, but fainter: the rows that hold photos stand out
|
||||
let count_color = |n: usize| if n == 0 { t.text_dim.gamma_multiply(0.55) } else { t.text_dim };
|
||||
let count_galley = count.filter(|_| app.ui.show_counts).map(|n| ui.painter().layout_no_wrap(n.to_string(), t.font(12.5), count_color(n)));
|
||||
let label_left = r.left() + 42.0 + indent;
|
||||
let count_left = count_galley.as_ref().map_or(edge - 18.0, |g| edge - 18.0 - g.size().x);
|
||||
// a label's dot sits just before the count
|
||||
if let Some(c) = mark {
|
||||
let dot = Rect::from_center_size(pos2(count_left - MARK_W / 2.0, r.center().y), vec2(8.0, 8.0));
|
||||
ui.painter().circle_filled(dot.center(), 4.0, c);
|
||||
register(ui, format!("labelMark:{id}"), dot);
|
||||
}
|
||||
// the name gives way to the count: cut with an ellipsis, in full on hover
|
||||
let room = count_left - mark_w - 8.0 - label_left;
|
||||
let measure = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), color).size().x;
|
||||
let full_w = measure(label);
|
||||
let shown = if full_w <= room { label.to_string() } else { crate::widgets::elide_head(label, room.max(0.0), measure) };
|
||||
let label_rect = ui.painter().text(pos2(label_left, r.center().y), Align2::LEFT_CENTER, &shown, font.clone(), color);
|
||||
register(ui, format!("label:{id}"), label_rect);
|
||||
let resp = if shown != label { resp.on_hover_text(label) } else { resp };
|
||||
note_width(ui, row_need(indent, full_w, mark_w, count_galley.as_ref().map(|g| g.size().x)));
|
||||
if let Some(galley) = count_galley {
|
||||
let rect = Rect::from_min_size(pos2(edge - 18.0 - galley.size().x, r.center().y - galley.size().y / 2.0), galley.size());
|
||||
register(ui, format!("count:{id}"), rect);
|
||||
ui.painter().galley(rect.min, galley, t.text_dim);
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
/// Whether the row `id` about to be drawn is out of sight: then it only takes its room, is listed
|
||||
/// for the control channel and asks for its width (so the content does not widen and narrow as
|
||||
/// rows scroll by), and the caller skips it. A library can have thousands of keywords or folders,
|
||||
/// and drawing them all every frame slows the whole app down (issue #1011). A row the sidebar is
|
||||
/// asked to scroll to is drawn. `label`, `count` and `mark` as for [`row_sensed`].
|
||||
pub(super) fn out_of_sight(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, label: &str, count: Option<usize>, indent: f32, mark: bool) -> bool {
|
||||
let r = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), ROW_HEIGHT));
|
||||
if ui.is_rect_visible(r) || scroll_asked(ui, id) {
|
||||
return false;
|
||||
}
|
||||
ui.allocate_space(r.size());
|
||||
register(ui, format!("source:{id}"), r);
|
||||
let indent = drawn_indent(ui, indent);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let painter = ui.painter();
|
||||
let name_w = painter.layout_no_wrap(label.to_string(), t.font(13.5), t.text_label).size().x;
|
||||
let count_w = count.filter(|_| app.ui.show_counts).map(|n| painter.layout_no_wrap(n.to_string(), t.font(12.5), t.text_dim).size().x);
|
||||
note_width(ui, row_need(indent, name_w, if mark { MARK_W } else { 0.0 }, count_w));
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether the sidebar is asked to scroll to the row `id` this frame ([`SCROLL_TO_ROW`]).
|
||||
fn scroll_asked(ui: &egui::Ui, id: &str) -> bool {
|
||||
ui.data(|d| d.get_temp::<String>(egui::Id::unique(SCROLL_TO_ROW))).is_some_and(|want| want == id.to_lowercase())
|
||||
}
|
||||
|
||||
/// How wide a row needs to be, from its indent, name, colour dot and count widths. It asks for
|
||||
/// room for its name up to a share of a panel, so one very long name does not make everything
|
||||
/// scroll: depth does that.
|
||||
fn row_need(indent: f32, name_w: f32, mark_w: f32, count_w: Option<f32>) -> f32 {
|
||||
42.0 + indent + name_w.min(MAX_NAME_NEED) + 18.0 + mark_w + count_w.map_or(0.0, |w| w + 16.0)
|
||||
}
|
||||
|
||||
/// The disclosure chevron at the left of a tree row (`indent` is the row's): right-pointing
|
||||
/// when closed, down when open, registered as widget `widget` for the control channel. Returns
|
||||
/// its response: the caller flips the open state on `clicked()`.
|
||||
fn disclosure_chevron(ui: &mut egui::Ui, row: &egui::Response, indent: f32, open: bool, id: egui::Id, widget: String) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let c = pos2(row.rect.left() + 10.0 + indent_x(ui.ctx(), indent), row.rect.center().y);
|
||||
let tri = Rect::from_center_size(c, vec2(16.0, 16.0));
|
||||
let tr = ui.interact(tri, id, Sense::click());
|
||||
register(ui, widget, tri);
|
||||
let col = if tr.hovered() { t.text } else { t.text_dim };
|
||||
paint(ui.painter(), Rect::from_center_size(c, vec2(16.0, 16.0)), if open { Icon::ChevronDown } else { Icon::ChevronRight }, col);
|
||||
tr
|
||||
}
|
||||
|
||||
/// Whether the tree row `key` is open: what the user last chose this session, else `default`.
|
||||
pub(super) fn tree_open(ui: &egui::Ui, key: egui::Id, default: bool) -> bool {
|
||||
ui.data(|d| d.get_temp(key)).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Open or close the tree row `key` from now on.
|
||||
pub(super) fn set_tree_open(ui: &egui::Ui, key: egui::Id, open: bool) {
|
||||
ui.data_mut(|d| d.insert_temp(key, open));
|
||||
}
|
||||
|
||||
/// The chevron of a tree row that has rows inside it (Albums' folders, Local, By Date, Folders,
|
||||
/// Keywords): a click flips `open` and remembers it under `key`. Returns the chevron's response
|
||||
/// (it sits on the row and takes its clicks, so a row's context menu is attached to it too).
|
||||
pub(super) fn tree_toggle(ui: &mut egui::Ui, row: &egui::Response, indent: f32, key: egui::Id, open: &mut bool, widget: String) -> egui::Response {
|
||||
let tri = disclosure_chevron(ui, row, indent, *open, key.with("triangle"), widget);
|
||||
if tri.clicked() {
|
||||
*open = !*open;
|
||||
set_tree_open(ui, key, *open);
|
||||
}
|
||||
let label = if *open { crate::i18n::tr("Collapse") } else { crate::i18n::tr("Expand") };
|
||||
tri.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label));
|
||||
tri
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
use super::albums::top_level_drop_target;
|
||||
use super::rows::{ROW_HEIGHT, note_width};
|
||||
use super::*;
|
||||
|
||||
/// Space above a section's header.
|
||||
pub(super) const SECTION_GAP: f32 = 10.0;
|
||||
|
||||
/// Height of a section's header.
|
||||
const SECTION_HEADER: f32 = 34.0;
|
||||
|
||||
/// One sidebar section: the gap above it, its header, and (while it is open) the rows `body`
|
||||
/// draws. Every section goes through here, so they all fold, space and announce themselves alike.
|
||||
/// `rows`: how many rows the section lists at its top level (a folded header says so).
|
||||
pub(super) fn section(
|
||||
app: &mut LightcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
section: SidebarSection,
|
||||
rows: usize,
|
||||
body: impl FnOnce(&mut LightcraftApp, &mut egui::Ui),
|
||||
) -> PlacedSection {
|
||||
ui.add_space(SECTION_GAP);
|
||||
let (header, _) = ui.allocate_exact_size(vec2(ui.available_width(), SECTION_HEADER), Sense::hover());
|
||||
// a hairline in the gap above tells the sections apart
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let edge = visible_right(ui).min(header.right());
|
||||
let y = header.top() - SECTION_GAP / 2.0;
|
||||
ui.painter().hline(header.left() + 18.0..=(edge - 18.0).max(header.left() + 18.0), y, egui::Stroke::new(1.0, t.divider));
|
||||
let show = egui::Id::unique("left-show-section");
|
||||
if let Some((asked, frame)) = ui.data(|d| d.get_temp::<(SidebarSection, u64)>(show))
|
||||
&& asked == section
|
||||
{
|
||||
ui.data_mut(|d| d.remove::<(SidebarSection, u64)>(show));
|
||||
// asked for by the frame before (a request the section was not drawn for is forgotten),
|
||||
// and up or down only: the sidebar stays where it was scrolled sideways
|
||||
if ui.ctx().cumulative_frame_nr().saturating_sub(frame) <= 2 {
|
||||
let view_top: f32 = ui.data(|d| d.get_temp(egui::Id::unique("left-view-top"))).unwrap_or(header.top());
|
||||
let delta = vec2(0.0, view_top - header.top());
|
||||
ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none());
|
||||
}
|
||||
}
|
||||
let open = section_header(app, ui, section, header, rows, false).open;
|
||||
if open {
|
||||
let rows_from = ui.cursor().top();
|
||||
body(app, ui);
|
||||
if ui.cursor().top() <= rows_from {
|
||||
empty_hint(ui, section);
|
||||
}
|
||||
}
|
||||
PlacedSection { section, top: header.top(), bottom: ui.cursor().top(), open, rows }
|
||||
}
|
||||
|
||||
/// Where what the panel shows of the sidebar's content ends (screen x): the content is wider
|
||||
/// than the panel while the sidebar scrolls sideways.
|
||||
fn visible_right(ui: &egui::Ui) -> f32 {
|
||||
ui.data(|d| d.get_temp(egui::Id::unique("left-visible-right"))).unwrap_or(f32::MAX)
|
||||
}
|
||||
|
||||
/// The header of the open section whose own header has scrolled out of sight stays at the top of
|
||||
/// the sidebar (widget `sidebarSectionPinned:<id>`), so it is always clear which section the rows
|
||||
/// belong to, and the section can be folded, moved or hidden from anywhere in a long list. The
|
||||
/// next section's header pushes it out as it arrives. `view_top`: where the visible part starts.
|
||||
pub(super) fn pinned_header(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSection], view_top: f32) {
|
||||
let pinned = placed.iter().find(|p| p.open && p.top < view_top && p.bottom > view_top);
|
||||
let rect = pinned.map(|p| {
|
||||
let top = view_top.min(p.bottom - SECTION_HEADER);
|
||||
Rect::from_x_y_ranges(ui.max_rect().x_range(), top..=top + SECTION_HEADER)
|
||||
});
|
||||
// rows ask where it is (next frame: they are laid out before it)
|
||||
ui.data_mut(|d| d.insert_temp(egui::Id::unique("left-pinned-rect"), rect));
|
||||
let (Some(p), Some(r)) = (pinned, rect) else { return };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let view = ui.clip_rect();
|
||||
// over the rows scrolling beneath it
|
||||
ui.painter().rect_filled(Rect::from_x_y_ranges(view.x_range(), r.y_range()), 0.0, t.chrome);
|
||||
ui.painter().hline(view.x_range(), r.bottom(), egui::Stroke::new(1.0, t.divider));
|
||||
if section_header(app, ui, p.section, r, p.rows, true).toggled {
|
||||
// folded from here: its own header comes into view, not whatever was below the section
|
||||
// (next frame, when the section is laid out folded: see `section`)
|
||||
let frame = ui.ctx().cumulative_frame_nr();
|
||||
ui.data_mut(|d| d.insert_temp(egui::Id::unique("left-show-section"), (p.section, frame)));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// What an open section with nothing to list says in place of its rows (widget
|
||||
/// `sidebarEmpty:<id>`), so the section stays where it is instead of disappearing.
|
||||
fn empty_hint(ui: &mut egui::Ui, section: SidebarSection) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), Sense::hover());
|
||||
register(ui, format!("sidebarEmpty:{}", section.id()), r);
|
||||
let text = crate::i18n::tr(section.empty_hint());
|
||||
let galley = ui.painter().layout_no_wrap(text.to_string(), t.font(12.5), t.text_dim);
|
||||
note_width(ui, 42.0 + galley.size().x + 18.0);
|
||||
ui.painter().galley(pos2(r.left() + 42.0, r.center().y - galley.size().y / 2.0), galley, t.text_dim);
|
||||
}
|
||||
|
||||
/// Where a section was laid out this frame (screen y): from its header's top to the end of its rows.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct PlacedSection {
|
||||
pub section: SidebarSection,
|
||||
pub top: f32,
|
||||
pub bottom: f32,
|
||||
/// Not folded: its rows (or its note that there are none) are drawn.
|
||||
pub open: bool,
|
||||
/// How many rows it lists at its top level.
|
||||
pub rows: usize,
|
||||
}
|
||||
|
||||
/// What a section's header says happened to it this frame.
|
||||
struct HeaderOutcome {
|
||||
open: bool,
|
||||
/// A click folded or unfolded the section.
|
||||
toggled: bool,
|
||||
}
|
||||
|
||||
/// Where a section dragged to `y` would go among the sections drawn: above the first one whose
|
||||
/// middle is below `y` (`Some`), or after them all (`None`).
|
||||
pub(crate) fn section_drop_before(placed: &[PlacedSection], y: f32) -> Option<SidebarSection> {
|
||||
placed.iter().find(|p| y < (p.top + p.bottom) / 2.0).map(|p| p.section)
|
||||
}
|
||||
|
||||
/// While a section is dragged by its header: a line shows where it would go, releasing over the
|
||||
/// sidebar moves it there (`view.sidebarMoveSection`), releasing elsewhere or Esc leaves it.
|
||||
pub(super) fn section_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSection]) {
|
||||
let Some(dragged) = app.ui.dragging_section else { return };
|
||||
let (released, pos, esc) = ui.input(|i| (i.pointer.primary_released(), i.pointer.latest_pos(), i.key_pressed(egui::Key::Escape)));
|
||||
if esc {
|
||||
// the drag's Esc, not also a dialog's or a view's
|
||||
crate::widgets::take_escape(ui.ctx());
|
||||
app.ui.dragging_section = None;
|
||||
return;
|
||||
}
|
||||
let view = ui.clip_rect();
|
||||
let Some(pos) = pos.filter(|p| view.contains(*p)) else {
|
||||
if released {
|
||||
app.ui.dragging_section = None;
|
||||
}
|
||||
return;
|
||||
};
|
||||
let before = section_drop_before(placed, pos.y);
|
||||
if released {
|
||||
app.ui.dragging_section = None;
|
||||
let before = before.map_or(serde_json::Value::Null, |b| json!(b.id()));
|
||||
if let Err(e) = app.run("view.sidebarMoveSection", json!({"section": dragged.id(), "before": before})) {
|
||||
app.toast(ui.ctx(), e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// the line: in the gap above the section it would go before, or below the last one
|
||||
let y = match before {
|
||||
Some(b) => placed.iter().find(|p| p.section == b).map(|p| p.top - SECTION_GAP / 2.0),
|
||||
None => placed.last().map(|p| p.bottom + SECTION_GAP / 2.0),
|
||||
};
|
||||
if let Some(y) = y {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let line = Rect::from_min_max(pos2(view.left() + 8.0, y - 1.0), pos2(visible_right(ui).min(view.right()) - 8.0, y + 1.0));
|
||||
ui.painter().rect_filled(line, 1.0, t.accent);
|
||||
register(ui, "sidebarSectionDrop", line);
|
||||
}
|
||||
}
|
||||
|
||||
/// The dragged section's name follows the pointer; a drag that ended anywhere (also outside the
|
||||
/// sidebar, or with the sidebar closed) is over.
|
||||
pub fn section_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(section) = app.ui.dragging_section else { return };
|
||||
let (down, pos) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos()));
|
||||
if !down || !app.ui.left_panel {
|
||||
app.ui.dragging_section = None;
|
||||
return;
|
||||
}
|
||||
let Some(pos) = pos else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
egui::Area::new(egui::Id::unique("drag-section")).order(egui::Order::Tooltip).interactable(false).fixed_pos(pos + vec2(14.0, 10.0)).show(
|
||||
ctx,
|
||||
|ui| {
|
||||
egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| {
|
||||
ui.label(egui::RichText::new(crate::i18n::tr(section.title())).color(egui::Color32::WHITE).font(t.semibold(12.0)));
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// A section's header in `r`: a disclosure chevron in the gutter (where tree rows have their
|
||||
/// chevron), then the bold title; a click folds or unfolds the section (kept in the UI state, so
|
||||
/// it survives restarts), and what the section adds to its header (its button, see `header_button`) sits at
|
||||
/// the visible edge. A folded section says how many rows it holds (`rows`, with View ▸ Show Photo
|
||||
/// Counts). `pinned`: this is the copy kept at the top of the sidebar (see `pinned_header`).
|
||||
fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSection, r: Rect, rows: usize, pinned: bool) -> HeaderOutcome {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let id = section.id();
|
||||
let title = crate::i18n::tr(section.title());
|
||||
let (interact, widget) = if pinned { ("sidebar-section-pinned", "sidebarSectionPinned") } else { ("sidebar-section", "sidebarSection") };
|
||||
let resp = ui.interact(r, ui.scope_id().with((interact, id)), Sense::click_and_drag());
|
||||
register(ui, format!("{widget}:{id}"), r);
|
||||
let toggled = resp.clicked();
|
||||
if toggled {
|
||||
app.ui.sidebar.toggle_collapsed(section);
|
||||
}
|
||||
// dragged by its header, a section moves among the others (see `section_drag`); a press on
|
||||
// the header's button is the button's, also when it turns into a drag
|
||||
let on_button = header_button(section).is_some() && ui.input(|i| i.pointer.press_origin()).is_some_and(|p| header_button_rect(ui, r).contains(p));
|
||||
if resp.drag_started_by(egui::PointerButton::Primary) && !on_button {
|
||||
// picked up by the pinned header, the drag starts inside the top edge (see `drag_auto_scroll`)
|
||||
ui.data_mut(|d| d.insert_temp(egui::Id::unique("left-section-drag-from-pinned"), pinned));
|
||||
app.ui.dragging_section = Some(section);
|
||||
}
|
||||
crate::widgets::context_menu(&resp, |ui| sections_menu(app, ui, Some(section)));
|
||||
let open = !app.ui.sidebar.is_collapsed(section);
|
||||
crate::widgets::announce_disclosure(&resp, true, open, title);
|
||||
// what is visible of the header: its bar and count end at the panel's edge, like a row's
|
||||
let edge = r.right().min(visible_right(ui));
|
||||
let hovered = resp.hovered() && app.ui.dragging_section.is_none();
|
||||
if hovered {
|
||||
let bar = Rect::from_min_max(r.min + vec2(8.0, 2.0), pos2((edge - 8.0).max(r.left() + 8.0), r.bottom() - 2.0));
|
||||
ui.painter().rect_filled(bar, 4.0, t.hover.gamma_multiply(0.6));
|
||||
}
|
||||
let c = pos2(r.left() + 10.0, r.center().y);
|
||||
register(ui, format!("{widget}Chevron:{id}"), Rect::from_center_size(c, vec2(16.0, 16.0)));
|
||||
let col = if hovered { t.text } else { t.text_dim };
|
||||
paint(ui.painter(), Rect::from_center_size(c, vec2(16.0, 16.0)), if open { Icon::ChevronDown } else { Icon::ChevronRight }, col);
|
||||
ui.painter().text(pos2(r.left() + 18.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.5), t.text_label);
|
||||
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 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, format!("{widget}Count:{id}"), count);
|
||||
}
|
||||
if section == SidebarSection::Albums {
|
||||
top_level_drop_target(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,
|
||||
}
|
||||
}
|
||||
|
||||
/// The end of `header` kept for its button: at the visible edge, also when the sidebar is
|
||||
/// scrolled sideways.
|
||||
fn header_button_rect(ui: &egui::Ui, header: Rect) -> Rect {
|
||||
let right = visible_right(ui).min(header.right());
|
||||
Rect::from_min_max(pos2(right - 50.0, header.top()), pos2(right, header.bottom()))
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
let mut hdr =
|
||||
ui.new_child(egui::UiBuilder::new().max_rect(header_button_rect(ui, header)).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).
|
||||
/// Made from here, the keyword is shown here: Keywords opens down to it (`reveal_keyword`).
|
||||
fn new_keyword(app: &mut LightcraftApp, _ctx: &egui::Context) {
|
||||
let mut dialog = crate::panels::keyword_list::create_dialog_inside(app, None);
|
||||
if let crate::state::Dialog::KeywordTag { reveal, .. } = &mut dialog {
|
||||
*reveal = true;
|
||||
}
|
||||
app.ui.dialog = Some(dialog);
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
pub(super) fn sections_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, on: Option<SidebarSection>) {
|
||||
let mut run = None;
|
||||
if let Some(section) = on {
|
||||
let hide = ui.button(crate::i18n::tr("Hide This Section"));
|
||||
register(ui, "sidebarMenu:hide", hide.rect);
|
||||
if hide.clicked() {
|
||||
run = Some(("view.sidebarSection", json!({"section": section.id(), "show": false})));
|
||||
}
|
||||
for (up, label, widget) in [(true, "Move Up", "sidebarMenu:moveUp"), (false, "Move Down", "sidebarMenu:moveDown")] {
|
||||
let item = ui.add_enabled(app.ui.sidebar.can_move(section, up), egui::Button::new(crate::i18n::tr(label)));
|
||||
register(ui, widget, item.rect);
|
||||
if item.clicked() {
|
||||
run = Some(("view.sidebarMoveSection", json!({"section": section.id(), "direction": if up { "up" } else { "down" }})));
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
}
|
||||
for s in app.ui.sidebar.sections().iter().filter(|s| s.id.available()) {
|
||||
let shown = ui.selectable_label(!s.hidden, crate::i18n::tr(s.id.title()));
|
||||
register(ui, format!("sidebarMenu:section:{}", s.id.id()), shown.rect);
|
||||
if shown.clicked() {
|
||||
run = Some(("view.sidebarSection", json!({"section": s.id.id(), "show": s.hidden})));
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
let reset = ui.add_enabled(!app.ui.sidebar.is_default(), egui::Button::new(crate::i18n::tr("Reset Sidebar Sections")));
|
||||
register(ui, "sidebarMenu:reset", reset.rect);
|
||||
if reset.clicked() {
|
||||
run = Some(("view.sidebarReset", json!({})));
|
||||
}
|
||||
if let Some((command, params)) = run {
|
||||
if let Err(e) = app.run(command, params) {
|
||||
app.toast(ui.ctx(), e);
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, include_selected: true });
|
||||
}
|
||||
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, include_selected: false });
|
||||
}
|
||||
// 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!({}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How many library photos have no file (Local browse records are not checked; see
|
||||
/// `cmd::missing::checked_path`). Checking stats every file, which on a network share takes
|
||||
/// seconds, so it runs on a worker thread: the count shown is the last finished one, refreshed
|
||||
/// at most every 5 s, and at once (after the running check) when the catalog changed.
|
||||
pub(super) fn missing_count(app: &mut LightcraftApp, ui: &mut egui::Ui) -> usize {
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering::Relaxed};
|
||||
#[derive(Clone, Default)]
|
||||
struct Job {
|
||||
n: std::sync::Arc<AtomicUsize>,
|
||||
running: std::sync::Arc<AtomicBool>,
|
||||
at: f64,
|
||||
rev: u64,
|
||||
}
|
||||
let id = egui::Id::unique("missing-count");
|
||||
let now = ui.input(|i| i.time);
|
||||
let rev = app.session.catalog.revision;
|
||||
let mut job: Job = ui.data(|d| d.get_temp(id)).unwrap_or(Job { rev: u64::MAX, at: f64::MIN, ..Default::default() });
|
||||
let fresh = now - job.at < 5.0 && job.rev == rev;
|
||||
if !fresh && !job.running.load(Relaxed) {
|
||||
// the same scope as the Missing Photos view: library photos only, never Local browse records
|
||||
let paths = lightcraft_engine::cmd::missing::candidates(&app.session.catalog);
|
||||
job.running.store(true, Relaxed);
|
||||
job.at = now;
|
||||
job.rev = rev;
|
||||
let (n, running, ctx) = (job.n.clone(), job.running.clone(), ui.ctx().clone());
|
||||
let work = move || {
|
||||
let missing = if cfg!(target_arch = "wasm32") { 0 } else { paths.iter().filter(|p| !std::path::Path::new(p).exists()).count() };
|
||||
n.store(missing, Relaxed);
|
||||
running.store(false, Relaxed);
|
||||
ctx.request_repaint();
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::thread::spawn(work);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
work();
|
||||
ui.data_mut(|d| d.insert_temp(id, job.clone()));
|
||||
}
|
||||
job.n.load(Relaxed)
|
||||
}
|
||||
Reference in New Issue
Block a user