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
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
// SPDX-FileCopyrightText: Copyright (c) 2026 Mike Li/Mikewolfli/Wei Li(mikewolfli@163.com)
// SPDX-License-Identifier: MIT
//! Popup window widget.
use crate::core::{Color, Font, HorizontalAlignment, ObjectId, Point, Rect, Size};
use crate::impl_widget_property_hooks;
use crate::property_names_of;
use crate::render::RenderContext;
use crate::signal::GenericSignal;
use crate::widget::capability::coercion::expect_string;
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
/// Popup window widget.
pub struct PopupWindow {
base: BaseWidget,
content_widget: Option<ObjectId>,
/// Title shown in the popup's own chrome.
///
/// Kept on the control rather than in a host-side map: the title is part of
/// what the popup *is*, and a host that held it separately could not paint it.
title: String,
/// Emitted when the popup is opened.
pub opened: GenericSignal,
/// Emitted when the popup is closed.
pub closed: GenericSignal,
}
impl PopupWindow {
/// Creates a popup window with geometry and no title.
pub fn new(geometry: Rect) -> Self {
Self::with_title(String::new(), geometry)
}
/// Creates a popup window with a title and geometry.
pub fn with_title(title: String, geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::PopupWindow, geometry, "PopupWindow"),
content_widget: None,
title,
opened: GenericSignal::new(),
closed: GenericSignal::new(),
}
}
/// Returns the popup's title.
pub fn title(&self) -> &str {
&self.title
}
/// Sets the popup's title.
pub fn set_title(&mut self, title: String) {
self.title = title;
self.base.request_redraw();
}
/// Returns the content widget ID, if any.
pub fn content_widget(&self) -> Option<ObjectId> {
self.content_widget
}
/// Sets the content widget for this popup.
pub fn set_content_widget(&mut self, widget: Option<ObjectId>) {
if let Some(old) = self.content_widget {
self.base.remove_child(old);
}
self.content_widget = widget;
if let Some(id) = widget {
self.base.add_child(id);
}
self.base.request_redraw();
}
/// Shows the popup and emits `opened`.
pub fn open(&mut self) {
self.show();
self.opened.emit();
}
/// Hides the popup and emits `closed`.
pub fn close(&mut self) {
self.hide();
self.closed.emit();
}
}
impl Widget for PopupWindow {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> Size {
crate::core::Size::new(300, 200)
}
/// Reports this widget as the object that paints it.
///
/// `PopupWindow` implements `Draw`, so `Some(self)` is total and cannot be
/// wrong.
fn as_draw_mut(&mut self) -> Option<&mut dyn crate::widget::Draw> {
Some(self)
}
impl_widget_property_hooks!();
}
/// `PopupWindow`'s property contract.
///
/// Read semantics are carried over unchanged from the centralised
/// `access_read_dialog.in.rs` dispatch. `has_content` is read-only: a popup's
/// content is supplied by its owner, not through the property layer.
impl WidgetProperties for PopupWindow {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"title" => Ok(CapabilityValue::String(self.title().to_string())),
"has_content" => Ok(CapabilityValue::Bool(self.content_widget().is_some())),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"title" => {
self.set_title(expect_string(value)?);
Ok(())
}
// A popup's content is supplied by its owner via `set_content_widget`,
// not through the property layer: the property reports presence only.
"has_content" => Err(CapabilityAccessError::ReadOnlyProperty),
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of!["title", "has_content", BASE_PROPERTY_NAMES]
}
/// Runs one of the commands `popup_window` publishes.
///
/// `set_content_widget` takes an `ObjectId` payload, so it is answered through
/// the caller's own handle rather than the scalar property route; reporting
/// `OutOfRange` for a payload-less call is the established convention.
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"set_content_widget" => Err(CapabilityAccessError::OutOfRange),
_ => Err(CapabilityAccessError::UnknownCommand),
}
}
}
impl Draw for PopupWindow {
/// Paints the popup's own chrome, then its content child.
///
/// # Why the title is painted here
///
/// The struct documents the title as "part of what the popup *is*" and keeps it
/// on the control "rather than in a host-side map: ... a host that held it
/// separately could not paint it". That reasoning only holds if this function
/// actually paints it, and it did not — the title was stored, published as a
/// read/write property, and never rendered. An empty title now means "no title
/// bar", so a popup created through [`PopupWindow::new`] keeps the titleless
/// chrome it had before.
///
/// # Child clipping
///
/// A content child is drawn by the host's tree walk, not from here, so this only
/// needs to leave the content area free of chrome. The title bar is taken out of
/// the top of the content rect so a child laid out at the popup's origin does not
/// sit under the title text.
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.base.geometry();
if rect.width == 0 || rect.height == 0 {
return;
}
// Chrome colours resolve explicit style first, then the theme's resolved style for
// this control, and only then a literal. Every colour below used to be a literal, so
// a light/dark switch left the popup and its title bar unchanged — the rendering
// census reported the control as theme-blind.
//
// The theme reads take and release the global manager's lock internally, so no guard
// is held across the draw (the mutex is not re-entrant).
let style = self.base.style().clone();
let theme = crate::style::resolved_theme_style("popup_window");
// `popup_window` is absent from `WidgetRole::for_kind_name`'s table, so it classifies
// as `Surface` and resolves to `theme.colors.background` — the window's own fill. A
// popup painted in that colour would be byte-identical to the frame behind it, so a
// resolved surface equal to the window fill is re-derived a visible step away from
// it: the popup is raised *above* the window, so the step is taken toward the
// foreground, the same distinction `Colors::input_background` draws for a field.
let window_fill = {
let manager = crate::style::theme_manager();
manager.current_theme().map(|active| active.colors.background).unwrap_or(Color::WHITE)
};
let ink = style
.text_color
.or_else(|| theme.as_ref().and_then(|t| t.text_color))
.unwrap_or(Color::rgb(40, 40, 40));
let surface = match style
.background_color
.or_else(|| theme.as_ref().and_then(|t| t.background_color))
{
Some(resolved) if resolved != window_fill => resolved,
_ => window_fill.blend(&ink, 0.08),
};
let border = style
.border_color
.or_else(|| theme.as_ref().and_then(|t| t.border_color))
.filter(|resolved| *resolved != surface)
.unwrap_or_else(|| surface.blend(&ink, 0.35));
// The title bar is a distinct band on the popup, derived from it so the two stay one
// visible step apart in either appearance.
let title_bar = surface.blend(&ink, 0.06);
// Background and border. The fill is opaque on purpose — a popup that wants
// translucency has to say so through its own background property, and the
// earlier "semi-transparent effect" comment described something this code
// never did.
context.fill_rect(rect, surface);
context.draw_rect(rect, border);
if self.title.is_empty() {
return;
}
// Title bar height is clamped to the popup so a short popup shows the title
// rather than painting outside itself.
let bar_height = TITLE_BAR_HEIGHT.min(rect.height);
context.fill_rect(Rect::new(rect.x, rect.y, rect.width, bar_height), title_bar);
context.draw_line(
Point::new(rect.x, rect.y + bar_height as i32),
Point::new(rect.x + rect.width as i32, rect.y + bar_height as i32),
border,
);
// The title is centred inside the bar. The origin is the glyph's **top** edge, so the
// offset is half the difference between the bar and the line box; using the bar's own
// midline put the glyph's top *at* the centre, leaving a 14 px title spanning 12..26 in
// a 24 px bar — two pixels over the separator below it.
let title_font = Font::default();
let title_h = context.measure_text("M", &title_font).height;
context.draw_text_fitted(
Rect::new(
rect.x + 8,
rect.y + (bar_height as i32 - title_h as i32) / 2,
rect.width.saturating_sub(16),
title_h,
),
&self.title,
&title_font,
ink,
HorizontalAlignment::Left,
);
}
}
/// Height of the popup's title bar, in pixels.
///
/// Also the amount the content area is inset by when a title is present, so the two
/// cannot drift apart.
pub const TITLE_BAR_HEIGHT: u32 = 24;
impl PopupWindow {
/// The rectangle available to the content child.
///
/// Insets the top by the title bar **only when a title is set**, so a titleless
/// popup gives its child the full rect.
pub fn content_rect(&self) -> Rect {
let rect = self.base.geometry();
if self.title.is_empty() {
return rect;
}
let inset = TITLE_BAR_HEIGHT.min(rect.height);
Rect::new(rect.x, rect.y + inset as i32, rect.width, rect.height - inset)
}
}
impl crate::event::EventHandler for PopupWindow {
fn handle_event(&mut self, event: &crate::event::Event) {
if !self.base.is_enabled() {
return;
}
match event {
crate::event::Event::MousePress { pos: _, button } if *button == 1 => {
self.base.set_mouse_pressed(true);
}
crate::event::Event::MouseRelease { pos: _, button } if *button == 1 => {
self.base.set_mouse_pressed(false);
}
_ => { /* Other events are not relevant */ }
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::object::Object;
use std::sync::{Arc, Mutex};
#[test]
fn popup_open_close_emits_lifecycle_signals() {
let mut popup = PopupWindow::new(Rect::new(0, 0, 120, 80));
let opened = Arc::new(Mutex::new(0usize));
let closed = Arc::new(Mutex::new(0usize));
let opened_sink = opened.clone();
popup.opened.connect(move || {
if let Ok(mut count) = opened_sink.lock() {
*count += 1;
}
});
let closed_sink = closed.clone();
popup.closed.connect(move || {
if let Ok(mut count) = closed_sink.lock() {
*count += 1;
}
});
popup.open();
popup.close();
assert!(!popup.is_visible());
assert_eq!(*opened.lock().expect("opened lock poisoned"), 1);
assert_eq!(*closed.lock().expect("closed lock poisoned"), 1);
}
#[test]
fn popup_replaces_content_widget_child_binding() {
let mut popup = PopupWindow::new(Rect::new(0, 0, 120, 80));
let old_id = Object::new("OldContent").id();
let new_id = Object::new("NewContent").id();
popup.set_content_widget(Some(old_id));
assert_eq!(popup.content_widget(), Some(old_id));
assert_eq!(popup.children(), &[old_id]);
popup.set_content_widget(Some(new_id));
assert_eq!(popup.content_widget(), Some(new_id));
assert_eq!(popup.children(), &[new_id]);
}
/// The title must reach the pixels, not just the property table.
///
/// `PopupWindow` stores a title, publishes it as a read/write property, and
/// documents it as something the control owns *because* a host could not paint it.
/// None of that was true while `draw` ignored the field. This asserts the rendered
/// frame differs once a title is set, which is the only evidence that survives a
/// future refactor of the chrome.
#[test]
fn popup_with_a_title_paints_chrome_the_titleless_one_does_not() {
let mut titleless = PopupWindow::new(Rect::new(0, 0, 160, 100));
let mut titled = PopupWindow::with_title("Details".to_string(), Rect::new(0, 0, 160, 100));
let plain = crate::widget::svg::render_to_svg(&mut titleless);
let decorated = crate::widget::svg::render_to_svg(&mut titled);
assert_ne!(plain, decorated, "a titled popup must paint more than a titleless one");
assert!(decorated.contains("Details"), "the title text must appear in the rendered output");
}
/// A titleless popup keeps the whole rect for its content.
#[test]
fn popup_content_rect_insets_only_when_a_title_is_present() {
let titleless = PopupWindow::new(Rect::new(0, 0, 160, 100));
assert_eq!(titleless.content_rect(), Rect::new(0, 0, 160, 100));
let titled = PopupWindow::with_title("T".to_string(), Rect::new(0, 0, 160, 100));
assert_eq!(
titled.content_rect(),
Rect::new(0, TITLE_BAR_HEIGHT as i32, 160, 100 - TITLE_BAR_HEIGHT)
);
}
/// A popup shorter than its own title bar must still render inside itself.
#[test]
fn popup_title_bar_clamps_to_a_short_popup() {
let mut tiny = PopupWindow::with_title("T".to_string(), Rect::new(0, 0, 60, 8));
// The assertion is that drawing does not panic and does not escape the rect;
// the clamp is what makes the second half true.
let _ = crate::widget::svg::render_to_svg(&mut tiny);
assert_eq!(tiny.content_rect().height, 0);
}
}