feat(ui): page icons, a searchable pages list, sliding layer buttons
Pages can carry an icon, the pages popover filters by name, a layer row's eye and lock slide in only while shown, and agent outlines fade sooner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -98,6 +98,7 @@ impl Editor {
|
||||
self.meta_dirty = false;
|
||||
// The groups of the previous project are gone; the session sets the new ones.
|
||||
self.page_groups.clear();
|
||||
self.page_icons.clear();
|
||||
}
|
||||
|
||||
pub fn active_page_id(&self) -> &str {
|
||||
@@ -323,6 +324,7 @@ impl Editor {
|
||||
{
|
||||
members.retain(|id| id != &removed.id);
|
||||
}
|
||||
self.page_icons.remove(&removed.id);
|
||||
if self.active_page > i {
|
||||
self.active_page -= 1;
|
||||
}
|
||||
@@ -656,6 +658,20 @@ impl Editor {
|
||||
/// Page groups (folders). Groups are single level: a page belongs to at most one group,
|
||||
/// and a group keeps its pages in the order of the page list.
|
||||
impl Editor {
|
||||
/// A page's icon (`set:name`), or none to show the default page icon.
|
||||
pub fn set_page_icon(&mut self, page_id: &str, icon: Option<&str>) -> bool {
|
||||
if !self.pages.iter().any(|p| p.id == page_id) || self.page_icons.get(page_id).map(String::as_str) == icon {
|
||||
return false;
|
||||
}
|
||||
match icon {
|
||||
Some(i) => self.page_icons.insert(page_id.to_string(), i.to_string()),
|
||||
None => self.page_icons.remove(page_id),
|
||||
};
|
||||
self.meta_dirty = true;
|
||||
self.revision += 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// Create an empty group. Fails on an empty or already used name.
|
||||
pub fn create_page_group(&mut self, name: &str) -> bool {
|
||||
let name = name.trim();
|
||||
|
||||
@@ -87,6 +87,9 @@ pub struct Manifest {
|
||||
/// Old manifests have no groups and load with none.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub page_groups: BTreeMap<String, Vec<String>>,
|
||||
/// Page id -> icon (`set:name` from the installed icon sets) shown in the pages list.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub page_icons: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
@@ -106,6 +109,7 @@ impl Manifest {
|
||||
active_page: Some(page.id.clone()),
|
||||
pages: vec![page],
|
||||
page_groups: BTreeMap::new(),
|
||||
page_icons: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ impl Session {
|
||||
}
|
||||
// The groups of the opened project (an old project has none).
|
||||
ed.page_groups = p.manifest.page_groups.clone();
|
||||
ed.page_icons = p.manifest.page_icons.clone();
|
||||
ed.sync_page_group_order();
|
||||
// Freshly loaded pages are saved by definition.
|
||||
for i in 0..ed.pages.len() {
|
||||
@@ -264,6 +265,7 @@ impl Session {
|
||||
let wrote = !dirty.is_empty() || ed.meta_dirty;
|
||||
p.manifest.pages = pages;
|
||||
p.manifest.page_groups = ed.page_groups.clone();
|
||||
p.manifest.page_icons = ed.page_icons.clone();
|
||||
p.manifest.active_page = Some(ed.active_page_id().to_string());
|
||||
if wrote {
|
||||
p.manifest.modified = now_ms();
|
||||
|
||||
+36
-20
@@ -314,7 +314,7 @@ pub struct AgentMark {
|
||||
}
|
||||
|
||||
/// How long an agent's outline stays on the canvas, fading out.
|
||||
const AGENT_MARK_SECS: f32 = 3.0;
|
||||
const AGENT_MARK_SECS: f32 = 1.5;
|
||||
/// Outlines kept from one call (its topmost changed layers) and in all.
|
||||
const AGENT_MARKS_PER_CALL: usize = 12;
|
||||
const AGENT_MARKS_MAX: usize = 48;
|
||||
@@ -1400,6 +1400,7 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
for (icon, label, action) in [
|
||||
(Icon::Image, "Export PNG…", Action::ExportPng),
|
||||
(Icon::Pen, "Export SVG…", Action::ExportSvg),
|
||||
(Icon::Page, "Export presentation…", Action::ExportPresentation),
|
||||
(Icon::Image, "Copy as PNG", Action::CopyAsPng),
|
||||
(Icon::Pen, "Copy as SVG", Action::CopyAsSvg),
|
||||
] {
|
||||
@@ -2100,6 +2101,10 @@ fn layer_matches(doc: &crate::model::Document, id: NodeId, needle: &str) -> bool
|
||||
})
|
||||
}
|
||||
|
||||
/// Room a layer row's eye or lock button takes while shown, and how far it slides in from.
|
||||
const TRAIL_W: f32 = 20.0;
|
||||
const TRAIL_SLIDE: f32 = 12.0;
|
||||
|
||||
fn layer_row(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, id: NodeId, depth: usize) {
|
||||
let Some(n) = ed.doc.get(id).cloned() else {
|
||||
return;
|
||||
@@ -2170,24 +2175,30 @@ fn layer_row(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, id: NodeId, depth:
|
||||
}
|
||||
x += 20.0;
|
||||
|
||||
// Trailing eye / lock buttons: always shown when hidden or locked, otherwise on hover.
|
||||
// Trailing eye / lock buttons: always shown when hidden or locked, otherwise on hover. A
|
||||
// button takes its room only while shown, sliding in from the row's edge, so names use the
|
||||
// whole row the rest of the time.
|
||||
let mut right = row.right() - 4.0;
|
||||
let mut trailing =
|
||||
|ui: &mut Ui, paint: &dyn Fn(&Ui, egui::Rect), tip: &str, show: bool| -> bool {
|
||||
let r =
|
||||
egui::Rect::from_center_size(egui::pos2(right - 9.0, cy), egui::vec2(18.0, 18.0));
|
||||
right -= 20.0;
|
||||
if !show {
|
||||
let shown = motion::progress(ui.ctx(), ("trail-shown", tip, id), show, motion::FAST, motion::ease_out_cubic);
|
||||
if shown <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
let slide = (1.0 - shown) * TRAIL_SLIDE;
|
||||
let r = egui::Rect::from_center_size(egui::pos2(right - 9.0 + slide, cy), egui::vec2(18.0, 18.0));
|
||||
right -= TRAIL_W * shown;
|
||||
let b = ui.interact(r, ui.id().with((tip, id)), egui::Sense::click());
|
||||
let t = motion::hover(ui.ctx(), ui.id().with(("trail-hover", tip, id)), &b);
|
||||
if t > 0.0 {
|
||||
let tint = p.hover;
|
||||
ui.painter().rect_filled(r, crate::theme::r(5.0), tint.gamma_multiply(t));
|
||||
ui.painter().rect_filled(r, crate::theme::r(5.0), tint.gamma_multiply(t * shown));
|
||||
}
|
||||
paint(ui, r.shrink(3.0));
|
||||
b.on_hover_text(tip).clicked()
|
||||
ui.scope(|ui| {
|
||||
ui.set_opacity(shown);
|
||||
paint(ui, r.shrink(3.0));
|
||||
});
|
||||
show && b.on_hover_text(tip).clicked()
|
||||
};
|
||||
let eye = if n.visible { Icon::Eye } else { Icon::EyeSlash };
|
||||
if trailing(
|
||||
@@ -2546,8 +2557,6 @@ fn screen_scroll_toggles(ctx: &egui::Context, ed: &mut Editor, canvas: egui::Rec
|
||||
}
|
||||
|
||||
const AGENT_OUTLINE: f32 = 2.0;
|
||||
/// How long fresh agent content takes to come in through its skeleton.
|
||||
const REVEAL_SECS: f32 = 0.35;
|
||||
/// The skeleton over the area a call being written will change.
|
||||
const DRAFT_SKELETON_ALPHA: f32 = 0.55;
|
||||
const DRAFT_OUTLINE_ALPHA: f32 = 0.8;
|
||||
@@ -2594,12 +2603,24 @@ fn agent_overlay(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState, canvas:
|
||||
// The call being written: a skeleton where it lands (its target, or the free spot a
|
||||
// new artboard takes), until it has run.
|
||||
if let Some(d) = &draft {
|
||||
let at = match d.target.as_deref().and_then(|k| k.trim_start_matches('#').split_once(':')) {
|
||||
Some((prefix, n)) => crate::mcp::exec::find_page(ed, prefix).ok().filter(|p| *p == ed.active_page).and(n.parse().ok()).and_then(|n| ed.doc.world_rect(NodeId(n))),
|
||||
// An existing target gets the dashed outline only: a skeleton would hide content
|
||||
// that is still there (or, when the agent's events arrive late, already changed).
|
||||
// A target an agent changed a moment ago is done, not being changed.
|
||||
let target = d.target.as_deref().and_then(|k| k.trim_start_matches('#').split_once(':'));
|
||||
let at = match target {
|
||||
Some((prefix, n)) => crate::mcp::exec::find_page(ed, prefix)
|
||||
.ok()
|
||||
.filter(|p| *p == ed.active_page)
|
||||
.and(n.parse().ok())
|
||||
.map(NodeId)
|
||||
.filter(|n| !st.agent_marks.iter().any(|m| m.page_id == page && m.node == *n))
|
||||
.and_then(|n| ed.doc.world_rect(n)),
|
||||
None => Some(draft_spot(&ed.doc)),
|
||||
};
|
||||
if let Some(r) = at.map(|r| to_screen(&r)) {
|
||||
kit::skeleton(&painter, r, t, DRAFT_SKELETON_ALPHA);
|
||||
if target.is_none() {
|
||||
kit::skeleton(&painter, r, t, DRAFT_SKELETON_ALPHA);
|
||||
}
|
||||
let stroke = egui::Stroke::new(AGENT_OUTLINE, accent.gamma_multiply(DRAFT_OUTLINE_ALPHA));
|
||||
painter.add(egui::Shape::dashed_line(&[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()], stroke, 6.0, 4.0));
|
||||
let ink = kit::ink_on(accent);
|
||||
@@ -2612,11 +2633,6 @@ fn agent_overlay(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState, canvas:
|
||||
let rect = |m: &AgentMark| (m.page_id == page).then(|| ed.doc.world_rect(m.node)).flatten().map(|r| to_screen(&r));
|
||||
for m in &st.agent_marks {
|
||||
let Some(r) = rect(m) else { continue };
|
||||
// New content comes in through its skeleton, so the outline never frames nothing.
|
||||
let age = m.at.elapsed().as_secs_f32();
|
||||
if age < REVEAL_SECS {
|
||||
kit::skeleton(&painter, r, t, (1.0 - age / REVEAL_SECS).powi(2));
|
||||
}
|
||||
let stroke = egui::Stroke::new(AGENT_OUTLINE, accent.gamma_multiply(fade(m)));
|
||||
painter.rect_stroke(r, REVIEW_RADIUS, stroke, egui::StrokeKind::Outside);
|
||||
}
|
||||
@@ -2883,7 +2899,7 @@ mod layer_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_outlines_show_after_a_call_and_go_after_three_seconds_without_repainting() {
|
||||
fn agent_outlines_show_after_a_call_and_fade_without_repainting() {
|
||||
let ctx = egui::Context::default();
|
||||
setup_style(&ctx);
|
||||
let mut ed = many_roots(3);
|
||||
|
||||
+288
-129
@@ -6,15 +6,26 @@ use super::{UiState, section};
|
||||
use crate::editor::Editor;
|
||||
use crate::theme::{self, Icon};
|
||||
use egui::{Color32, RichText, Ui};
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub struct PagesUi {
|
||||
/// The pages popover is closed.
|
||||
pub collapsed: bool,
|
||||
pub renaming: Option<(usize, String)>,
|
||||
drag: Option<(String, String)>,
|
||||
collapsed_groups: HashSet<String>,
|
||||
group_dialog: Option<GroupDialog>,
|
||||
duplicate: Option<String>,
|
||||
/// Filters the popover's pages by name.
|
||||
query: String,
|
||||
icon_picker: Option<IconPicker>,
|
||||
}
|
||||
|
||||
/// "Change icon…" for one page: a search over the installed icon sets.
|
||||
struct IconPicker {
|
||||
page_id: String,
|
||||
query: String,
|
||||
/// The query `hits` were found for: the sets are read from disk once per query.
|
||||
searched: Option<String>,
|
||||
hits: Vec<String>,
|
||||
}
|
||||
|
||||
struct GroupDialog {
|
||||
@@ -30,9 +41,10 @@ impl Default for PagesUi {
|
||||
collapsed: true,
|
||||
renaming: None,
|
||||
drag: None,
|
||||
collapsed_groups: HashSet::new(),
|
||||
group_dialog: None,
|
||||
duplicate: None,
|
||||
query: String::new(),
|
||||
icon_picker: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +58,31 @@ impl PagesUi {
|
||||
|
||||
const ROW_H: f32 = 30.0;
|
||||
const MENU_WIDTH: f32 = 220.0;
|
||||
/// Rows the popover shows before it scrolls.
|
||||
const POPOVER_ROWS: f32 = 12.0;
|
||||
/// A group's name above its pages in the popover (spacing included).
|
||||
const GROUP_HEADER_H: f32 = 38.0;
|
||||
const DISABLED_OPACITY: f32 = 0.4;
|
||||
/// Icons the picker shows for a query.
|
||||
const ICON_HITS: usize = 120;
|
||||
const ICON_CELL: f32 = 36.0;
|
||||
const ICON_PICKER_W: f32 = 9.0 * ICON_CELL;
|
||||
|
||||
/// A page's icon (`set:name` from the installed sets), or the default page icon.
|
||||
fn paint_page_icon(ui: &Ui, rect: egui::Rect, icon: Option<&str>, ink: Color32) {
|
||||
if let Some(t) = icon.and_then(|i| icon_texture(ui.ctx(), i)) {
|
||||
let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0));
|
||||
ui.painter().image(t.id(), rect, uv, ink);
|
||||
} else {
|
||||
theme::paint_icon(ui.painter(), rect, Icon::Page, ink);
|
||||
}
|
||||
}
|
||||
|
||||
fn icon_texture(ctx: &egui::Context, icon: &str) -> Option<egui::TextureHandle> {
|
||||
let (set, name) = icon.split_once(':')?;
|
||||
let svg = crate::vfs::read(crate::icons::dir().join(set).join(format!("{name}.svg"))).ok()?;
|
||||
super::chat::chat_agents::svg_texture(ctx, &format!("page-icon-{icon}"), &svg)
|
||||
}
|
||||
|
||||
pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
let p = theme::pal(ui);
|
||||
@@ -81,13 +118,13 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
theme::radius_lg(),
|
||||
theme::blend(p.field, p.hover.gamma_multiply(hover)),
|
||||
);
|
||||
theme::paint_icon(
|
||||
ui.painter(),
|
||||
paint_page_icon(
|
||||
ui,
|
||||
egui::Rect::from_center_size(
|
||||
egui::pos2(sel.left() + 18.0, sel.center().y),
|
||||
egui::vec2(16.0, 16.0),
|
||||
),
|
||||
Icon::Page,
|
||||
ed.page_icons.get(ed.active_page_id()).map(String::as_str),
|
||||
theme::accent(),
|
||||
);
|
||||
ui.painter()
|
||||
@@ -137,92 +174,11 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
);
|
||||
if resp.on_hover_text("Show all pages").clicked() {
|
||||
st.pages.collapsed = !st.pages.collapsed;
|
||||
st.pages.query.clear();
|
||||
}
|
||||
if st.pages.collapsed {
|
||||
return;
|
||||
if !st.pages.collapsed {
|
||||
popover(ui.ctx(), ed, st, sel);
|
||||
}
|
||||
let groups = ed.page_groups.clone();
|
||||
let rows = ed.pages.len() + groups.len();
|
||||
let max_h = (rows as f32 * (ROW_H + 1.0)).min(9.5 * (ROW_H + 1.0));
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("pages")
|
||||
.max_height(max_h)
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 1.0;
|
||||
let ungrouped: Vec<String> = ed
|
||||
.pages
|
||||
.iter()
|
||||
.filter(|page| ed.page_group(&page.id).is_none())
|
||||
.map(|page| page.id.clone())
|
||||
.collect();
|
||||
page_rows(ui, ed, st, &ungrouped);
|
||||
for (name, ids) in groups {
|
||||
let expanded = !st.pages.collapsed_groups.contains(&name);
|
||||
let response = ui
|
||||
.horizontal(|ui| {
|
||||
if theme::icon_button(
|
||||
ui,
|
||||
if expanded {
|
||||
Icon::ChevronDown
|
||||
} else {
|
||||
Icon::ChevronRight
|
||||
},
|
||||
16.0,
|
||||
false,
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
if expanded {
|
||||
st.pages.collapsed_groups.insert(name.clone());
|
||||
} else {
|
||||
st.pages.collapsed_groups.remove(&name);
|
||||
}
|
||||
}
|
||||
let response = ui.add(
|
||||
egui::Label::new(RichText::new(&name).font(theme::medium(theme::BODY)))
|
||||
.sense(egui::Sense::click()),
|
||||
);
|
||||
ui.label(
|
||||
RichText::new(ids.len().to_string())
|
||||
.weak()
|
||||
.size(theme::SECONDARY),
|
||||
);
|
||||
response
|
||||
})
|
||||
.inner;
|
||||
response.context_menu(|ui| {
|
||||
crate::theme::popup_backing(ui);
|
||||
ui.set_width(MENU_WIDTH);
|
||||
if super::icon_menu_item(ui, crate::theme::Icon::Text, "Rename group").clicked()
|
||||
{
|
||||
st.pages.group_dialog = Some(GroupDialog {
|
||||
project_stamp: ed.project_stamp,
|
||||
old_name: Some(name.clone()),
|
||||
page_id: None,
|
||||
name: name.clone(),
|
||||
});
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
if super::icon_menu_item(ui, crate::theme::Icon::Trash, "Remove group")
|
||||
.clicked()
|
||||
{
|
||||
confirm::request(st, ed, Removal::Group(name.clone()));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
if expanded {
|
||||
let members: Vec<String> = ed
|
||||
.pages
|
||||
.iter()
|
||||
.filter(|page| ids.contains(&page.id))
|
||||
.map(|page| page.id.clone())
|
||||
.collect();
|
||||
ui.indent(("page-group", &name), |ui| page_rows(ui, ed, st, &members));
|
||||
}
|
||||
}
|
||||
});
|
||||
if ui.input(|i| i.pointer.any_released())
|
||||
&& let Some((from, to)) = st.pages.drag.take()
|
||||
&& let (Some(from), Some(to)) = (
|
||||
@@ -238,6 +194,201 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
ed.duplicate_page(i);
|
||||
}
|
||||
group_dialog(ui.ctx(), ed, st);
|
||||
icon_picker(ui.ctx(), ed, st);
|
||||
}
|
||||
|
||||
/// The full page list under the selector: a search field, ungrouped pages, then each group
|
||||
/// under its name. Closes on a page pick, Escape or a click outside.
|
||||
fn popover(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState, sel: egui::Rect) {
|
||||
let area = egui::Area::new(egui::Id::new("pages-popover"))
|
||||
.order(egui::Order::Foreground)
|
||||
.fixed_pos(sel.left_bottom() + egui::vec2(0.0, 6.0))
|
||||
.show(ctx, |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
theme::popup_backing(ui);
|
||||
ui.set_width(sel.width() - 12.0);
|
||||
let search = crate::theme::lit(ui, |ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut st.pages.query)
|
||||
.hint_text("Search pages")
|
||||
.desired_width(f32::INFINITY)
|
||||
.margin(theme::INPUT_MARGIN),
|
||||
)
|
||||
});
|
||||
if search.gained_focus() || !ui.memory(|m| m.focused().is_some()) && st.pages.renaming.is_none() {
|
||||
search.request_focus();
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
let q = st.pages.query.trim().to_lowercase();
|
||||
let matches = |id: &String| {
|
||||
q.is_empty() || ed.pages.iter().any(|p| &p.id == id && p.name.to_lowercase().contains(&q))
|
||||
};
|
||||
let ungrouped: Vec<String> = ed
|
||||
.pages
|
||||
.iter()
|
||||
.filter(|page| ed.page_group(&page.id).is_none())
|
||||
.map(|page| page.id.clone())
|
||||
.filter(matches)
|
||||
.collect();
|
||||
let groups: Vec<(String, Vec<String>)> = ed
|
||||
.page_groups
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|(name, ids)| {
|
||||
let members = ed.pages.iter().filter(|p| ids.contains(&p.id)).map(|p| p.id.clone()).filter(matches).collect();
|
||||
(name, members)
|
||||
})
|
||||
.filter(|(_, ids): &(String, Vec<String>)| q.is_empty() || !ids.is_empty())
|
||||
.collect();
|
||||
// Sized from the rows, not last frame's content, so the popover never starts
|
||||
// shorter than its list.
|
||||
let rows = ungrouped.len() + groups.iter().map(|(_, ids)| ids.len()).sum::<usize>();
|
||||
let height = (rows as f32 * (ROW_H + 1.0) + groups.len() as f32 * GROUP_HEADER_H)
|
||||
.clamp(ROW_H, POPOVER_ROWS * (ROW_H + 1.0));
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("pages")
|
||||
.max_height(height)
|
||||
.min_scrolled_height(height)
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 1.0;
|
||||
page_rows(ui, ed, st, &ungrouped);
|
||||
for (name, ids) in groups {
|
||||
group_header(ui, ed, st, &name, ids.len());
|
||||
page_rows(ui, ed, st, &ids);
|
||||
}
|
||||
if ungrouped.is_empty() && ed.page_groups.is_empty() || (!q.is_empty() && ed.pages.iter().all(|p| !p.name.to_lowercase().contains(&q))) {
|
||||
ui.label(RichText::new("No pages match").weak());
|
||||
}
|
||||
});
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Enter))
|
||||
&& let Some(i) = ed.pages.iter().position(|p| !q.is_empty() && p.name.to_lowercase().contains(&q))
|
||||
{
|
||||
ed.switch_page(i);
|
||||
st.pages.collapsed = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
let busy = st.pages.renaming.is_some()
|
||||
|| st.pages.drag.is_some()
|
||||
|| st.pages.group_dialog.is_some()
|
||||
|| st.pages.icon_picker.is_some()
|
||||
|| egui::Popup::is_any_open(ctx)
|
||||
|| ctx.memory(|m| m.top_modal_layer().is_some());
|
||||
let outside = ctx.input(|i| {
|
||||
i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !area.response.rect.contains(p) && !sel.contains(p))
|
||||
});
|
||||
if !busy && (outside || ctx.input(|i| i.key_pressed(egui::Key::Escape))) {
|
||||
st.pages.collapsed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// A group's name over its pages, with its menu (rename, remove).
|
||||
fn group_header(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, name: &str, count: usize) {
|
||||
ui.add_space(6.0);
|
||||
let response = ui
|
||||
.horizontal(|ui| {
|
||||
ui.add_space(6.0);
|
||||
let r = ui.add(
|
||||
egui::Label::new(RichText::new(name).font(theme::medium(theme::SECONDARY)).color(theme::pal(ui).secondary))
|
||||
.sense(egui::Sense::click()),
|
||||
);
|
||||
ui.label(RichText::new(count.to_string()).weak().size(theme::SECONDARY));
|
||||
r
|
||||
})
|
||||
.inner;
|
||||
response.context_menu(|ui| {
|
||||
crate::theme::popup_backing(ui);
|
||||
ui.set_width(MENU_WIDTH);
|
||||
if super::icon_menu_item(ui, crate::theme::Icon::Text, "Rename group").clicked() {
|
||||
st.pages.group_dialog = Some(GroupDialog {
|
||||
project_stamp: ed.project_stamp,
|
||||
old_name: Some(name.to_string()),
|
||||
page_id: None,
|
||||
name: name.to_string(),
|
||||
});
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
if super::icon_menu_item(ui, crate::theme::Icon::Trash, "Remove group").clicked() {
|
||||
confirm::request(st, ed, Removal::Group(name.to_string()));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The page icon picker: search the installed icon sets, click to set; Reset goes back to the
|
||||
/// default page icon.
|
||||
fn icon_picker(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState) {
|
||||
let Some(mut pick) = st.pages.icon_picker.take() else {
|
||||
return;
|
||||
};
|
||||
if !ed.pages.iter().any(|p| p.id == pick.page_id) {
|
||||
return;
|
||||
}
|
||||
if pick.searched.as_deref() != Some(pick.query.as_str()) {
|
||||
pick.hits = crate::icons::search(&crate::icons::dir(), &pick.query, None, ICON_HITS);
|
||||
pick.searched = Some(pick.query.clone());
|
||||
}
|
||||
let mut done = false;
|
||||
let response = egui::Modal::new(egui::Id::new("page-icon-picker")).show(ctx, |ui| {
|
||||
theme::modal_backing(ui, ui.visuals().window_fill, theme::radius_xl());
|
||||
ui.set_width(ICON_PICKER_W);
|
||||
ui.heading("Page icon");
|
||||
let input = crate::theme::lit(ui, |ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut pick.query)
|
||||
.hint_text("Search icons (folder, users, code…)")
|
||||
.desired_width(f32::INFINITY)
|
||||
.margin(theme::INPUT_MARGIN),
|
||||
)
|
||||
});
|
||||
if input.gained_focus() || !ui.memory(|m| m.focused().is_some()) {
|
||||
input.request_focus();
|
||||
}
|
||||
let p = theme::pal(ui);
|
||||
if pick.hits.is_empty() {
|
||||
let note = if crate::icons::installed(&crate::icons::dir()).is_empty() {
|
||||
"No icon sets installed. Add one with: papercraft-cli icons add phosphor"
|
||||
} else {
|
||||
"No icons match"
|
||||
};
|
||||
ui.label(RichText::new(note).weak());
|
||||
}
|
||||
let current = ed.page_icons.get(&pick.page_id).cloned();
|
||||
egui::ScrollArea::vertical().max_height(6.0 * ICON_CELL).show(ui, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(0.0, 0.0);
|
||||
for hit in &pick.hits {
|
||||
let (cell, resp) = ui.allocate_exact_size(egui::vec2(ICON_CELL, ICON_CELL), egui::Sense::click());
|
||||
let on = current.as_deref() == Some(hit.as_str());
|
||||
if on {
|
||||
theme::fill_selected(ui, cell.shrink(2.0), theme::ru(8), p.field);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(cell.shrink(2.0), theme::r(8.0), p.hover);
|
||||
}
|
||||
paint_page_icon(ui, egui::Rect::from_center_size(cell.center(), egui::vec2(18.0, 18.0)), Some(hit), if on { theme::accent() } else { p.text });
|
||||
if resp.on_hover_text(hit.as_str()).clicked() {
|
||||
ed.set_page_icon(&pick.page_id, Some(hit));
|
||||
done = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
if theme::small_button(ui, "Cancel").clicked() {
|
||||
done = true;
|
||||
}
|
||||
if current.is_some() && theme::small_button(ui, "Reset").clicked() {
|
||||
ed.set_page_icon(&pick.page_id, None);
|
||||
done = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
if !done && !response.should_close() {
|
||||
st.pages.icon_picker = Some(pick);
|
||||
}
|
||||
}
|
||||
|
||||
fn page_rows(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, ids: &[String]) {
|
||||
@@ -296,7 +447,6 @@ fn group_dialog(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState) {
|
||||
match &draft.old_name {
|
||||
Some(old) => {
|
||||
ed.rename_page_group(old, name);
|
||||
st.pages.collapsed_groups.remove(old);
|
||||
}
|
||||
None => {
|
||||
ed.create_page_group(name);
|
||||
@@ -335,13 +485,13 @@ fn page_row(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, i: usize) {
|
||||
ui.painter()
|
||||
.hline(row.x_range(), y, egui::Stroke::new(2.0, theme::accent()));
|
||||
}
|
||||
theme::paint_icon(
|
||||
ui.painter(),
|
||||
paint_page_icon(
|
||||
ui,
|
||||
egui::Rect::from_center_size(
|
||||
egui::pos2(row.left() + 14.0, row.center().y),
|
||||
egui::vec2(13.0, 13.0),
|
||||
),
|
||||
Icon::Page,
|
||||
ed.page_icons.get(&ed.pages[i].id).map(String::as_str),
|
||||
if active { theme::accent() } else { p.secondary },
|
||||
);
|
||||
let name_rect = egui::Rect::from_min_max(
|
||||
@@ -390,6 +540,7 @@ fn page_row(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, i: usize) {
|
||||
st.pages.renaming = Some((i, ed.pages[i].name.clone()));
|
||||
} else if resp.clicked() {
|
||||
ed.switch_page(i);
|
||||
st.pages.collapsed = true;
|
||||
}
|
||||
let page_id = ed.pages[i].id.clone();
|
||||
if resp.drag_started() {
|
||||
@@ -408,46 +559,54 @@ fn page_row(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, i: usize) {
|
||||
st.pages.renaming = Some((i, ed.pages[i].name.clone()));
|
||||
ui.close();
|
||||
}
|
||||
if super::icon_menu_item(ui, crate::theme::Icon::Page, "Change icon\u{2026}").clicked() {
|
||||
st.pages.icon_picker = Some(IconPicker { page_id: page_id.clone(), query: String::new(), searched: None, hits: Vec::new() });
|
||||
ui.close();
|
||||
}
|
||||
if super::icon_menu_item(ui, crate::theme::Icon::Group, "Duplicate").clicked() {
|
||||
st.pages.duplicate = Some(page_id.clone());
|
||||
ui.close();
|
||||
}
|
||||
ui.menu_button("Move to group", |ui| {
|
||||
crate::theme::popup_backing(ui);
|
||||
ui.set_width(MENU_WIDTH);
|
||||
if ui
|
||||
.selectable_label(ed.page_group(&page_id).is_none(), "Ungrouped")
|
||||
.clicked()
|
||||
{
|
||||
ed.assign_page_group(&page_id, None);
|
||||
ui.close();
|
||||
}
|
||||
let groups: Vec<String> = ed.page_groups.keys().cloned().collect();
|
||||
for group in groups {
|
||||
if ui
|
||||
.selectable_label(ed.page_group(&page_id) == Some(group.as_str()), &group)
|
||||
.clicked()
|
||||
{
|
||||
ed.assign_page_group(&page_id, Some(&group));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if super::icon_menu_item(ui, crate::theme::Icon::Plus, "New group…").clicked() {
|
||||
st.pages.group_dialog = Some(GroupDialog {
|
||||
project_stamp: ed.project_stamp,
|
||||
old_name: None,
|
||||
page_id: Some(page_id.clone()),
|
||||
name: String::new(),
|
||||
});
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
// Destinations inline (no submenu): every group but the page's own, then a new group.
|
||||
ui.separator();
|
||||
if ui
|
||||
.add_enabled(ed.pages.len() > 1, egui::Button::new("Delete"))
|
||||
.clicked()
|
||||
{
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(10.0);
|
||||
ui.label(RichText::new("Move to group").size(theme::SECONDARY).color(theme::pal(ui).secondary));
|
||||
});
|
||||
let current = ed.page_group(&page_id).map(str::to_owned);
|
||||
if current.is_some() && super::icon_menu_item(ui, crate::theme::Icon::Page, "Ungrouped").clicked() {
|
||||
ed.assign_page_group(&page_id, None);
|
||||
ui.close();
|
||||
}
|
||||
let groups: Vec<String> = ed.page_groups.keys().filter(|g| current.as_ref() != Some(*g)).cloned().collect();
|
||||
for group in groups {
|
||||
if super::icon_menu_item(ui, crate::theme::Icon::Group, &group).clicked() {
|
||||
ed.assign_page_group(&page_id, Some(&group));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
if super::icon_menu_item(ui, crate::theme::Icon::Plus, "New group\u{2026}").clicked() {
|
||||
st.pages.group_dialog = Some(GroupDialog {
|
||||
project_stamp: ed.project_stamp,
|
||||
old_name: None,
|
||||
page_id: Some(page_id.clone()),
|
||||
name: String::new(),
|
||||
});
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
// The last page can't go: shown dimmed and inert.
|
||||
let can_delete = ed.pages.len() > 1;
|
||||
let delete = ui
|
||||
.scope(|ui| {
|
||||
if !can_delete {
|
||||
ui.disable();
|
||||
ui.multiply_opacity(DISABLED_OPACITY);
|
||||
}
|
||||
super::icon_menu_item(ui, crate::theme::Icon::Trash, "Delete")
|
||||
})
|
||||
.inner;
|
||||
if delete.clicked() {
|
||||
confirm::request(
|
||||
st,
|
||||
ed,
|
||||
|
||||
Reference in New Issue
Block a user