mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 13:56:54 -06:00
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:
+357
-368
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user