M3.23: per-item Opacity popups and one grouped blend-mode widget

Clicking an Opacity row in the Appearance panel (a fill's or stroke's, or
the object's) opens a popup with the same blend-mode dropdown, opacity
field and slider as the Transparency panel (widgets::opacity_blend), and
edits that item through transparency.set. The blend-mode dropdown draws a
separator between the BlendMode::group() groups, also for the blend-mode
parameters of effect dialogs and inline effect editors. Fills and strokes
of type objects composite with their own opacity and blend mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-04 17:21:28 +07:00
parent 543d09985d
commit b6b87ee9b6
8 changed files with 235 additions and 67 deletions

View File

@ -231,6 +231,12 @@ impl AppearanceItem {
pub fn kind_name(&self) -> &'static str { pub fn kind_name(&self) -> &'static str {
if self.is_fill() { "fill" } else { "stroke" } if self.is_fill() { "fill" } else { "stroke" }
} }
pub fn paint(&self) -> &Paint {
match self {
AppearanceItem::Fill(f) => &f.paint,
AppearanceItem::Stroke(s) => &s.paint,
}
}
pub fn visible(&self) -> bool { pub fn visible(&self) -> bool {
match self { match self {
AppearanceItem::Fill(f) => f.visible, AppearanceItem::Fill(f) => f.visible,

View File

@ -245,3 +245,25 @@ fn per_fill_offset_path_renders() {
let px = render(&s).pixel(90, 150); let px = render(&s).pixel(90, 150);
assert!(px[0] > 200 && px[3] > 200, "{px:?}"); assert!(px[0] > 200 && px[3] > 200, "{px:?}");
} }
#[test]
fn text_fill_items_composite_with_their_opacity() {
let mut s = session();
let t = NodeId(run(&mut s, "text.create", json!({"x": 20, "y": 200, "text": "HIM", "size": 150}))["id"].as_u64().unwrap());
run(&mut s, "select.set", json!({"ids": [t.0]}));
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "appearance.setItem", json!({"index": 0, "color": "#ff0000"}));
let render = |s: &Session| {
vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default())
};
let opaque = render(&s);
// A pixel the red fill covers fully (over the black character fill).
let (x, y) = (0..400u32).flat_map(|y| (0..400u32).map(move |x| (x, y))).find(|&(x, y)| opaque.pixel(x, y) == [255, 0, 0, 255]).expect("red text");
run(&mut s, "appearance.setItem", json!({"index": 0, "opacity": 0.5, "blend": "multiply"}));
let px = render(&s).pixel(x, y);
// Half of red multiplied over black: black.
assert!(px[0] < 20 && px[3] == 255, "{px:?}");
run(&mut s, "appearance.setItem", json!({"index": 0, "blend": "normal"}));
let px = render(&s).pixel(x, y);
assert!((110..=145).contains(&px[0]) && px[1] < 10, "{px:?}");
}

View File

@ -798,19 +798,28 @@ impl Renderer {
if !n.appearance.items.is_empty() { if !n.appearance.items.is_empty() {
let mut all = g.all.clone(); let mut all = g.all.clone();
all.apply_affine(t.xf); all.apply_affine(t.xf);
for item in &n.appearance.items { for item in n.appearance.items.iter().filter(|i| i.visible() && !i.paint().is_none()) {
// Each item composites with its own opacity and blend mode.
let layered = item.opacity() < 1.0 || item.blend() != vectorcraft_color::BlendMode::Normal;
if layered {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(item.blend())), Some(item.opacity()), None, None);
}
ctx.set_transform(f.view); ctx.set_transform(f.view);
match item { match item {
AppearanceItem::Fill(fl) if fl.visible && paint::set_paint(ctx, &fl.paint, tb, f.doc) => { AppearanceItem::Fill(fl) if paint::set_paint(ctx, &fl.paint, tb, f.doc) => {
ctx.set_fill_rule(peniko::Fill::NonZero); ctx.set_fill_rule(peniko::Fill::NonZero);
ctx.fill_path(&all); ctx.fill_path(&all);
} }
AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 && paint::set_paint(ctx, &st.paint, tb, f.doc) => { AppearanceItem::Stroke(st) if st.width > 0.0 && paint::set_paint(ctx, &st.paint, tb, f.doc) => {
ctx.set_stroke(kurbo::Stroke::new(st.width)); ctx.set_stroke(kurbo::Stroke::new(st.width));
ctx.stroke_path(&all); ctx.stroke_path(&all);
} }
_ => {} _ => {}
} }
if layered {
ctx.pop_layer();
}
} }
} }
} }

View File

@ -59,6 +59,14 @@ pub(super) fn param_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect(); d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect();
for (k, v) in keys { for (k, v) in keys {
ui.label(egui::RichText::new(humanize(&k)).color(t.text)); ui.label(egui::RichText::new(humanize(&k)).color(t.text));
if let Some(cur) = crate::widgets::blend_param(&k, &v) {
if let Some(m) = crate::widgets::blend_param_dropdown(ui, ("fx-blend", &k), cur) {
d.fields.insert(k, m);
changed = true;
}
ui.end_row();
continue;
}
match v { match v {
Value::Number(n) => { Value::Number(n) => {
let mut x = n.as_f64().unwrap_or(0.0); let mut x = n.as_f64().unwrap_or(0.0);

View File

@ -9,9 +9,9 @@ use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeId}; use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeId};
use super::{first_selected, pstate, set_pstate}; use super::{first_selected, live_run, pstate, set_pstate};
use crate::theme::Tokens; use crate::theme::Tokens;
use crate::widgets::{self, menu_item}; use crate::widgets::{self, TransparencyEdit, menu_item};
use crate::{VectorcraftApp, icons}; use crate::{VectorcraftApp, icons};
const ROW: f32 = 30.0; const ROW: f32 = 30.0;
@ -221,11 +221,7 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) {
let open: bool = pstate(ui.ctx(), &format!("ap-open-{i}")); let open: bool = pstate(ui.ctx(), &format!("ap-open-{i}"));
let (r, resp) = row(ui, sel == Sel::Item(i)); let (r, resp) = row(ui, sel == Sel::Item(i));
row_rects.push((i, r)); row_rects.push((i, r));
let visible = it.visible(); let (visible, paint) = (it.visible(), it.paint());
let (paint, width) = match it {
AppearanceItem::Fill(f) => (&f.paint, 0.0),
AppearanceItem::Stroke(s) => (&s.paint, s.width),
};
let is_stroke = !it.is_fill(); let is_stroke = !it.is_fill();
if eye(ui, r, ("ap-eye", i), visible, true) { if eye(ui, r, ("ap-eye", i), visible, true) {
app.run("appearance.setItem", json!({"index": i, "visible": !visible})).ok(); app.run("appearance.setItem", json!({"index": i, "visible": !visible})).ok();
@ -249,10 +245,10 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) {
egui::Popup::menu(&cresp).show(|ui| { egui::Popup::menu(&cresp).show(|ui| {
swatch_picker(app, ui, i); swatch_picker(app, ui, i);
}); });
if is_stroke { if let AppearanceItem::Stroke(st) = it {
let fr = Rect::from_min_size(pos2(r.left() + EYE_W + 104.0, r.center().y - 12.0), vec2(56.0, 24.0)); let fr = Rect::from_min_size(pos2(r.left() + EYE_W + 104.0, r.center().y - 12.0), vec2(56.0, 24.0));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(fr).layout(egui::Layout::left_to_right(egui::Align::Center))); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(fr).layout(egui::Layout::left_to_right(egui::Align::Center)));
if let Some(w) = widgets::num_field(&mut child, ("ap-w", i), Some(width), vectorcraft_doc::Unit::Points, 56.0) { if let Some(w) = widgets::num_field(&mut child, ("ap-w", i), Some(st.width), vectorcraft_doc::Unit::Points, 56.0) {
app.run("appearance.setItem", json!({"index": i, "weight": w})).ok(); app.run("appearance.setItem", json!({"index": i, "weight": w})).ok();
} }
} }
@ -269,7 +265,7 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) {
stopped = true; stopped = true;
} }
if open { if open {
sub_row(app, ui, ("ap-sub-op", i), &format!("Opacity: {}", opacity_text(it.opacity(), it.blend())), true, 1); opacity_row(app, ui, Some(i), it.opacity(), it.blend());
for (k, e) in it.effects().iter().enumerate() { for (k, e) in it.effects().iter().enumerate() {
effect_row(app, ui, Some(i), k, e, sel == Sel::ItemEffect(i, k)); effect_row(app, ui, Some(i), k, e, sel == Sel::ItemEffect(i, k));
} }
@ -297,14 +293,7 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) {
for (k, e) in n.appearance.effects.iter().enumerate() { for (k, e) in n.appearance.effects.iter().enumerate() {
effect_row(app, ui, None, k, e, sel == Sel::Effect(k)); effect_row(app, ui, None, k, e, sel == Sel::Effect(k));
} }
// Object opacity row. opacity_row(app, ui, None, n.opacity, n.blend);
let (r, resp) = row(ui, false);
eye(ui, r, "ap-op-eye", true, false);
let lx = r.left() + EYE_W + 24.0;
if link(ui, pos2(lx, r.center().y), "ap-op-link", "Opacity:") || resp.double_clicked() {
app.ui.open_panel = Some("transparency".into());
}
text(ui, pos2(lx + 56.0, r.center().y), &opacity_text(n.opacity, n.blend), false);
if let Some((from, to_row)) = drop if let Some((from, to_row)) = drop
&& from != to_row && from != to_row
{ {
@ -320,10 +309,24 @@ fn target_index(rows: &[(usize, Rect)], y: f32) -> usize {
}) })
} }
fn sub_row(_app: &mut VectorcraftApp, ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug + Copy, label: &str, visible: bool, depth: usize) { /// The Opacity row of the object (`item: None`) or, indented, of fill/stroke `item`. Clicking it
let (r, _) = row(ui, false); /// opens a popup with the shared opacity and blend controls (`transparency.set` on that item).
eye(ui, r, (id, "eye"), visible, false); fn opacity_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, opacity: f32, blend: BlendMode) {
text(ui, r.left_center() + vec2(EYE_W + 24.0 + depth as f32 * 12.0, 0.0), label, false); let (r, resp) = row(ui, false);
eye(ui, r, ("ap-op-eye", item), true, false);
let lx = r.left() + EYE_W + 24.0 + if item.is_some() { 12.0 } else { 0.0 };
let clicked = link(ui, pos2(lx, r.center().y), ("ap-op-link", item), "Opacity:") || resp.clicked();
text(ui, pos2(lx + 56.0, r.center().y), &opacity_text(opacity, blend), false);
egui::Popup::menu(&resp)
.open_memory(clicked.then_some(egui::SetOpenCommand::Toggle))
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.show(|ui| match widgets::opacity_blend(ui, ("ap-op", item), opacity, blend, true) {
Some(TransparencyEdit::Blend(b)) => {
app.run("transparency.set", json!({"item": item, "blend": b.label()})).ok();
}
Some(TransparencyEdit::Opacity(o, phase)) => live_run(app, "Opacity", "transparency.set", json!({"item": item, "opacity": o}), phase),
None => {}
});
} }
/// An effect row: of the object (`item: None`) or, indented under it, of fill/stroke `item`. /// An effect row: of the object (`item: None`) or, indented under it, of fill/stroke `item`.
@ -379,6 +382,12 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k:
ui.horizontal(|ui| { ui.horizontal(|ui| {
let label = humanize(key); let label = humanize(key);
ui.add_sized(vec2(84.0, 22.0), egui::Label::new(egui::RichText::new(label).size(11.5).color(t.text)).truncate()); ui.add_sized(vec2(84.0, 22.0), egui::Label::new(egui::RichText::new(label).size(11.5).color(t.text)).truncate());
if let Some(cur) = widgets::blend_param(key, v) {
if let Some(m) = widgets::blend_param_dropdown(ui, ("fx-blend", item, k, key.as_str()), cur) {
change = Some((key.clone(), m));
}
return;
}
match v { match v {
Value::Bool(b) => { Value::Bool(b) => {
if widgets::check(ui, "", *b, true) { if widgets::check(ui, "", *b, true) {

View File

@ -126,3 +126,34 @@ fn item_effect_rows_toggle_their_eye() {
fn current_stroke_effects(app: &VectorcraftApp) -> Vec<vectorcraft_doc::Effect> { fn current_stroke_effects(app: &VectorcraftApp) -> Vec<vectorcraft_doc::Effect> {
first_selected(app).unwrap().appearance.items[1].effects().clone() first_selected(app).unwrap().appearance.items[1].effects().clone()
} }
#[test]
fn blend_separators_follow_the_groups() {
use vectorcraft_color::BlendMode;
let starts: Vec<BlendMode> =
(0..BlendMode::ALL.len()).filter(|i| crate::widgets::blend_separator_before(*i)).map(|i| BlendMode::ALL[i]).collect();
assert_eq!(starts, [BlendMode::Darken, BlendMode::Lighten, BlendMode::Overlay, BlendMode::Difference, BlendMode::Hue]);
assert!(starts.iter().all(|m| m.group() > 0) && !crate::widgets::blend_separator_before(BlendMode::ALL.len()));
}
#[test]
fn item_opacity_popup_sets_the_items_opacity() {
let mut app = app_with_rect();
let ctx = egui::Context::default();
super::set_pstate(&ctx, "ap-open-1", true);
let texts = frame_events(&ctx, &mut app, vec![], appearance::show);
// The stroke's Opacity sub-row (listed first; the object's own Opacity row is last).
let link = texts.iter().find(|(t, _)| t == "Opacity:").expect("item opacity row").1;
click(&ctx, &mut app, link.center(), appearance::show);
let texts = frame_events(&ctx, &mut app, vec![], appearance::show);
assert!(texts.iter().any(|(t, _)| t == "Normal"), "the popup shows the blend mode: {texts:?}");
// Type 50 into the popup's opacity field.
let field = text_rect(&texts, "100%");
click(&ctx, &mut app, field.center(), appearance::show);
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
frame_events(&ctx, &mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND), egui::Event::Text("50".into())], appearance::show);
frame_events(&ctx, &mut app, vec![key(egui::Key::Enter, Default::default())], appearance::show);
let n = first_selected(&app).unwrap();
assert_eq!(n.appearance.items[1].opacity(), 0.5);
assert_eq!((n.appearance.items[0].opacity(), n.opacity), (1.0, 1.0));
}

View File

@ -8,7 +8,7 @@ use vectorcraft_color::{BlendMode, Paint};
use super::{current_paints, current_transparency, first_selected, live_run, pstate, selection_len, set_pstate}; use super::{current_paints, current_transparency, first_selected, live_run, pstate, selection_len, set_pstate};
use crate::theme::Tokens; use crate::theme::Tokens;
use crate::widgets::{self, Live, menu_item}; use crate::widgets::{self, TransparencyEdit, menu_item};
use crate::{VectorcraftApp, icons}; use crate::{VectorcraftApp, icons};
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
@ -27,36 +27,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
Some(_) => n.as_ref().map(|n| (n.opacity, n.blend)), Some(_) => n.as_ref().map(|n| (n.opacity, n.blend)),
} }
.unwrap_or((1.0, BlendMode::Normal)); .unwrap_or((1.0, BlendMode::Normal));
ui.horizontal(|ui| { match widgets::opacity_blend(ui, "tr", op, blend, has) {
let labels: Vec<&str> = BlendMode::ALL.iter().map(|b| b.label()).collect(); Some(TransparencyEdit::Blend(b)) => {
ui.add_enabled_ui(has, |ui| { app.run("transparency.set", json!({"blend": b.label()})).ok();
if let Some(i) = widgets::dropdown(ui, "tr-blend", blend.label(), &labels, 104.0) { }
app.run("transparency.set", json!({"blend": labels[i]})).ok(); Some(TransparencyEdit::Opacity(o, phase)) => live_run(app, "Opacity", "transparency.set", json!({ "opacity": o }), phase),
} None => {}
}); }
widgets::dim_label(ui, "Opacity:");
ui.spacing_mut().item_spacing.x = 0.0;
ui.add_enabled_ui(has, |ui| {
if let Some(o) = widgets::plain_field(ui, "tr-op", op as f64 * 100.0, "%", 0, 50.0) {
app.run("transparency.set", json!({"opacity": o.clamp(0.0, 100.0)})).ok();
}
});
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 26.0), if has { Sense::click() } else { Sense::hover() });
ui.painter().rect_stroke(r, 2, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
icons::paint(ui, "chevron-right", r.shrink2(vec2(3.0, 7.0)), if has { t.icon } else { t.text_disabled });
egui::Popup::menu(&resp).show(|ui| {
let mut o = op * 100.0;
let r = ui.add(egui::Slider::new(&mut o, 0.0..=100.0).show_value(false));
let phase = if r.drag_stopped() || (r.changed() && !r.dragged()) {
Live::Released
} else if r.changed() {
Live::Dragging
} else {
Live::Idle
};
live_run(app, "Opacity", "transparency.set", json!({"opacity": o.round()}), phase);
});
});
widgets::divider(ui); widgets::divider(ui);
// Thumbnails and the opacity-mask controls. // Thumbnails and the opacity-mask controls.
let hide_thumbs: bool = pstate(ui.ctx(), "tr-hide-thumbs"); let hide_thumbs: bool = pstate(ui.ctx(), "tr-hide-thumbs");

View File

@ -1,7 +1,7 @@
//! Shared widgets in Illustrator's panel style. Colours come from theme tokens. //! Shared widgets in Illustrator's panel style. Colours come from theme tokens.
use egui::{Color32, CornerRadius, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; use egui::{Color32, CornerRadius, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2};
use vectorcraft_color::Paint; use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::Unit; use vectorcraft_doc::Unit;
use crate::icons; use crate::icons;
@ -269,19 +269,125 @@ pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, fill_active:
/// A compact dropdown. Returns the chosen index. /// A compact dropdown. Returns the chosen index.
pub fn dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: &str, options: &[&str], width: f32) -> Option<usize> { pub fn dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: &str, options: &[&str], width: f32) -> Option<usize> {
let t = Tokens::get(ui.ctx()); combo(ui, id, current, width, |ui| {
let mut chosen = None; let mut chosen = None;
let resp = egui::Frame::NONE.fill(t.input).stroke(Stroke::new(1.0, t.input_border)).corner_radius(CornerRadius::same(3)).show(ui, |ui| { for (i, o) in options.iter().enumerate() {
egui::ComboBox::from_id_salt(ui.id().with(id)).selected_text(egui::RichText::new(current).size(12.0)).width(width - 4.0).show_ui(ui, |ui| { if ui.selectable_label(*o == current, *o).clicked() {
for (i, o) in options.iter().enumerate() { chosen = Some(i);
if ui.selectable_label(*o == current, *o).clicked() {
chosen = Some(i);
}
} }
}
chosen
})
}
/// The recessed combo box of [`dropdown`] showing `current`; `list` draws the options and returns
/// the chosen one.
fn combo<R>(
ui: &mut Ui,
id: impl std::hash::Hash + std::fmt::Debug,
current: &str,
width: f32,
list: impl FnOnce(&mut Ui) -> Option<R>,
) -> Option<R> {
let t = Tokens::get(ui.ctx());
egui::Frame::NONE
.fill(t.input)
.stroke(Stroke::new(1.0, t.input_border))
.corner_radius(CornerRadius::same(3))
.show(ui, |ui| {
egui::ComboBox::from_id_salt(ui.id().with(id))
.selected_text(egui::RichText::new(current).size(12.0))
.width(width - 4.0)
.show_ui(ui, list)
.inner
.flatten()
}) })
.inner
}
/// Whether the blend-mode list draws a separator above `BlendMode::ALL[i]`: where a
/// [`BlendMode::group`] starts.
pub fn blend_separator_before(i: usize) -> bool {
i > 0 && i < BlendMode::ALL.len() && BlendMode::ALL[i].group() != BlendMode::ALL[i - 1].group()
}
/// The blend-mode dropdown, its groups separated (Normal | darken | lighten | contrast | inversion
/// | component modes). Returns the chosen mode.
pub fn blend_dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: BlendMode, width: f32) -> Option<BlendMode> {
combo(ui, id, current.label(), width, |ui| {
let mut chosen = None;
for (i, m) in BlendMode::ALL.into_iter().enumerate() {
if blend_separator_before(i) {
ui.separator();
}
if ui.selectable_label(m == current, m.label()).clicked() {
chosen = Some(m);
}
}
chosen
})
}
/// The blend mode an effect parameter names (`"mode": "multiply"`), which editors show as a
/// [`blend_param_dropdown`].
pub fn blend_param(key: &str, value: &serde_json::Value) -> Option<BlendMode> {
value.as_str().filter(|_| key == "mode").and_then(BlendMode::parse)
}
/// [`blend_dropdown`] for a blend-mode parameter; returns the chosen mode's parameter value.
pub fn blend_param_dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: BlendMode) -> Option<serde_json::Value> {
blend_dropdown(ui, id, current, 120.0).map(|m| serde_json::Value::String(m.label().to_ascii_lowercase()))
}
/// An edit made with [`opacity_blend`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum TransparencyEdit {
Blend(BlendMode),
/// Opacity in percent, with the slider's drag phase (`Released` for a typed value).
Opacity(f64, Live),
}
/// The transparency controls shared by the Transparency panel and the Appearance panel's Opacity
/// popups: the grouped blend-mode dropdown, the opacity percent field and its slider popup.
pub fn opacity_blend(
ui: &mut Ui,
id: impl std::hash::Hash + std::fmt::Debug + Copy,
opacity: f32,
blend: BlendMode,
enabled: bool,
) -> Option<TransparencyEdit> {
let t = Tokens::get(ui.ctx());
let mut edit = None;
ui.horizontal(|ui| {
ui.add_enabled_ui(enabled, |ui| {
if let Some(m) = blend_dropdown(ui, (id, "blend"), blend, 104.0) {
edit = Some(TransparencyEdit::Blend(m));
}
});
dim_label(ui, "Opacity:");
ui.spacing_mut().item_spacing.x = 0.0;
ui.add_enabled_ui(enabled, |ui| {
if let Some(o) = plain_field(ui, (id, "opacity"), opacity as f64 * 100.0, "%", 0, 50.0) {
edit = Some(TransparencyEdit::Opacity(o.clamp(0.0, 100.0), Live::Released));
}
});
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 26.0), if enabled { Sense::click() } else { Sense::hover() });
ui.painter().rect_stroke(r, 2, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
icons::paint(ui, "chevron-right", r.shrink2(vec2(3.0, 7.0)), if enabled { t.icon } else { t.text_disabled });
egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
let mut o = opacity * 100.0;
let r = ui.add(egui::Slider::new(&mut o, 0.0..=100.0).show_value(false));
let phase = if r.drag_stopped() || (r.changed() && !r.dragged()) {
Live::Released
} else if r.changed() {
Live::Dragging
} else {
return;
};
edit = Some(TransparencyEdit::Opacity(o.round() as f64, phase));
});
}); });
let _ = resp; edit
chosen
} }
/// The 3×3 reference point locator. Returns a new index when clicked. /// The 3×3 reference point locator. Returns a new index when clicked.