diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index f4a27c5e..07fff995 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -1,7 +1,7 @@ //! The colour picker popover: paint type, saturation/brightness square, hue and alpha sliders, //! hex and RGB fields, gradient stops, image and pattern fills and document colours. -use egui::{Align2, Color32, CornerRadius, Id, Mesh, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use egui::{Align2, Color32, CornerRadius, Id, Mesh, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_color::{ Color, ColorStop, Gradient, GradientKind, Hsb, ImageFilters, ImageScaleMode, Paint, PaintKind, PatternAlign, PatternPaint, PatternTile, }; @@ -12,7 +12,8 @@ use crate::FigApp; use crate::a11y::Tip; use crate::i18n::{tr, tr_noop, trf}; use crate::icons::{self, Icon}; -use crate::theme::{Tokens, doc_c32, regular, semibold}; +use crate::popover::{Hold, Open, Popover}; +use crate::theme::{Tokens, doc_c32, regular}; use crate::widgets; #[derive(Clone, Debug, PartialEq)] @@ -451,6 +452,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { finish(app); } app.ui.picker = None; + app.ui.picker_popover = None; return; }; let t = Tokens::get(ctx); @@ -461,407 +463,394 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { _ => None, }; let pos = beside.unwrap_or(pos2(screen.max.x - app.ui.right_width - 260.0, 140.0)); - let mut open = true; - let mut close_now = false; - egui::Window::new("color_picker") - .title_bar(false) - .fixed_pos(pos) - .fixed_size(vec2(240.0, 0.0)) - .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(0))) - .open(&mut open) - .show(ctx, |ui| { - ui.spacing_mut().item_spacing = vec2(8.0, 8.0); - // Header. - let (hr, _) = ui.allocate_exact_size(vec2(240.0, 40.0), Sense::hover()); - ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, tr("Custom"), semibold(11.0), t.text); - let cr = Rect::from_center_size(pos2(hr.max.x - 20.0, hr.center().y), vec2(24.0, 24.0)); - let cresp = ui.interact(cr, Id::new("cp_close"), Sense::click()); - icons::paint(ui.painter(), cr.shrink(6.0), Icon::Close, t.icon); - if cresp.clicked() { - close_now = true; - } - // Paint type. - if !matches!( - target, - PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. } - ) { - let kinds = [ - (Icon::Fill, tr("Solid")), - (Icon::GradientLinear, tr("Linear")), - (Icon::GradientRadial, tr("Radial")), - (Icon::GradientAngular, tr("Angular")), - (Icon::GradientDiamond, tr("Diamond")), - (Icon::Image, tr("Image")), - (Icon::ViewGrid, tr("Pattern")), - ]; - let active = match &paint.kind { - PaintKind::Solid { .. } => 0, - PaintKind::Gradient(g) => match g.kind { - GradientKind::Linear => 1, - GradientKind::Radial => 2, - GradientKind::Angular => 3, - GradientKind::Diamond => 4, - }, - PaintKind::Image(_) => 5, - PaintKind::Pattern(_) => 6, - }; - ui.horizontal(|ui| { - ui.add_space(12.0); - if let Some(i) = widgets::segmented(ui, &kinds, Some(active), 30.0) { - let base = paint.preview_color(); - let mut image_pending = false; - paint.kind = match i { - 0 => PaintKind::Solid { color: base.with_alpha(1.0) }, - 5 => { - image_pending = true; - let target = target.clone(); - app.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"], move |app, p| { - let Ok(bytes) = figcraft_engine::file_access::read(&p) else { return }; - let Some(hash) = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)) else { return }; - if let Some(mut paint) = current_paint(app, &target) { - paint.kind = Paint::image(hash, ImageScaleMode::Fill).kind; - write(app, &target, &paint, false); - } - }); + // The colour picker's own header — title 16 in, the X 8 in from the right — is the shared + // popover chrome now; the picker keeps its body. + let mut pop = app.ui.picker_popover.take().unwrap_or_else(|| Popover::new("color_picker", tr("Custom")).pad(0.0).close_on_outside(false)); + let closed = pop.show(ctx, Open::At(pos), Hold::default(), |ui| { + ui.spacing_mut().item_spacing = vec2(8.0, 8.0); + // Paint type. + if !matches!( + target, + PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. } + ) { + let kinds = [ + (Icon::Fill, tr("Solid")), + (Icon::GradientLinear, tr("Linear")), + (Icon::GradientRadial, tr("Radial")), + (Icon::GradientAngular, tr("Angular")), + (Icon::GradientDiamond, tr("Diamond")), + (Icon::Image, tr("Image")), + (Icon::ViewGrid, tr("Pattern")), + ]; + let active = match &paint.kind { + PaintKind::Solid { .. } => 0, + PaintKind::Gradient(g) => match g.kind { + GradientKind::Linear => 1, + GradientKind::Radial => 2, + GradientKind::Angular => 3, + GradientKind::Diamond => 4, + }, + PaintKind::Image(_) => 5, + PaintKind::Pattern(_) => 6, + }; + ui.horizontal(|ui| { + ui.add_space(12.0); + if let Some(i) = widgets::segmented(ui, &kinds, Some(active), 30.0) { + let base = paint.preview_color(); + let mut image_pending = false; + paint.kind = match i { + 0 => PaintKind::Solid { color: base.with_alpha(1.0) }, + 5 => { + image_pending = true; + let target = target.clone(); + app.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"], move |app, p| { + let Ok(bytes) = figcraft_engine::file_access::read(&p) else { return }; + let Some(hash) = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)) else { return }; + if let Some(mut paint) = current_paint(app, &target) { + paint.kind = Paint::image(hash, ImageScaleMode::Fill).kind; + write(app, &target, &paint, false); + } + }); + paint.kind.clone() + } + 6 => match pattern_sources(app, &target).first() { + Some((id, _)) => Paint::pattern(id.0).kind, + None => { + app.toast(tr("Add a layer or component to tile first").to_string()); paint.kind.clone() } - 6 => match pattern_sources(app, &target).first() { - Some((id, _)) => Paint::pattern(id.0).kind, - None => { - app.toast(tr("Add a layer or component to tile first").to_string()); - paint.kind.clone() - } - }, - k => { - let kind = [GradientKind::Linear, GradientKind::Radial, GradientKind::Angular, GradientKind::Diamond][k - 1]; - match &paint.kind { - PaintKind::Gradient(g) => { - PaintKind::Gradient(Gradient { kind, ..Gradient::default_for(kind, base).with_stops(g.stops.clone()) }) - } - _ => PaintKind::Gradient(Gradient::default_for(kind, base)), + }, + k => { + let kind = [GradientKind::Linear, GradientKind::Radial, GradientKind::Angular, GradientKind::Diamond][k - 1]; + match &paint.kind { + PaintKind::Gradient(g) => { + PaintKind::Gradient(Gradient { kind, ..Gradient::default_for(kind, base).with_stops(g.stops.clone()) }) } + _ => PaintKind::Gradient(Gradient::default_for(kind, base)), } - }; - if !image_pending { - write(app, &target, &paint, false); } - } - }); - } - // The paint's own blend mode, with the paints and layers under it. - if !matches!( - target, - PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. } - ) { - ui.horizontal(|ui| { - ui.add_space(16.0); - let resp = widgets::dropdown(ui, tr(paint.blend.label()), 208.0, Some(Icon::Blend)).on_hover_text(tr("Blend mode")); - crate::right::menu_popup(&resp).width(200.0).show(|ui| { - if let Some(b) = crate::right::blend_menu(ui, false) { - paint.blend = b; - write(app, &target, &paint, false); - } - }); - }); - } - // Gradient stops. - let mut edit_color: Option = match &paint.kind { - PaintKind::Solid { color } => Some(color.with_alpha(color.a * paint.opacity)), - _ => None, - }; - let sel_stop_id = stop_id(); - if let PaintKind::Gradient(g) = &paint.kind { - let mut g = g.clone(); - let mut sel: usize = ui.data(|d| d.get_temp(sel_stop_id)).unwrap_or(0).min(g.stops.len().saturating_sub(1)); - let (bar, _) = ui.allocate_exact_size(vec2(240.0, 28.0), Sense::hover()); - let bar = bar.shrink2(vec2(16.0, 6.0)); - widgets::checker(&ui.painter().with_clip_rect(bar), bar, 4.0); - let n = 24; - for i in 0..n { - let a = i as f32 / n as f32; - let b = (i + 1) as f32 / n as f32; - let r = Rect::from_min_max(pos2(bar.min.x + a * bar.width(), bar.min.y), pos2(bar.min.x + b * bar.width(), bar.max.y)); - ui.painter().rect_filled(r, CornerRadius::ZERO, doc_c32(ui.ctx(), g.sample((a + b) / 2.0))); - } - let mut changed = false; - let mut done = false; - // The bar first, so the stops on it take their own clicks. - let bresp = ui.interact(bar, Id::new("cp_bar"), Sense::click()); - for (i, s) in g.stops.clone().iter().enumerate() { - let x = bar.min.x + s.position * bar.width(); - let r = Rect::from_center_size(pos2(x, bar.center().y), vec2(12.0, 18.0)); - let resp = ui.interact(r, Id::new(("cp_stopk", i)), Sense::click_and_drag()); - ui.painter().rect_filled(r, CornerRadius::same(3), doc_c32(ui.ctx(), s.color.with_alpha(1.0))); - ui.painter().rect_stroke( - r, - CornerRadius::same(3), - Stroke::new(if i == sel { 2.0 } else { 1.0 }, if i == sel { crate::theme::ACCENT } else { Color32::WHITE }), - StrokeKind::Outside, - ); - if resp.clicked() || resp.drag_started() { - sel = i; - } - if resp.dragged() - && let Some(p) = resp.interact_pointer_pos() - && let Some(st) = g.stops.get_mut(i) - { - st.position = ((p.x - bar.min.x) / bar.width()).clamp(0.0, 1.0); - changed = true; - } - if resp.drag_stopped() { - done = true; + }; + if !image_pending { + write(app, &target, &paint, false); } } - // Click on the bar adds a stop. - if bresp.clicked() - && let Some(p) = bresp.interact_pointer_pos() + }); + } + // The paint's own blend mode, with the paints and layers under it. + if !matches!( + target, + PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. } + ) { + ui.horizontal(|ui| { + ui.add_space(16.0); + let resp = widgets::dropdown(ui, tr(paint.blend.label()), 208.0, Some(Icon::Blend)).on_hover_text(tr("Blend mode")); + crate::right::menu_popup(&resp).width(200.0).show(|ui| { + if let Some(b) = crate::right::blend_menu(ui, false) { + paint.blend = b; + write(app, &target, &paint, false); + } + }); + }); + } + // Gradient stops. + let mut edit_color: Option = match &paint.kind { + PaintKind::Solid { color } => Some(color.with_alpha(color.a * paint.opacity)), + _ => None, + }; + let sel_stop_id = stop_id(); + if let PaintKind::Gradient(g) = &paint.kind { + let mut g = g.clone(); + let mut sel: usize = ui.data(|d| d.get_temp(sel_stop_id)).unwrap_or(0).min(g.stops.len().saturating_sub(1)); + let (bar, _) = ui.allocate_exact_size(vec2(240.0, 28.0), Sense::hover()); + let bar = bar.shrink2(vec2(16.0, 6.0)); + widgets::checker(&ui.painter().with_clip_rect(bar), bar, 4.0); + let n = 24; + for i in 0..n { + let a = i as f32 / n as f32; + let b = (i + 1) as f32 / n as f32; + let r = Rect::from_min_max(pos2(bar.min.x + a * bar.width(), bar.min.y), pos2(bar.min.x + b * bar.width(), bar.max.y)); + ui.painter().rect_filled(r, CornerRadius::ZERO, doc_c32(ui.ctx(), g.sample((a + b) / 2.0))); + } + let mut changed = false; + let mut done = false; + // The bar first, so the stops on it take their own clicks. + let bresp = ui.interact(bar, Id::new("cp_bar"), Sense::click()); + for (i, s) in g.stops.clone().iter().enumerate() { + let x = bar.min.x + s.position * bar.width(); + let r = Rect::from_center_size(pos2(x, bar.center().y), vec2(12.0, 18.0)); + let resp = ui.interact(r, Id::new(("cp_stopk", i)), Sense::click_and_drag()); + ui.painter().rect_filled(r, CornerRadius::same(3), doc_c32(ui.ctx(), s.color.with_alpha(1.0))); + ui.painter().rect_stroke( + r, + CornerRadius::same(3), + Stroke::new(if i == sel { 2.0 } else { 1.0 }, if i == sel { crate::theme::ACCENT } else { Color32::WHITE }), + StrokeKind::Outside, + ); + if resp.clicked() || resp.drag_started() { + sel = i; + } + if resp.dragged() + && let Some(p) = resp.interact_pointer_pos() + && let Some(st) = g.stops.get_mut(i) { - let pos = ((p.x - bar.min.x) / bar.width()).clamp(0.0, 1.0); - g.stops.push(ColorStop { position: pos, color: g.sample(pos) }); - sel = g.stops.len() - 1; + st.position = ((p.x - bar.min.x) / bar.width()).clamp(0.0, 1.0); changed = true; + } + if resp.drag_stopped() { done = true; } - ui.data_mut(|d| d.insert_temp(sel_stop_id, sel)); - if changed { + } + // Click on the bar adds a stop. + if bresp.clicked() + && let Some(p) = bresp.interact_pointer_pos() + { + let pos = ((p.x - bar.min.x) / bar.width()).clamp(0.0, 1.0); + g.stops.push(ColorStop { position: pos, color: g.sample(pos) }); + sel = g.stops.len() - 1; + changed = true; + done = true; + } + ui.data_mut(|d| d.insert_temp(sel_stop_id, sel)); + if changed { + paint.kind = PaintKind::Gradient(g.clone()); + write(app, &target, &paint, !done); + } + if done { + finish(app); + } + edit_color = g.stops.get(sel).map(|s| s.color); + ui.horizontal(|ui| { + ui.add_space(16.0); + if ui.small_button(tr("Remove stop")).clicked() && g.stops.len() > 2 { + g.stops.remove(sel); + ui.data_mut(|d| d.insert_temp(sel_stop_id, 0usize)); paint.kind = PaintKind::Gradient(g.clone()); - write(app, &target, &paint, !done); + write(app, &target, &paint, false); } - if done { - finish(app); + if ui.small_button(tr("Flip")).clicked() { + for s in &mut g.stops { + s.position = 1.0 - s.position; + } + paint.kind = PaintKind::Gradient(g.clone()); + write(app, &target, &paint, false); } - edit_color = g.stops.get(sel).map(|s| s.color); + if ui.small_button(tr("Rotate 90°")).clicked() { + let (fx, fy) = g.from; + let rot = |(x, y): (f64, f64)| (fx - (y - fy), fy + (x - fx)); + g.to = rot(g.to); + g.width = rot(g.width); + paint.kind = PaintKind::Gradient(g.clone()); + write(app, &target, &paint, false); + } + }); + // The selected stop's colour can come from a colour variable. + if let PickerTarget::Paint { fills, index, ids } = &target { + let field = format!("{}/{index}/stops/{sel}/color", if *fills { "fills" } else { "strokes" }); + let stop = g.stops.get(sel).map(|s| s.color); ui.horizontal(|ui| { ui.add_space(16.0); - if ui.small_button(tr("Remove stop")).clicked() && g.stops.len() > 2 { - g.stops.remove(sel); - ui.data_mut(|d| d.insert_temp(sel_stop_id, 0usize)); - paint.kind = PaintKind::Gradient(g.clone()); - write(app, &target, &paint, false); - } - if ui.small_button(tr("Flip")).clicked() { - for s in &mut g.stops { - s.position = 1.0 - s.position; + crate::variable_ui::bindable(app, ui, ids, &field, figcraft_doc::VariableType::Color, 208.0, |_, ui| { + let (r, _) = ui.allocate_exact_size(vec2(208.0, widgets::FIELD_H), Sense::hover()); + ui.painter().rect_filled(r, CornerRadius::same(5), t.field); + let sw = Rect::from_center_size(pos2(r.min.x + 13.0, r.center().y), vec2(14.0, 14.0)); + if let Some(c) = stop { + widgets::paint_swatch(ui.painter(), sw, &Paint::solid(c)); + let text = trf("Stop {n} {hex}", &[("n", &(sel + 1)), ("hex", &c.to_hex())]); + ui.painter().text(pos2(sw.max.x + 8.0, r.center().y), Align2::LEFT_CENTER, text, regular(11.0), t.text); } - paint.kind = PaintKind::Gradient(g.clone()); - write(app, &target, &paint, false); - } - if ui.small_button(tr("Rotate 90°")).clicked() { - let (fx, fy) = g.from; - let rot = |(x, y): (f64, f64)| (fx - (y - fy), fy + (x - fx)); - g.to = rot(g.to); - g.width = rot(g.width); - paint.kind = PaintKind::Gradient(g.clone()); - write(app, &target, &paint, false); - } - }); - // The selected stop's colour can come from a colour variable. - if let PickerTarget::Paint { fills, index, ids } = &target { - let field = format!("{}/{index}/stops/{sel}/color", if *fills { "fills" } else { "strokes" }); - let stop = g.stops.get(sel).map(|s| s.color); - ui.horizontal(|ui| { - ui.add_space(16.0); - crate::variable_ui::bindable(app, ui, ids, &field, figcraft_doc::VariableType::Color, 208.0, |_, ui| { - let (r, _) = ui.allocate_exact_size(vec2(208.0, widgets::FIELD_H), Sense::hover()); - ui.painter().rect_filled(r, CornerRadius::same(5), t.field); - let sw = Rect::from_center_size(pos2(r.min.x + 13.0, r.center().y), vec2(14.0, 14.0)); - if let Some(c) = stop { - widgets::paint_swatch(ui.painter(), sw, &Paint::solid(c)); - let text = trf("Stop {n} {hex}", &[("n", &(sel + 1)), ("hex", &c.to_hex())]); - ui.painter().text(pos2(sw.max.x + 8.0, r.center().y), Align2::LEFT_CENTER, text, regular(11.0), t.text); - } - }); }); - } + }); } - if let PaintKind::Image(img) = &paint.kind { - let mut img = img.clone(); - ui.horizontal(|ui| { - ui.add_space(16.0); - let resp = widgets::dropdown(ui, tr(&format!("{:?}", img.scale_mode)), 100.0, None); - crate::right::menu_popup(&resp).show(|ui| { - for (l, m) in [ - (tr("Fill"), ImageScaleMode::Fill), - (tr("Fit"), ImageScaleMode::Fit), - (tr("Crop"), ImageScaleMode::Crop), - (tr("Tile"), ImageScaleMode::Tile), - ] { - if crate::right::menu_item(ui, l, "") { - img.scale_mode = m; - paint.kind = PaintKind::Image(img.clone()); - write(app, &target, &paint, false); - ui.close(); - } - } - }); - if icons::button(ui, Icon::Rotate, 24.0, false, tr("Rotate 90°")).clicked() { - img.rotation = (img.rotation + 1) % 4; - paint.kind = PaintKind::Image(img.clone()); - write(app, &target, &paint, false); - } - }); - // The image's own colour profile (decoding converts it into the file's). - let source = app.session.doc().ok().and_then(|d| figcraft_render::images::profile_name(&img.image, d.images.get(&img.image))); - let source = match source.as_deref() { - None => tr("Unknown — treated as sRGB"), - Some("") => tr("Embedded profile"), - Some(name) => name, - }; - ui.horizontal(|ui| { - ui.add_space(16.0); - let text = trf("Color profile: {name}", &[("name", &source)]); - ui.add(egui::Label::new(egui::RichText::new(text).font(regular(11.0)).color(t.text_secondary)).truncate()); - }); - image_presets(app, ui, &target, &mut paint, img.filters); - for (label, val) in [ - (tr_noop("Exposure"), img.filters.exposure), - (tr_noop("Contrast"), img.filters.contrast), - (tr_noop("Saturation"), img.filters.saturation), - (tr_noop("Temperature"), img.filters.temperature), - (tr_noop("Tint"), img.filters.tint), - (tr_noop("Highlights"), img.filters.highlights), - (tr_noop("Shadows"), img.filters.shadows), - ] { - ui.horizontal(|ui| { - ui.add_space(16.0); - // Labels share a column so the sliders line up. - let (lr, _) = ui.allocate_exact_size(vec2(72.0, 18.0), Sense::hover()); - ui.painter().text(lr.left_center(), Align2::LEFT_CENTER, tr(label), regular(11.0), t.text); - let mut v = val; - let r = ui.add(egui::Slider::new(&mut v, -1.0..=1.0).show_value(false)); - if r.changed() { - let f = &mut img.filters; - match label { - "Exposure" => f.exposure = v, - "Contrast" => f.contrast = v, - "Saturation" => f.saturation = v, - "Temperature" => f.temperature = v, - "Tint" => f.tint = v, - "Highlights" => f.highlights = v, - _ => f.shadows = v, - } + } + if let PaintKind::Image(img) = &paint.kind { + let mut img = img.clone(); + ui.horizontal(|ui| { + ui.add_space(16.0); + let resp = widgets::dropdown(ui, tr(&format!("{:?}", img.scale_mode)), 100.0, None); + crate::right::menu_popup(&resp).show(|ui| { + for (l, m) in [ + (tr("Fill"), ImageScaleMode::Fill), + (tr("Fit"), ImageScaleMode::Fit), + (tr("Crop"), ImageScaleMode::Crop), + (tr("Tile"), ImageScaleMode::Tile), + ] { + if crate::right::menu_item(ui, l, "") { + img.scale_mode = m; paint.kind = PaintKind::Image(img.clone()); - write(app, &target, &paint, !r.drag_stopped()); + write(app, &target, &paint, false); + ui.close(); } - if r.drag_stopped() { - finish(app); - } - }); - } - } - if let PaintKind::Pattern(pt) = paint.kind.clone() { - pattern_controls(app, ui, &target, &mut paint, pt); - } - if let Some(color) = edit_color { - let mut hsb = ui - .data(|d| d.get_temp::<(f32, f32, f32, [u8; 4])>(Id::new("cp_hsb"))) - .filter(|(_, _, _, rgba)| *rgba == color.to_rgba8()) - .map(|(h, s, b, _)| Hsb { h, s, b }) - .unwrap_or_else(|| Hsb::from_color(color)); - let (sq, _) = ui.allocate_exact_size(vec2(240.0, 240.0), Sense::hover()); - let (c1, d1) = sv_square(ui, sq, &mut hsb); - let (hr, _) = ui.allocate_exact_size(vec2(240.0, 28.0), Sense::hover()); - let hr = Rect::from_min_max(pos2(hr.min.x + 48.0, hr.min.y + 8.0), pos2(hr.max.x - 16.0, hr.max.y - 8.0)); - if icons::button( - &mut ui.new_child( - egui::UiBuilder::new().max_rect(Rect::from_min_size(pos2(hr.min.x - 36.0, hr.center().y - 12.0), vec2(24.0, 24.0))), - ), - Icon::Eyedropper, - 24.0, - false, - tr("Pick color (I)"), - ) - .clicked() - { - app.ui.picker = None; - app.run("edit.pickColor", json!({})); - return; - } - let (c2, d2) = hue_slider(ui, hr, &mut hsb); - let (ar, _) = ui.allocate_exact_size(vec2(240.0, 20.0), Sense::hover()); - let ar = Rect::from_min_max(pos2(ar.min.x + 48.0, ar.min.y + 2.0), pos2(ar.max.x - 16.0, ar.max.y - 6.0)); - let mut new_alpha = None; - if let Some((a, d)) = alpha_slider(ui, ar, color) { - new_alpha = Some((a, d)); - } - let mut nc = hsb.to_color().with_alpha(color.a); - let mut done = d1 || d2; - let mut changed = c1 || c2; - if let Some((a, d)) = new_alpha { - nc = nc.with_alpha(a); - changed = true; - done |= d; - } - // Colour model fields (Hex, RGB, HSL, HSB or CSS) + alpha. - if let Some((c, d)) = model_fields(ui, nc) { - nc = c; - hsb = Hsb::from_color(nc); - changed = true; - done |= d; - } - ui.data_mut(|d| d.insert_temp(Id::new("cp_hsb"), (hsb.h, hsb.s, hsb.b, nc.to_rgba8()))); - // A Display P3 colour sRGB can't show: the canvas (sRGB) shows the nearest one. - if app.session.doc().is_ok_and(|d| d.color_profile.conversion_to(figcraft_color::ColorProfile::Srgb).clips(nc)) { - gamut_warning(ui, &t); - } - if changed { - match &mut paint.kind { - PaintKind::Solid { color } => { - *color = nc.with_alpha(1.0); - paint.opacity = nc.a; - } - PaintKind::Gradient(g) => { - let sel: usize = ui.data(|d| d.get_temp(sel_stop_id)).unwrap_or(0); - if let Some(s) = g.stops.get_mut(sel) { - s.color = nc; - } - } - PaintKind::Image(_) | PaintKind::Pattern(_) => {} } - write(app, &target, &paint, !done); + }); + if icons::button(ui, Icon::Rotate, 24.0, false, tr("Rotate 90°")).clicked() { + img.rotation = (img.rotation + 1) % 4; + paint.kind = PaintKind::Image(img.clone()); + write(app, &target, &paint, false); } - if done { - finish(app); - } - } - // Colours used on this page. - let doc_colors = page_colors(app, ui); - if !doc_colors.is_empty() { + }); + // The image's own colour profile (decoding converts it into the file's). + let source = app.session.doc().ok().and_then(|d| figcraft_render::images::profile_name(&img.image, d.images.get(&img.image))); + let source = match source.as_deref() { + None => tr("Unknown — treated as sRGB"), + Some("") => tr("Embedded profile"), + Some(name) => name, + }; + ui.horizontal(|ui| { + ui.add_space(16.0); + let text = trf("Color profile: {name}", &[("name", &source)]); + ui.add(egui::Label::new(egui::RichText::new(text).font(regular(11.0)).color(t.text_secondary)).truncate()); + }); + image_presets(app, ui, &target, &mut paint, img.filters); + for (label, val) in [ + (tr_noop("Exposure"), img.filters.exposure), + (tr_noop("Contrast"), img.filters.contrast), + (tr_noop("Saturation"), img.filters.saturation), + (tr_noop("Temperature"), img.filters.temperature), + (tr_noop("Tint"), img.filters.tint), + (tr_noop("Highlights"), img.filters.highlights), + (tr_noop("Shadows"), img.filters.shadows), + ] { ui.horizontal(|ui| { ui.add_space(16.0); - widgets::label(ui, tr("On this page"), t.text_secondary); - }); - // Inset on both sides: `add_space` would only indent the first row of a wrap. - inset(ui, |ui| { - for c in &doc_colors { - if widgets::swatch(ui, &Paint::solid(*c), 14.0).on_hover_text(c.to_hex()).clicked() { - set_color(app, &target, &mut paint, *c, sel_stop_id, ui); + // Labels share a column so the sliders line up. + let (lr, _) = ui.allocate_exact_size(vec2(72.0, 18.0), Sense::hover()); + ui.painter().text(lr.left_center(), Align2::LEFT_CENTER, tr(label), regular(11.0), t.text); + let mut v = val; + let r = ui.add(egui::Slider::new(&mut v, -1.0..=1.0).show_value(false)); + if r.changed() { + let f = &mut img.filters; + match label { + "Exposure" => f.exposure = v, + "Contrast" => f.contrast = v, + "Saturation" => f.saturation = v, + "Temperature" => f.temperature = v, + "Tint" => f.tint = v, + "Highlights" => f.highlights = v, + _ => f.shadows = v, } + paint.kind = PaintKind::Image(img.clone()); + write(app, &target, &paint, !r.drag_stopped()); + } + if r.drag_stopped() { + finish(app); } }); } - // Recent colours. - let recent = app.session.recent_colors.clone(); - if !recent.is_empty() { - ui.horizontal(|ui| { - ui.add_space(16.0); - widgets::label(ui, tr("Recent"), t.text_secondary); - }); - inset(ui, |ui| { - for c in recent.iter().take(14) { - if widgets::swatch(ui, &Paint::solid(*c), 14.0).clicked() { - set_color(app, &target, &mut paint, *c, sel_stop_id, ui); + } + if let PaintKind::Pattern(pt) = paint.kind.clone() { + pattern_controls(app, ui, &target, &mut paint, pt); + } + if let Some(color) = edit_color { + let mut hsb = ui + .data(|d| d.get_temp::<(f32, f32, f32, [u8; 4])>(Id::new("cp_hsb"))) + .filter(|(_, _, _, rgba)| *rgba == color.to_rgba8()) + .map(|(h, s, b, _)| Hsb { h, s, b }) + .unwrap_or_else(|| Hsb::from_color(color)); + let (sq, _) = ui.allocate_exact_size(vec2(240.0, 240.0), Sense::hover()); + let (c1, d1) = sv_square(ui, sq, &mut hsb); + let (hr, _) = ui.allocate_exact_size(vec2(240.0, 28.0), Sense::hover()); + let hr = Rect::from_min_max(pos2(hr.min.x + 48.0, hr.min.y + 8.0), pos2(hr.max.x - 16.0, hr.max.y - 8.0)); + if icons::button( + &mut ui + .new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(pos2(hr.min.x - 36.0, hr.center().y - 12.0), vec2(24.0, 24.0)))), + Icon::Eyedropper, + 24.0, + false, + tr("Pick color (I)"), + ) + .clicked() + { + app.ui.picker = None; + app.run("edit.pickColor", json!({})); + return; + } + let (c2, d2) = hue_slider(ui, hr, &mut hsb); + let (ar, _) = ui.allocate_exact_size(vec2(240.0, 20.0), Sense::hover()); + let ar = Rect::from_min_max(pos2(ar.min.x + 48.0, ar.min.y + 2.0), pos2(ar.max.x - 16.0, ar.max.y - 6.0)); + let mut new_alpha = None; + if let Some((a, d)) = alpha_slider(ui, ar, color) { + new_alpha = Some((a, d)); + } + let mut nc = hsb.to_color().with_alpha(color.a); + let mut done = d1 || d2; + let mut changed = c1 || c2; + if let Some((a, d)) = new_alpha { + nc = nc.with_alpha(a); + changed = true; + done |= d; + } + // Colour model fields (Hex, RGB, HSL, HSB or CSS) + alpha. + if let Some((c, d)) = model_fields(ui, nc) { + nc = c; + hsb = Hsb::from_color(nc); + changed = true; + done |= d; + } + ui.data_mut(|d| d.insert_temp(Id::new("cp_hsb"), (hsb.h, hsb.s, hsb.b, nc.to_rgba8()))); + // A Display P3 colour sRGB can't show: the canvas (sRGB) shows the nearest one. + if app.session.doc().is_ok_and(|d| d.color_profile.conversion_to(figcraft_color::ColorProfile::Srgb).clips(nc)) { + gamut_warning(ui, &t); + } + if changed { + match &mut paint.kind { + PaintKind::Solid { color } => { + *color = nc.with_alpha(1.0); + paint.opacity = nc.a; + } + PaintKind::Gradient(g) => { + let sel: usize = ui.data(|d| d.get_temp(sel_stop_id)).unwrap_or(0); + if let Some(s) = g.stops.get_mut(sel) { + s.color = nc; } } - }); + PaintKind::Image(_) | PaintKind::Pattern(_) => {} + } + write(app, &target, &paint, !done); } - ui.add_space(12.0); - }); - if !open || close_now || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + if done { + finish(app); + } + } + // Colours used on this page. + let doc_colors = page_colors(app, ui); + if !doc_colors.is_empty() { + ui.horizontal(|ui| { + ui.add_space(16.0); + widgets::label(ui, tr("On this page"), t.text_secondary); + }); + // Inset on both sides: `add_space` would only indent the first row of a wrap. + inset(ui, |ui| { + for c in &doc_colors { + if widgets::swatch(ui, &Paint::solid(*c), 14.0).on_hover_text(c.to_hex()).clicked() { + set_color(app, &target, &mut paint, *c, sel_stop_id, ui); + } + } + }); + } + // Recent colours. + let recent = app.session.recent_colors.clone(); + if !recent.is_empty() { + ui.horizontal(|ui| { + ui.add_space(16.0); + widgets::label(ui, tr("Recent"), t.text_secondary); + }); + inset(ui, |ui| { + for c in recent.iter().take(14) { + if widgets::swatch(ui, &Paint::solid(*c), 14.0).clicked() { + set_color(app, &target, &mut paint, *c, sel_stop_id, ui); + } + } + }); + } + ui.add_space(12.0); + }); + if closed { finish(app); app.ui.picker = None; + app.ui.picker_popover = None; + } else { + app.ui.picker_popover = Some(pop); } - let _ = Pos2::ZERO; } /// Swatches wrapped in rows inset 16 points from both sides of the picker. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fdb209e3..84c85a80 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -488,6 +488,8 @@ pub struct UiState { /// The prototype preview pane floating over the canvas, when open. pub preview: Option, pub picker: Option, + /// The colour picker's popover while it is open (its place survives a drag). + pub picker_popover: Option, pub context_menu: Option<(egui::Pos2, Vec)>, /// The context menu was asked for from the keyboard (`view.contextMenu`): it opens at the /// focused layer row or the canvas selection and takes the keyboard. @@ -529,6 +531,8 @@ pub struct UiState { pub color_profile: Option, /// The open style picker. pub style_picker: Option, + /// The style picker's popover while it is open (its place survives a drag). + pub style_picker_popover: Option, /// The style picker shows colour styles as a grid of swatches. pub style_grid: bool, /// Style folders closed in the style picker. @@ -630,6 +634,7 @@ impl Default for UiState { present_view: present::PresentView::default(), preview: None, picker: None, + picker_popover: None, context_menu: None, key_menu: false, key_box: None, @@ -655,6 +660,7 @@ impl Default for UiState { pdf_print: figcraft_doc::PrintSettings::default(), color_profile: None, style_picker: None, + style_picker_popover: None, style_grid: false, style_folds: BTreeSet::new(), text_preview: None, diff --git a/crates/ui-egui/src/popover.rs b/crates/ui-egui/src/popover.rs index b6a56d76..97652c39 100644 --- a/crates/ui-egui/src/popover.rs +++ b/crates/ui-egui/src/popover.rs @@ -1,21 +1,27 @@ -//! The chrome every detached panel shares: a 15rem-wide card with a 40-point header — the title 16 -//! in from its left edge, the X 8 in from its right — a hairline under the header and the -//! three-layer shadow of the design spec, around one slot. +//! The chrome every detached panel shares: a card with a header — the title 16 points in from its +//! left edge, a 24-point X 8 in from its right — a hairline under the header and the theme's popup +//! shadow, around one slot. //! -//! It opens against an anchor: its right edge on the anchor's x (the left edge of the right-hand -//! panel), its top on the anchor's y (the top of the button that opened it) — lifted, when that -//! would run its bottom past the screen's bottom, so its bottom keeps [`SCREEN_GAP`] from it. The -//! header drags it anywhere else, and a caller can put it down outright with [`Popover::at`]. +//! The measurements are our own: they are the header the colour picker has always drawn, and the +//! card is as wide as it. The shadow comes from the theme, so the dark theme gets its own. Nothing +//! here is taken from another product's interface. +//! +//! It opens against an anchor (its right edge on it, its top on it, lifted so its bottom keeps off +//! the screen's bottom) or at a point, the header drags it, and it closes itself on the X, on +//! Escape and on a press outside it — except in the rects the caller holds out (the button that +//! opened it, a popover it opened in turn), so that button's own click can toggle it shut instead +//! of the press closing it and the release opening it again. use egui::epaint::RectShape; -use egui::{Align2, Color32, Context, CornerRadius, Id, Pos2, Rect, Sense, Shadow, Ui, Vec2, pos2, vec2}; +use egui::{Align2, Color32, Context, CornerRadius, Id, Pos2, Rect, Sense, Ui, Vec2, pos2, vec2}; +use crate::a11y::Tip; use crate::i18n::tr; -use crate::icons::Icon; +use crate::icons::{self, Icon}; use crate::theme::{Tokens, semibold}; use crate::widgets; -/// 15rem: the width of every popover. +/// The default width: the colour picker's, and the one every detached panel shares. pub const WIDTH: f32 = 240.0; /// The header's height: the title's line and the X sit in it. pub const HEADER_H: f32 = 40.0; @@ -24,7 +30,7 @@ const TITLE_LEFT: f32 = 16.0; const CLOSE_RIGHT: f32 = 8.0; /// The X's button (the icon is inset in it). const CLOSE: f32 = 24.0; -/// The slot's margin inside the panel. +/// The slot's default margin inside the panel. const SLOT_PAD: f32 = 16.0; /// A lifted popover keeps this much screen under it; it stays [`EDGE`] inside the others. const SCREEN_GAP: f32 = 16.0; @@ -32,17 +38,37 @@ const EDGE: f32 = 8.0; /// The panel's corners. const RADIUS: u8 = 13; -/// The spec's three shadows — `0 2px 5px rgba(0,0,0,.15)`, `0 10px 16px rgba(0,0,0,.12)`, the -/// `0 0 .5px rgba(0,0,0,.12)` hairline — painted in reverse: the first of them ends up on top. -const SHADOW_NEAR: Shadow = Shadow { offset: [0, 2], blur: 5, spread: 0, color: Color32::from_black_alpha(38) }; -const SHADOW_FAR: Shadow = Shadow { offset: [0, 10], blur: 16, spread: 0, color: Color32::from_black_alpha(31) }; -const SHADOW_EDGE: Shadow = Shadow { offset: [0, 0], blur: 1, spread: 0, color: Color32::from_black_alpha(31) }; +/// The surface a popover draws on: the panels' own, or the dark one the style picker uses. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Surface { + Panel, + Menu, +} + +/// Where a popover opens when the caller has not put it down itself. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Open { + /// Its right edge on `x` (the right panel's left edge), its top on `y` (the button's top), + /// lifted when that would run its bottom past the screen's bottom. + Anchor(Pos2), + /// Its top-left exactly here. + At(Pos2), +} + +/// What keeps a popover open while a press or Escape happens somewhere else. +#[derive(Clone, Copy, Default)] +pub struct Hold<'a> { + /// Presses in these rects do not close it: the button that opened it, the popovers it opened. + pub rects: &'a [Rect], + /// Escape belongs to something above it (a field being typed in, a picker of its own). + pub escape: bool, +} /// Where the popover shows. #[derive(Clone, Copy, Debug, PartialEq)] enum Place { - /// Against the anchor, until the header drags it (the rule on opening). - Anchor, + /// From [`Open`], until the header drags it. + Auto, /// Here: put down by the caller or by the drag. At(Pos2), } @@ -52,10 +78,15 @@ pub struct Popover { id: Id, /// The header's line. pub title: String, + width: f32, + pad: f32, + surface: Surface, + /// Whether a press outside it closes it. + outside: bool, place: Place, /// While the header is held down: where in the panel the pointer grabbed it. grab: Option, - /// The panel, its header, its slot and the X, as drawn last frame (0-sized until it has been). + /// The panel, its header, its slot and the X, as drawn last frame. pub rect: Rect, pub header: Rect, pub slot: Rect, @@ -65,12 +96,16 @@ pub struct Popover { } impl Popover { - /// A popover titled `title`, opening against an anchor (see [`Self::show`]). - pub fn new(id: &'static str, title: impl Into) -> Self { + /// A popover titled `title`; `id` names its egui area, so it must be stable and unique. + pub fn new(id: impl std::hash::Hash + std::fmt::Debug, title: impl Into) -> Self { Self { id: Id::new(id), title: title.into(), - place: Place::Anchor, + width: WIDTH, + pad: SLOT_PAD, + surface: Surface::Panel, + outside: true, + place: Place::Auto, grab: None, rect: Rect::NOTHING, header: Rect::NOTHING, @@ -80,48 +115,77 @@ impl Popover { } } - /// Its top-left at `pos`, instead of against the anchor. + /// `width` points wide instead of [`WIDTH`]. + pub fn width(mut self, width: f32) -> Self { + self.width = width.max(1.0); + self.size.x = self.width; + self + } + + /// `pad` points of margin around the slot instead of the default 16. + pub fn pad(mut self, pad: f32) -> Self { + self.pad = pad.max(0.0); + self + } + + /// Drawn on the dark surface the style browser uses. + pub fn surface(mut self, surface: Surface) -> Self { + self.surface = surface; + self + } + + /// Whether a press outside it closes it (the default). The colour picker stays open, so a + /// press on a row beside it keeps editing the paint it is on. + pub fn close_on_outside(mut self, yes: bool) -> Self { + self.outside = yes; + self + } + + /// Its top-left at `pos`, instead of where [`Open`] would put it. pub fn at(mut self, pos: Pos2) -> Self { self.place = Place::At(pos); self } - /// Draws the panel and fills its slot with `body`; true when the X was clicked. - /// - /// Opened against `anchor` — its right edge on `anchor.x` (the right panel's left edge), its top - /// on `anchor.y` (the top of the button that opened it) — it is lifted when that would run its - /// bottom past the screen's bottom, so its bottom keeps [`SCREEN_GAP`] from it. The header - /// drags it; [`Self::at`] and the drag both overrule the anchor. - pub fn show(&mut self, ctx: &Context, anchor: Pos2, body: impl FnOnce(&mut Ui)) -> bool { + /// Draws the panel and fills its slot with `body`; true when it asked to close — the X, + /// Escape or a press outside it (outside [`Hold`]'s rects) on this frame. + pub fn show(&mut self, ctx: &Context, open: Open, hold: Hold<'_>, body: impl FnOnce(&mut Ui)) -> bool { let t = Tokens::get(ctx); + let dark = self.surface == Surface::Menu; let screen = ctx.content_rect(); - // The rule needs the panel's height, which only a layout gives: the first frame lays it - // out invisibly and the next one places it — a jump would show. + // The anchor rule needs the panel's height, which only a layout gives: the first frame + // lays it out invisibly and the next one places it — a jump would show. let first = self.size.y <= 0.0; let pos = match self.place { Place::At(p) => clamp(p, self.size, screen), - Place::Anchor => clamp(open_pos(anchor, self.size, screen), self.size, screen), + Place::Auto => { + let p = match open { + Open::At(p) => p, + Open::Anchor(a) => open_pos(a, self.size, screen), + }; + clamp(p, self.size, screen) + } }; - let mut closed = false; + let mut close = false; egui::Area::new(self.id).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| { if first { ui.set_invisible(); ctx.request_repaint(); } - // The panel is painted into a place kept before the content: the shadows and the fill + // The panel is painted into a place kept before the content: the shadow and the fill // go under it, though its size is only known once the slot is laid out. let bg = ui.painter().add(egui::Shape::Noop); let spacing = ui.spacing().item_spacing; ui.spacing_mut().item_spacing = Vec2::ZERO; // 1. The header: the title, then the X at the right. - let (header, _) = ui.allocate_exact_size(vec2(WIDTH, HEADER_H), Sense::hover()); - let close = Rect::from_min_size(pos2(header.max.x - CLOSE_RIGHT - CLOSE, header.center().y - CLOSE / 2.0), Vec2::splat(CLOSE)); - let handle = Rect::from_min_max(header.min, pos2(close.min.x - 6.0, header.max.y)); + let (header, _) = ui.allocate_exact_size(vec2(self.width, HEADER_H), Sense::hover()); + let close_rect = Rect::from_min_size(pos2(header.max.x - CLOSE_RIGHT - CLOSE, header.center().y - CLOSE / 2.0), Vec2::splat(CLOSE)); + let handle = Rect::from_min_max(header.min, pos2(close_rect.min.x - 6.0, header.max.y)); let drag = ui.interact(handle, self.id.with("drag"), Sense::drag()); - let font = semibold(11.5); + let font = semibold(11.0); let title = widgets::elide_font(ui, &self.title, (handle.width() - TITLE_LEFT - 8.0).max(0.0), font.clone()); - ui.painter().text(pos2(header.min.x + TITLE_LEFT, header.center().y), Align2::LEFT_CENTER, title, font, t.text); - closed = widgets::icon_button_at(ui, self.id.with("close"), close, Icon::Close, tr("Close")); + ui.painter().text(pos2(header.min.x + TITLE_LEFT, header.center().y), Align2::LEFT_CENTER, title, font, header_text(t, dark)); + close = close_button(ui, self.id.with("close"), close_rect, dark, t); // 2. The header drags the panel (grabbed where the press landed, so the first frame of // a fast drag counts too); the X keeps its own clicks. if drag.hovered() && ctx.input(|i| i.pointer.primary_pressed()) { @@ -139,35 +203,62 @@ impl Popover { ui.ctx().set_cursor_icon(if drag.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }); } // 3. The slot, under a hairline. - let frame = egui::Frame::NONE.inner_margin(egui::Margin::same(SLOT_PAD as i8)).show(ui, |ui| { + let frame = egui::Frame::NONE.inner_margin(egui::Margin::same(self.pad as i8)).show(ui, |ui| { ui.spacing_mut().item_spacing = spacing; - ui.set_width(WIDTH - 2.0 * SLOT_PAD); + ui.set_width(self.width - 2.0 * self.pad); body(ui); ui.min_rect() }); let slot = frame.inner; - let panel = Rect::from_min_size(header.min, vec2(WIDTH, frame.response.rect.max.y - header.min.y)); - let shade = |s: Shadow| egui::Shape::from(s.as_shape(panel, RADIUS)); - // `inset 0 -1px #E6E6E6` under the header: the theme's border colour, a point thick. - let hairline = Rect::from_min_max(pos2(panel.min.x, header.max.y - 1.0), pos2(panel.max.x, header.max.y)); + let panel = Rect::from_min_size(header.min, vec2(self.width, frame.response.rect.max.y - header.min.y)); + let shadow = ctx.global_style().visuals.popup_shadow; + let fill = if dark { t.menu } else { t.panel }; let shapes = vec![ - shade(SHADOW_EDGE), - shade(SHADOW_FAR), - shade(SHADOW_NEAR), - RectShape::filled(panel, CornerRadius::same(RADIUS), t.panel).into(), - RectShape::filled(hairline, CornerRadius::ZERO, t.border).into(), + egui::Shape::from(shadow.as_shape(panel, RADIUS)), + RectShape::filled(panel, CornerRadius::same(RADIUS), fill).into(), + // A point of the theme's border colour under the header. + RectShape::filled( + Rect::from_min_max(pos2(panel.min.x, header.max.y - 1.0), pos2(panel.max.x, header.max.y)), + CornerRadius::ZERO, + if dark { Color32::from_white_alpha(24) } else { t.border }, + ) + .into(), ]; ui.painter().set(bg, egui::Shape::Vec(shapes)); self.rect = panel; self.header = header; self.slot = slot; - self.close = close; + self.close = close_rect; self.size = panel.size(); }); - closed + // Closing is the widget's: the X above, Escape and a press outside (a child popup or a + // held-out rect keeps it open, so the button that opened it can click it shut). + let popup_open = egui::Popup::is_any_open(ctx); + let escape = !hold.escape && !popup_open && ctx.input(|i| i.key_pressed(egui::Key::Escape)); + let outside = self.outside + && !popup_open + && ctx.input(|i| { + i.pointer.any_pressed() + && i.pointer.interact_pos().is_some_and(|at| !self.rect.contains(at) && !hold.rects.iter().any(|r| r.contains(at))) + }); + close || escape || outside } } +fn header_text(t: Tokens, dark: bool) -> Color32 { + if dark { t.menu_text } else { t.text } +} + +/// The X: a 24-point button, its icon inset 6 (the colour picker's header). +fn close_button(ui: &mut Ui, id: Id, rect: Rect, dark: bool, t: Tokens) -> bool { + let resp = ui.interact(rect, id, Sense::click()).tip(tr("Close")); + if resp.hovered() { + ui.painter().rect_filled(rect, CornerRadius::same(5), if dark { Color32::from_white_alpha(20) } else { t.hover }); + } + icons::paint(ui.painter(), rect.shrink(6.0), Icon::Close, if dark { t.menu_text } else { t.icon }); + resp.clicked() +} + /// The opening position: the right edge on `anchor.x` (the right panel's left edge), the top on /// `anchor.y` (the button's top) — lifted, when that runs its bottom past the screen's bottom, to /// keep [`SCREEN_GAP`] under it. diff --git a/crates/ui-egui/src/popover_tests.rs b/crates/ui-egui/src/popover_tests.rs index 80d067d2..713e8d39 100644 --- a/crates/ui-egui/src/popover_tests.rs +++ b/crates/ui-egui/src/popover_tests.rs @@ -1,11 +1,12 @@ -//! The generic popover drawn on its own: 15rem wide, a 40-high header with the title 16 in from -//! the left and the X 8 in from the right, the slot under a hairline, the opening rule against an -//! anchor (right edge on it, top aligned, lifted when that would run off the screen) and the -//! header dragging the panel. +//! The shared popover drawn on its own: 240 points wide, a 40-high header with the title 16 in +//! from the left and the X 8 in from the right, the slot under a hairline, the opening rule +//! against an anchor (right edge on it, top aligned, lifted when that would run off the screen), +//! the header dragging it, and closing on the X, Escape and a press outside — but not on a press +//! in a held-out rect, so the button that opened it can toggle it shut. -use egui::{Event, PointerButton, Pos2, RawInput, Rect, pos2, vec2}; +use egui::{Event, Key, PointerButton, Pos2, RawInput, Rect, pos2, vec2}; -use crate::popover::{HEADER_H, Popover, WIDTH}; +use crate::popover::{HEADER_H, Hold, Open, Popover, WIDTH}; const SCREEN: egui::Vec2 = vec2(1440.0, 900.0); @@ -25,12 +26,16 @@ fn press(p: Pos2, pressed: bool) -> Vec { vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }] } -/// One frame of `p` against `anchor`, its slot holding a row: the shapes it painted and whether -/// the X was clicked. -fn draw(ctx: &egui::Context, t: &mut f64, p: &mut Popover, anchor: Pos2, events: Vec) -> (Vec, bool) { +fn escape() -> Vec { + vec![Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }] +} + +/// One frame of `p` opened at `open`, its slot holding a row: the shapes it painted and whether it +/// asked to close. +fn draw(ctx: &egui::Context, t: &mut f64, p: &mut Popover, open: Open, hold: Hold<'_>, events: Vec) -> (Vec, bool) { let mut closed = false; let mut out = ctx.run_ui(input(t, events), |ui| { - closed = p.show(ui.ctx(), anchor, |ui| { + closed = p.show(ui.ctx(), open, hold, |ui| { ui.label("slot"); }); }); @@ -39,9 +44,9 @@ fn draw(ctx: &egui::Context, t: &mut f64, p: &mut Popover, anchor: Pos2, events: } /// Two frames: the first lays the panel out to learn its height, the second places and paints it. -fn settle(ctx: &egui::Context, t: &mut f64, p: &mut Popover, anchor: Pos2) -> Vec { - draw(ctx, t, p, anchor, vec![]); - draw(ctx, t, p, anchor, vec![]).0 +fn settle(ctx: &egui::Context, t: &mut f64, p: &mut Popover, open: Open) -> Vec { + draw(ctx, t, p, open, Hold::default(), vec![]); + draw(ctx, t, p, open, Hold::default(), vec![]).0 } fn near(a: Pos2, b: Pos2) -> bool { @@ -52,17 +57,16 @@ fn near(a: Pos2, b: Pos2) -> bool { fn the_header_is_40_high_with_the_title_16_in_and_the_x_8_in() { let ctx = context(); let mut t = 0.0; - let mut p = Popover::new("popover_header", "Fill rule"); - let shapes = settle(&ctx, &mut t, &mut p, pos2(400.0, 100.0)); - // 15rem wide, its top-left where the rule puts it. - assert_eq!(p.rect.width(), WIDTH, "15rem wide"); - assert!(near(p.rect.min, pos2(400.0 - WIDTH, 100.0)), "right edge on the anchor, top on it: {:?}", p.rect); + let mut p = Popover::new("popover_header", "Custom"); + let shapes = settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0))); + assert_eq!(p.rect.width(), WIDTH, "240 points wide"); + assert!(near(p.rect.min, pos2(160.0, 100.0)), "where it was put: {:?}", p.rect); assert_eq!(p.header.height(), HEADER_H, "the header is 40 high"); // The X: 24 square, its right edge 8 in from the panel's, centred on the header's line. assert!((p.close.max.x - (p.rect.max.x - 8.0)).abs() < 0.01, "the X is 8 in from the right: {:?}", p.close); assert!((p.close.center().y - p.header.center().y).abs() < 0.01, "the X is on the header's line"); // The title: one line, 16 in from the left, on the header's middle. - let (_, at, size) = crate::live_tests::texts(&shapes).into_iter().find(|(s, _, _)| s == "Fill rule").expect("the title shows"); + let (_, at, size) = crate::live_tests::texts(&shapes).into_iter().find(|(s, _, _)| s == "Custom").expect("the title shows"); assert!((at.x - (p.rect.min.x + 16.0)).abs() < 0.5, "the title starts 16 in: {at:?}"); assert!((at.y + size.y / 2.0 - p.header.center().y).abs() < 1.0, "the title is on the header's line: {at:?}"); // The slot: 16 in from the panel's sides, under the header. @@ -75,7 +79,7 @@ fn it_opens_with_its_right_edge_on_the_anchor_and_its_top_aligned() { let ctx = context(); let mut t = 0.0; let mut p = Popover::new("popover_anchor", "Popup"); - settle(&ctx, &mut t, &mut p, pos2(900.0, 200.0)); + settle(&ctx, &mut t, &mut p, Open::Anchor(pos2(900.0, 200.0))); assert!((p.rect.max.x - 900.0).abs() < 0.01, "its right edge on the anchor: {:?}", p.rect); assert!((p.rect.min.y - 200.0).abs() < 0.01, "its top on the anchor: {:?}", p.rect); } @@ -85,7 +89,7 @@ fn it_lifts_so_its_bottom_keeps_16_from_the_screens_bottom() { let ctx = context(); let mut t = 0.0; let mut p = Popover::new("popover_lift", "Popup"); - settle(&ctx, &mut t, &mut p, pos2(900.0, 860.0)); + settle(&ctx, &mut t, &mut p, Open::Anchor(pos2(900.0, 860.0))); // Aligning the top would run it off: it rises so its bottom sits 16 above the screen's. assert!((p.rect.max.y - (SCREEN.y - 16.0)).abs() < 0.01, "its bottom is 16 above the screen's: {:?}", p.rect); assert!(p.rect.min.y < 860.0, "it was lifted: {:?}", p.rect); @@ -96,49 +100,76 @@ fn the_header_drags_it_and_the_slot_does_not() { let ctx = context(); let mut t = 0.0; let mut p = Popover::new("popover_drag", "Popup"); - settle(&ctx, &mut t, &mut p, pos2(400.0, 100.0)); + settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0))); + let at = Open::At(pos2(160.0, 100.0)); let (from, to) = (pos2(200.0, 120.0), pos2(250.0, 180.0)); let before = p.rect.min; - draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), press(from, true)); - draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), vec![Event::PointerMoved(to)]); - draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), vec![]); - draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), press(to, false)); - draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), vec![]); + draw(&ctx, &mut t, &mut p, at, Hold::default(), press(from, true)); + draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![Event::PointerMoved(to)]); + draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]); + draw(&ctx, &mut t, &mut p, at, Hold::default(), press(to, false)); + draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]); assert!(near(p.rect.min, before + (to - from)), "the header dragged it by the pointer's move: {:?} from {before:?}", p.rect.min); // The slot does not drag it. let (from, to) = (p.slot.center(), p.slot.center() + vec2(-70.0, -40.0)); let held = p.rect.min; - draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), press(from, true)); - draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), vec![Event::PointerMoved(to)]); - draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), press(to, false)); - draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), vec![]); + draw(&ctx, &mut t, &mut p, at, Hold::default(), press(from, true)); + draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![Event::PointerMoved(to)]); + draw(&ctx, &mut t, &mut p, at, Hold::default(), press(to, false)); + draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]); assert!(near(p.rect.min, held), "a press in the slot leaves it: {:?} from {held:?}", p.rect.min); } #[test] -fn the_x_closes_it() { +fn the_x_and_escape_close_it() { let ctx = context(); let mut t = 0.0; let mut p = Popover::new("popover_close", "Popup"); - settle(&ctx, &mut t, &mut p, pos2(400.0, 100.0)); - let at = p.close.center(); - assert!(!draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), press(at, true)).1, "a press is not a click yet"); - assert!(draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), press(at, false)).1, "the X asks for the close"); + settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0))); + let at = Open::At(pos2(160.0, 100.0)); + let close = p.close.center(); + assert!(!draw(&ctx, &mut t, &mut p, at, Hold::default(), press(close, true)).1, "a press is not a click yet"); + assert!(draw(&ctx, &mut t, &mut p, at, Hold::default(), press(close, false)).1, "the X asks for the close"); + assert!(draw(&ctx, &mut t, &mut p, at, Hold::default(), escape()).1, "Escape asks for the close"); + // Escape that belongs to something above it does not. + let mut p = Popover::new("popover_escape_held", "Popup"); + settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0))); + let hold = Hold { rects: &[], escape: true }; + assert!(!draw(&ctx, &mut t, &mut p, at, hold, escape()).1, "a held Escape leaves it open"); +} + +#[test] +fn a_press_outside_closes_it_but_not_on_the_button_that_opened_it() { + let ctx = context(); + let mut t = 0.0; + let mut p = Popover::new("popover_outside", "Popup"); + settle(&ctx, &mut t, &mut p, Open::At(pos2(300.0, 200.0))); + let at = Open::At(pos2(300.0, 200.0)); + // The button that opened it: a press there leaves it open, so its release can toggle it shut. + let opener = Rect::from_min_size(pos2(60.0, 60.0), vec2(24.0, 24.0)); + let hold = Hold { rects: &[opener], escape: false }; + let on_opener = opener.center(); + assert!(!draw(&ctx, &mut t, &mut p, at, hold, press(on_opener, true)).1, "a press on the opener leaves it open"); + assert!(!draw(&ctx, &mut t, &mut p, at, hold, press(on_opener, false)).1, "and so does its release"); + // A press anywhere else closes it, before the release. + let elsewhere = pos2(900.0, 400.0); + assert!(draw(&ctx, &mut t, &mut p, at, hold, press(elsewhere, true)).1, "a press outside closes it"); } #[test] fn a_caller_can_put_it_where_it_likes() { let ctx = context(); let mut t = 0.0; - // Placed outright, the anchor no longer matters. + // Placed outright, the opening rule no longer matters. let mut p = Popover::new("popover_at", "Popup").at(pos2(300.0, 250.0)); - settle(&ctx, &mut t, &mut p, pos2(900.0, 100.0)); + settle(&ctx, &mut t, &mut p, Open::At(pos2(900.0, 100.0))); assert!(near(p.rect.min, pos2(300.0, 250.0)), "where the caller put it: {:?}", p.rect); // Nor after a drag: it stays where it was dropped. + let at = Open::At(pos2(900.0, 100.0)); let (from, to) = (pos2(340.0, 270.0), pos2(400.0, 320.0)); - draw(&ctx, &mut t, &mut p, pos2(900.0, 100.0), press(from, true)); - draw(&ctx, &mut t, &mut p, pos2(900.0, 100.0), vec![Event::PointerMoved(to)]); - draw(&ctx, &mut t, &mut p, pos2(900.0, 100.0), press(to, false)); - draw(&ctx, &mut t, &mut p, pos2(900.0, 100.0), vec![]); + draw(&ctx, &mut t, &mut p, at, Hold::default(), press(from, true)); + draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![Event::PointerMoved(to)]); + draw(&ctx, &mut t, &mut p, at, Hold::default(), press(to, false)); + draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]); assert!(near(p.rect.min, pos2(360.0, 300.0)), "dropped where the drag left it: {:?}", p.rect); } diff --git a/crates/ui-egui/src/style_picker.rs b/crates/ui-egui/src/style_picker.rs index 966ea482..18a253cf 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -19,11 +19,13 @@ use crate::a11y::Tip; use crate::colorpicker::PickerTarget; use crate::i18n::{tr, trf}; use crate::icons::{self, Icon}; +use crate::popover::{Hold, Open, Popover, Surface}; use crate::theme::{Tokens, regular, semibold}; use crate::widgets; -/// Content width of the popover. -const W: f32 = 240.0; +/// The slot's margin inside the shared popover, and the content width left of it. +const PAD: f32 = 8.0; +const W: f32 = 224.0; /// Height of a list row. const ROW_H: f32 = 26.0; /// Height of a text style's list row (its name in its own font). @@ -516,9 +518,8 @@ fn listing(app: &FigApp, ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut let link = link_of(app, doc, &p.ids, slot); let applied = link.clone().flatten().map(|id| Key::of_local(doc, &id)); let grid = slot.has_grid() && app.ui.style_grid; - // Header: title, then view toggle, detach and create. + // The title is the popover's; this row keeps the view toggle, detach and create. let (hr, _) = ui.allocate_exact_size(vec2(W, 24.0), Sense::hover()); - ui.painter().text(pos2(hr.min.x + 2.0, hr.center().y), Align2::LEFT_CENTER, slot.title(), semibold(11.0), t.menu_text); let mut x = hr.max.x - 24.0; let mut slot_rect = || { let r = Rect::from_min_size(pos2(x, hr.min.y), vec2(24.0, 24.0)); @@ -777,7 +778,6 @@ fn text_properties(ui: &mut Ui, id: &str, def: &TextStyle, out: &mut Frame) { /// Editing one style: name, description, its values (paints open the colour picker; text styles /// have their typography), update from the selection, delete. fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut Frame) { - let t = Tokens::get(ui.ctx()); let slot = p.slot; let has_selection = !p.ids.is_empty(); let Some(e) = p.edit.as_mut() else { return }; @@ -789,7 +789,6 @@ fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut Frame) { if menu_icon(ui, Rect::from_min_size(hr.min, vec2(24.0, 24.0)), Icon::ChevronLeft, tr("Back to styles"), false).clicked() { out.act = Some(Act::Back); } - ui.painter().text(pos2(hr.min.x + 30.0, hr.center().y), Align2::LEFT_CENTER, tr("Edit style"), semibold(11.0), t.menu_text); ui.add_space(6.0); caption(ui, tr("Name")); let r = ui.add(egui::TextEdit::singleline(&mut e.name).font(regular(11.0)).desired_width(W)); @@ -880,7 +879,6 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { } return; } - let t = Tokens::get(ctx); let screen = ctx.content_rect(); // Left of the Design panel; further left while the colour picker (which sits there) is open. let mut x = screen.max.x - app.ui.right_width - W - 32.0; @@ -889,34 +887,24 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { } let y = p.y.clamp(screen.min.y + 48.0, (screen.max.y - 460.0).max(screen.min.y + 48.0)); let mut out = Frame { act: None, hover: None, budget: 4 }; - let area = egui::Area::new(Id::new("style_picker_area")).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show(ctx, |ui| { - egui::Frame::NONE.fill(t.menu).corner_radius(CornerRadius::same(10)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { - ui.set_width(W); - if p.edit.is_some() { - editor(ui, &doc, &mut p, &mut out); - } else { - listing(app, ui, &doc, &mut p, &mut out); - } - }); - }); - let rect = area.response.rect; - ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("style_picker_border"))).rect_stroke( - rect, - CornerRadius::same(10), - Stroke::new(1.0, Color32::from_black_alpha(40)), - egui::StrokeKind::Inside, - ); - // Clicks in the colour picker editing this picker's style, or in a menu the editor opened, - // keep it open. + // The title and the X are the shared popover's now; the list keeps its tools row. + let title = if p.edit.is_some() { tr("Edit style") } else { p.slot.title() }; + let mut pop = app.ui.style_picker_popover.take().unwrap_or_else(|| Popover::new("style_picker_area", title).pad(PAD).surface(Surface::Menu)); + pop.title = title.to_string(); + // Clicks in the colour picker editing this picker's style, in the variable picker binding one + // of its fields, or in a menu the editor opened, keep it open. let picker_rect = app.ui.picker.as_ref().filter(|t| matches!(t, PickerTarget::Style { .. })).and(ctx.memory(|m| m.area_rect(Id::new("color_picker")))); - // So do clicks in the variable picker binding one of its fields (Escape closes that first). let variables = crate::variable_ui::picker_rect(ctx); - let in_popup = egui::Popup::is_any_open(ctx) || variables.is_some(); - let outside = ctx.input(|i| { - i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|pos| !rect.contains(pos) && !picker_rect.is_some_and(|r| r.contains(pos))) - }) && !in_popup; - let mut close = outside || (ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !in_popup); + let holds: Vec = picker_rect.into_iter().chain(variables).collect(); + let closed = pop.show(ctx, Open::At(pos2(x, y)), Hold { rects: &holds, escape: variables.is_some() }, |ui| { + if p.edit.is_some() { + editor(ui, &doc, &mut p, &mut out); + } else { + listing(app, ui, &doc, &mut p, &mut out); + } + }); + let mut close = closed; let ids = p.ids.clone(); let slot = p.slot; // The canvas shows the text style under the pointer on the selected text. @@ -989,8 +977,10 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { if matches!(app.ui.picker, Some(PickerTarget::Style { .. })) { app.ui.picker = None; } + app.ui.style_picker_popover = None; } else { app.ui.style_picker = Some(p); + app.ui.style_picker_popover = Some(pop); } }