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:
Marcos Paulo
2026-10-28 20:47:02 -03:00
parent e4639054e7
commit a9a719f1ef
5 changed files with 346 additions and 149 deletions
+16
View File
@@ -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();
+4
View File
@@ -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(),
}
}
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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,