1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 FernTech
//! `DisclosureCaret` — a 6×6 dp right triangle painted in the
//! bottom-right corner of a popover trigger to indicate "this opens a
//! menu, not a single-action button". Shared between
//! [`PopoverIconButton`](crate::popover_widget::PopoverIconButton)
//! and [`PopoverButton`](crate::popover_widget::PopoverButton).
//!
//! The caret takes a caller-derived `Signal<TextRole>`; each wrapper
//! computes the role from its own interaction state + variant policy
//! (IconButton's embedded vs stand-alone palette, Button's Default /
//! Regular / Flat) and hands the derived signal in. The caret has
//! zero domain knowledge of "which button type" — it just resolves
//! the role against the current theme on every paint.
//!
//! Pointer-pass-through: the caret never absorbs clicks meant for the
//! IconButton/Button sibling beneath it in the parent ZStack. AT-
//! hidden — the popover affordance is announced by the trigger via
//! `set_has_popup` + `set_expanded`.
use teksilo_canvas::{Canvas, Path, Point, Rect, Size, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::binding::BindingLevel;
use teksilo_core::build_context::BuildContext;
use teksilo_core::signal::Signal;
use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
use teksilo_core::widget_builder::HandlerSet;
use teksilo_core::widget_id::WidgetId;
use teksilo_tokens::TextRole;
/// Width / height of the painted disclosure triangle, in logical pixels.
pub(crate) const CARET_DIM: f32 = 6.0;
/// Inset between the triangle's bottom-right vertex and the host
/// widget's bottom-right corner. Keeps the vertex inside the rounded
/// background (Button uses a 4 dp corner radius, IconButton 8 dp; 2
/// dp clears both).
pub(crate) const CARET_INSET: f32 = 2.0;
/// Layered on top of a popover trigger to paint the disclosure
/// triangle. Layout-transparent (inherits the trigger's bounds via
/// `layout_response`), pointer-pass-through (set in `build`).
#[derive(Debug)]
pub(crate) struct DisclosureCaret {
/// Caller-derived role signal. The caret repaints when it
/// changes — typically a `Signal::map(...)` over the trigger's
/// shared `Signal<InteractionState>`, applying whichever
/// variant-specific role policy the trigger uses.
pub(crate) role: Signal<TextRole>,
}
impl Widget for DisclosureCaret {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
// Decorative chrome: must not intercept pointer events, or it
// would absorb clicks meant for the trigger sibling sitting
// beneath it in the parent ZStack. Same trick the inspector's
// HighlightLayer / HoverProbe use.
ctx.apply_self_handlers(HandlerSet::new().event_pass_through(true));
// Repaint when the role flips so the caret tracks the
// trigger's hover / press / focus / disabled tints.
let self_id = ctx.self_id();
let registry = ctx.binding_registry();
self.role
.bind_to(self_id, registry, BindingLevel::RepaintOnly);
Vec::new()
}
fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
// Inherit the parent's proposal so the caret's bounds match
// the trigger sibling. The triangle is positioned manually in
// `paint` at the bottom-right corner of those bounds.
let w = proposal.width.unwrap_or(0.0);
let h = proposal.height.unwrap_or(0.0);
Size::new(w, h).into()
}
fn place_children(
&self,
_bounds: Rect,
_proposal: SizeProposal,
_children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
}
fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
if bounds.width <= CARET_DIM + CARET_INSET || bounds.height <= CARET_DIM + CARET_INSET {
return;
}
let color = self.role.get().resolve(&ctx.theme.colors);
let right = bounds.x + bounds.width - CARET_INSET;
let bottom = bounds.y + bounds.height - CARET_INSET;
// Right-angle vertex at (right, bottom); legs go up by
// CARET_DIM and left by CARET_DIM. Hypotenuse runs from
// (right - CARET_DIM, bottom) to (right, bottom - CARET_DIM).
let mut path = Path::new();
path.move_to(Point::new(right, bottom));
path.line_to(Point::new(right - CARET_DIM, bottom));
path.line_to(Point::new(right, bottom - CARET_DIM));
path.close();
canvas.fill_path(&path, color);
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
// Decorative — the popover affordance is announced by the
// trigger sibling via `set_has_popup` + `set_expanded`.
builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
builder.set_hidden();
}
}