M4: the popover every detached panel shares

`Popover` is the one card detached panels draw on: a header 40 points high
with the title 16 in from its left edge and a 24-point X 8 in from its right,
a hairline under it, the theme's popup shadow, and one slot. Its measurements
are our own — the colour picker's header, which it now uses — not another
product's.

The colour picker and the style picker move onto it in this commit; each keeps
only its own body. The style picker keeps its dark surface through
`Surface::Menu`, and its tools row (view, detach, create) now starts the slot,
under the shared title.

Closing is the widget's now: the X, Escape, and 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 (the bug #730 has). The colour
picker keeps its old habit of staying open while the panel beside it is used.

Tests: press-and-release for the X, for a press on the opener (open) and for a
press elsewhere (closed), plus Escape, the opening rule, the lift and the drag.
This commit is contained in:
叶林橦
2026-10-20 11:57:49 +08:00
parent e9a0c437e2
commit 345a07fb8f
5 changed files with 603 additions and 496 deletions
+357 -368
View File
@@ -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<Color> = 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<Color> = 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.
+6
View File
@@ -488,6 +488,8 @@ pub struct UiState {
/// The prototype preview pane floating over the canvas, when open.
pub preview: Option<present::Pane>,
pub picker: Option<colorpicker::PickerTarget>,
/// The colour picker's popover while it is open (its place survives a drag).
pub picker_popover: Option<popover::Popover>,
pub context_menu: Option<(egui::Pos2, Vec<NodeId>)>,
/// 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<figcraft_color::ColorProfile>,
/// The open style picker.
pub style_picker: Option<style_picker::Picker>,
/// The style picker's popover while it is open (its place survives a drag).
pub style_picker_popover: Option<popover::Popover>,
/// 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,
+145 -54
View File
@@ -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<Vec2>,
/// 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<String>) -> 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<String>) -> 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.
+73 -42
View File
@@ -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<Event> {
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<Event>) -> (Vec<egui::Shape>, bool) {
fn escape() -> Vec<Event> {
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<Event>) -> (Vec<egui::Shape>, 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<egui::Shape> {
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<egui::Shape> {
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);
}
+22 -32
View File
@@ -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<Rect> = 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);
}
}