Skip to main content

oxiui_core/
widget_ext.rs

1//! Widget combinators and the clipboard / drag-and-drop / cursor abstractions.
2//!
3//! [`WidgetExt`] adds chainable decorators to any [`Widget`]: `.padding(..)`,
4//! `.margin(..)`, `.background(..)`, `.border(..)`, `.on_click(..)`,
5//! `.on_hover(..)`. Each returns a wrapper widget that records the decoration
6//! and forwards [`Widget::render`] to the inner widget, so decorators compose
7//! (`w.padding(p).border(b)` nests two wrappers). The wrappers expose their
8//! recorded style so an adapter that understands them can honour it; adapters
9//! that don't still render the inner widget correctly.
10//!
11//! The trait objects [`ClipboardProvider`], [`DragSource`] and [`DropTarget`]
12//! are the platform seams a backend implements; [`DropEffect`] and the cursor
13//! shape (re-exported from [`style`](crate::style)) round out the interaction
14//! surface.
15
16use crate::style::{Border, Margin, Padding};
17use crate::{ButtonResponse, Color, UiCtx, UiError, Widget};
18
19// ── Clipboard ────────────────────────────────────────────────────────────────
20
21/// A clipboard backend. Plain-text access plus optional MIME-typed payloads for
22/// rich clipboard content (HTML, images, …).
23pub trait ClipboardProvider {
24    /// Read the clipboard's plain-text contents, if any.
25    fn get_text(&self) -> Result<Option<String>, UiError>;
26
27    /// Replace the clipboard's plain-text contents.
28    fn set_text(&mut self, text: &str) -> Result<(), UiError>;
29
30    /// Read a MIME-typed payload (e.g. `"text/html"`), if the backend supports
31    /// it. The default returns `Ok(None)` (unsupported MIME type).
32    fn get_mime(&self, _mime: &str) -> Result<Option<Vec<u8>>, UiError> {
33        Ok(None)
34    }
35
36    /// Write a MIME-typed payload. The default returns
37    /// [`UiError::Clipboard`] indicating rich clipboard is unsupported.
38    fn set_mime(&mut self, mime: &str, _data: &[u8]) -> Result<(), UiError> {
39        Err(UiError::Clipboard(format!(
40            "MIME type '{mime}' not supported"
41        )))
42    }
43}
44
45// ── Drag and drop ──────────────────────────────────────────────────────────
46
47/// The effect a drop performs, mirroring the HTML drag-and-drop model.
48#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
49pub enum DropEffect {
50    /// The drop is rejected.
51    #[default]
52    None,
53    /// Copy the dragged data (source retained).
54    Copy,
55    /// Move the dragged data (source removed).
56    Move,
57    /// Create a link/reference to the dragged data.
58    Link,
59}
60
61/// A typed payload carried during a drag.
62#[derive(Clone, Debug, PartialEq, Eq)]
63pub struct DragData {
64    /// The MIME type describing `bytes` (e.g. `"text/plain"`).
65    pub mime: String,
66    /// The raw payload bytes.
67    pub bytes: Vec<u8>,
68}
69
70impl DragData {
71    /// A `text/plain` payload from a string.
72    pub fn text(s: impl Into<String>) -> Self {
73        Self {
74            mime: "text/plain".to_owned(),
75            bytes: s.into().into_bytes(),
76        }
77    }
78
79    /// A payload with an explicit MIME type.
80    pub fn new(mime: impl Into<String>, bytes: Vec<u8>) -> Self {
81        Self {
82            mime: mime.into(),
83            bytes,
84        }
85    }
86
87    /// Interpret the payload as UTF-8 text, if valid.
88    pub fn as_text(&self) -> Option<String> {
89        String::from_utf8(self.bytes.clone()).ok()
90    }
91}
92
93/// Something that can originate a drag.
94pub trait DragSource {
95    /// Produce the payload to carry for this drag, or `None` to not start one.
96    fn drag_data(&self) -> Option<DragData>;
97
98    /// The effects this source permits (defaults to copy + move).
99    fn allowed_effects(&self) -> &[DropEffect] {
100        const DEFAULT: &[DropEffect] = &[DropEffect::Copy, DropEffect::Move];
101        DEFAULT
102    }
103}
104
105/// Something that can accept a drop.
106pub trait DropTarget {
107    /// Whether this target accepts `data`, and if so which effect it would
108    /// apply. Returns [`DropEffect::None`] to reject.
109    fn can_accept(&self, data: &DragData) -> DropEffect;
110
111    /// Commit a drop of `data` with the negotiated `effect`. Returns whether the
112    /// drop was consumed.
113    fn accept_drop(&mut self, data: &DragData, effect: DropEffect) -> Result<bool, UiError>;
114}
115
116// ── WidgetExt combinators ────────────────────────────────────────────────────
117
118/// A click callback invoked when the wrapped widget's [`ButtonResponse`]
119/// reports `clicked`.
120type ClickFn = Box<dyn FnMut()>;
121/// A hover callback invoked with the current hover state.
122type HoverFn = Box<dyn FnMut(bool)>;
123
124/// Wraps a widget with [`Padding`]; renders the inner widget unchanged but
125/// exposes the padding for layout-aware adapters.
126pub struct Padded<W> {
127    inner: W,
128    /// The padding to apply around the inner widget.
129    pub padding: Padding,
130}
131
132impl<W: Widget> Widget for Padded<W> {
133    fn render(&mut self, ui: &mut dyn UiCtx) {
134        self.inner.render(ui);
135    }
136}
137
138/// Wraps a widget with [`Margin`].
139pub struct Margined<W> {
140    inner: W,
141    /// The margin around the inner widget.
142    pub margin: Margin,
143}
144
145impl<W: Widget> Widget for Margined<W> {
146    fn render(&mut self, ui: &mut dyn UiCtx) {
147        self.inner.render(ui);
148    }
149}
150
151/// Wraps a widget with a background [`Color`].
152pub struct Backgrounded<W> {
153    inner: W,
154    /// The background fill colour.
155    pub background: Color,
156}
157
158impl<W: Widget> Widget for Backgrounded<W> {
159    fn render(&mut self, ui: &mut dyn UiCtx) {
160        self.inner.render(ui);
161    }
162}
163
164/// Wraps a widget with a [`Border`].
165pub struct Bordered<W> {
166    inner: W,
167    /// The border to draw around the inner widget.
168    pub border: Border,
169}
170
171impl<W: Widget> Widget for Bordered<W> {
172    fn render(&mut self, ui: &mut dyn UiCtx) {
173        self.inner.render(ui);
174    }
175}
176
177/// Wraps a widget so a callback fires when it is clicked.
178///
179/// The wrapper renders the inner widget, then renders a companion button whose
180/// label is `click_label`; when that button reports `clicked`, the callback
181/// runs. This keeps the immediate-mode contract (no retained state) while still
182/// offering an ergonomic `.on_click` combinator. Use [`OnClick::probe`] in
183/// tests to drive the callback directly.
184pub struct OnClick<W> {
185    inner: W,
186    label: String,
187    callback: ClickFn,
188}
189
190impl<W: Widget> OnClick<W> {
191    /// Manually deliver a [`ButtonResponse`]; invokes the callback when
192    /// `response.clicked` is set. Returns whether the callback fired.
193    pub fn probe(&mut self, response: &ButtonResponse) -> bool {
194        if response.clicked {
195            (self.callback)();
196            true
197        } else {
198            false
199        }
200    }
201}
202
203impl<W: Widget> Widget for OnClick<W> {
204    fn render(&mut self, ui: &mut dyn UiCtx) {
205        self.inner.render(ui);
206        let resp = ui.button(&self.label);
207        if resp.clicked {
208            (self.callback)();
209        }
210    }
211}
212
213/// Wraps a widget so a callback receives hover-state changes.
214pub struct OnHover<W> {
215    inner: W,
216    label: String,
217    callback: HoverFn,
218}
219
220impl<W: Widget> OnHover<W> {
221    /// Manually deliver a [`ButtonResponse`]; invokes the callback with
222    /// `response.hovered`.
223    pub fn probe(&mut self, response: &ButtonResponse) {
224        (self.callback)(response.hovered);
225    }
226}
227
228impl<W: Widget> Widget for OnHover<W> {
229    fn render(&mut self, ui: &mut dyn UiCtx) {
230        self.inner.render(ui);
231        let resp = ui.button(&self.label);
232        (self.callback)(resp.hovered);
233    }
234}
235
236/// Chainable decorators for any [`Widget`].
237///
238/// Blanket-implemented for every `Widget`, so `my_widget.padding(p).border(b)`
239/// works without per-type impls. Each method consumes `self` and returns a
240/// wrapper that still implements [`Widget`].
241pub trait WidgetExt: Widget + Sized {
242    /// Wrap with [`Padding`].
243    fn padding(self, padding: Padding) -> Padded<Self> {
244        Padded {
245            inner: self,
246            padding,
247        }
248    }
249
250    /// Wrap with [`Margin`].
251    fn margin(self, margin: Margin) -> Margined<Self> {
252        Margined {
253            inner: self,
254            margin,
255        }
256    }
257
258    /// Wrap with a background [`Color`].
259    fn background(self, background: Color) -> Backgrounded<Self> {
260        Backgrounded {
261            inner: self,
262            background,
263        }
264    }
265
266    /// Wrap with a [`Border`].
267    fn border(self, border: Border) -> Bordered<Self> {
268        Bordered {
269            inner: self,
270            border,
271        }
272    }
273
274    /// Attach a click callback, surfaced through a companion button labelled
275    /// `label`.
276    fn on_click(self, label: impl Into<String>, callback: impl FnMut() + 'static) -> OnClick<Self> {
277        OnClick {
278            inner: self,
279            label: label.into(),
280            callback: Box::new(callback),
281        }
282    }
283
284    /// Attach a hover callback, surfaced through a companion button labelled
285    /// `label`.
286    fn on_hover(
287        self,
288        label: impl Into<String>,
289        callback: impl FnMut(bool) + 'static,
290    ) -> OnHover<Self> {
291        OnHover {
292            inner: self,
293            label: label.into(),
294            callback: Box::new(callback),
295        }
296    }
297}
298
299impl<W: Widget> WidgetExt for W {}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::geometry::Insets;
305    use std::cell::Cell;
306    use std::rc::Rc;
307
308    /// A trivial widget that records each render into a shared counter.
309    struct Probe(Rc<Cell<u32>>);
310    impl Widget for Probe {
311        fn render(&mut self, _ui: &mut dyn UiCtx) {
312            self.0.set(self.0.get() + 1);
313        }
314    }
315
316    /// A UiCtx that returns a fixed ButtonResponse for `button`.
317    struct StubCtx {
318        clicked: bool,
319        hovered: bool,
320    }
321    impl UiCtx for StubCtx {
322        fn heading(&mut self, _text: &str) {}
323        fn label(&mut self, _text: &str) {}
324        fn button(&mut self, _label: &str) -> ButtonResponse {
325            ButtonResponse {
326                clicked: self.clicked,
327                hovered: self.hovered,
328            }
329        }
330    }
331
332    #[test]
333    fn decorators_record_style_and_forward_render() {
334        let n = Rc::new(Cell::new(0u32));
335        let mut w = Probe(Rc::clone(&n))
336            .padding(Padding::all(4.0))
337            .border(Border::solid(1.0, Color(0, 0, 0, 255)));
338        // Outer is Bordered<Padded<Probe>>: style is exposed.
339        assert_eq!(w.border.insets, Insets::all(1.0));
340        let mut ctx = StubCtx {
341            clicked: false,
342            hovered: false,
343        };
344        w.render(&mut ctx);
345        assert_eq!(n.get(), 1, "inner widget should still render exactly once");
346    }
347
348    #[test]
349    fn background_and_margin_compose() {
350        let n = Rc::new(Cell::new(0u32));
351        let w = Probe(Rc::clone(&n))
352            .background(Color(10, 20, 30, 255))
353            .margin(Margin::symmetric(2.0, 4.0));
354        assert_eq!(w.margin.insets(), Insets::symmetric(2.0, 4.0));
355        // Inner background preserved through the margin wrapper.
356        assert_eq!(w.inner.background, Color(10, 20, 30, 255));
357    }
358
359    #[test]
360    fn on_click_fires_callback_when_clicked() {
361        let n = Rc::new(Cell::new(0u32));
362        let clicks = Rc::new(Cell::new(0u32));
363        let clicks_c = Rc::clone(&clicks);
364        let mut w = Probe(Rc::clone(&n)).on_click("ok", move || clicks_c.set(clicks_c.get() + 1));
365
366        // Not clicked -> callback does not fire.
367        let mut ctx = StubCtx {
368            clicked: false,
369            hovered: false,
370        };
371        w.render(&mut ctx);
372        assert_eq!(clicks.get(), 0);
373
374        // Clicked -> callback fires.
375        let mut ctx = StubCtx {
376            clicked: true,
377            hovered: false,
378        };
379        w.render(&mut ctx);
380        assert_eq!(clicks.get(), 1);
381        assert_eq!(n.get(), 2, "inner rendered each frame");
382    }
383
384    #[test]
385    fn on_hover_reports_state() {
386        let n = Rc::new(Cell::new(0u32));
387        let hovered = Rc::new(Cell::new(false));
388        let hovered_c = Rc::clone(&hovered);
389        let mut w = Probe(Rc::clone(&n)).on_hover("h", move |h| hovered_c.set(h));
390        let mut ctx = StubCtx {
391            clicked: false,
392            hovered: true,
393        };
394        w.render(&mut ctx);
395        assert!(hovered.get());
396    }
397
398    #[test]
399    fn on_click_probe_helper() {
400        let fired = Rc::new(Cell::new(false));
401        let fired_c = Rc::clone(&fired);
402        let n = Rc::new(Cell::new(0u32));
403        let mut w = Probe(n).on_click("x", move || fired_c.set(true));
404        assert!(w.probe(&ButtonResponse {
405            clicked: true,
406            hovered: false
407        }));
408        assert!(fired.get());
409        assert!(!w.probe(&ButtonResponse {
410            clicked: false,
411            hovered: false
412        }));
413    }
414
415    #[test]
416    fn drag_data_text_roundtrip() {
417        let d = DragData::text("hello");
418        assert_eq!(d.mime, "text/plain");
419        assert_eq!(d.as_text().as_deref(), Some("hello"));
420    }
421
422    #[test]
423    fn drop_effect_default_is_none() {
424        assert_eq!(DropEffect::default(), DropEffect::None);
425    }
426
427    // A minimal clipboard to exercise the default MIME behaviour.
428    struct MemClipboard {
429        text: Option<String>,
430    }
431    impl ClipboardProvider for MemClipboard {
432        fn get_text(&self) -> Result<Option<String>, UiError> {
433            Ok(self.text.clone())
434        }
435        fn set_text(&mut self, text: &str) -> Result<(), UiError> {
436            self.text = Some(text.to_owned());
437            Ok(())
438        }
439    }
440
441    #[test]
442    fn clipboard_default_mime_is_unsupported() {
443        let mut c = MemClipboard { text: None };
444        c.set_text("hi").expect("set");
445        assert_eq!(c.get_text().expect("get"), Some("hi".to_string()));
446        // Default get_mime returns None; default set_mime errors.
447        assert_eq!(c.get_mime("text/html").expect("mime get"), None);
448        assert!(matches!(
449            c.set_mime("text/html", b"<b>x</b>"),
450            Err(UiError::Clipboard(_))
451        ));
452    }
453}