Skip to main content

gpui_authoring/
platform.rs

1mod app_menu;
2
3// Kept here rather than in `gpui_runtime` because the test and bench harnesses in
4// this crate build an app against it, which would make this crate depend on that
5// one and invert the layering.
6#[cfg(any(test, feature = "test-support", feature = "bench-support"))]
7mod threaded_dispatcher;
8
9#[cfg(any(test, feature = "test-support", feature = "bench-support"))]
10mod test;
11
12#[cfg(all(target_os = "macos", any(test, feature = "test-support")))]
13mod visual_test;
14
15use crate::{
16    App, AsyncWindowContext, Bounds, BoundsExt, ClipboardItem, Image, ImageFormat, ImageSource,
17    Pixels, PlatformInputHandler, PlatformInputHandlerDelegate, Point, RenderImage, Size,
18    SvgRenderer, TextInputConfiguration, UTF16Selection, Window, WindowBounds,
19};
20use anyhow::{Context as _, Result};
21use image::codecs::gif::GifDecoder;
22use image::{AnimationDecoder as _, DynamicImage, Frame};
23pub use scheduler::RunnableMeta;
24use smallvec::SmallVec;
25use std::io::Cursor;
26use std::{ops::Range, sync::Arc};
27
28pub use app_menu::*;
29
30#[cfg(any(test, feature = "test-support", feature = "bench-support"))]
31pub(crate) use test::*;
32
33#[cfg(any(test, feature = "test-support"))]
34pub use test::{TestScreenCaptureSource, TestScreenCaptureStream};
35
36#[cfg(any(test, feature = "test-support", feature = "bench-support"))]
37pub use threaded_dispatcher::{PlatformDispatcherExt, ThreadedDispatcher};
38
39#[cfg(all(target_os = "macos", any(test, feature = "test-support")))]
40pub use visual_test::VisualTestPlatform;
41
42#[doc(hidden)]
43pub enum TasksIncluded {
44    OnlyCompleted,
45    CompletedAndRunning,
46}
47
48/// The gpui-backed implementation of [`PlatformInputHandlerDelegate`].
49struct GpuiInputHandler {
50    cx: AsyncWindowContext,
51    handler: Box<dyn InputHandler>,
52}
53
54impl PlatformInputHandlerDelegate for GpuiInputHandler {
55    fn selected_text_range(&mut self, ignore_disabled_input: bool) -> Option<UTF16Selection> {
56        self.cx
57            .update(|window, cx| {
58                self.handler
59                    .selected_text_range(ignore_disabled_input, window, cx)
60            })
61            .ok()
62            .flatten()
63    }
64
65    fn marked_text_range(&mut self) -> Option<Range<usize>> {
66        self.cx
67            .update(|window, cx| self.handler.marked_text_range(window, cx))
68            .ok()
69            .flatten()
70    }
71
72    fn text_for_range(
73        &mut self,
74        range_utf16: Range<usize>,
75        adjusted: &mut Option<Range<usize>>,
76    ) -> Option<String> {
77        self.cx
78            .update(|window, cx| {
79                self.handler
80                    .text_for_range(range_utf16, adjusted, window, cx)
81            })
82            .ok()
83            .flatten()
84    }
85
86    fn replace_text_in_range(&mut self, replacement_range: Option<Range<usize>>, text: &str) {
87        self.cx
88            .update(|window, cx| {
89                self.handler
90                    .replace_text_in_range(replacement_range, text, window, cx);
91            })
92            .ok();
93    }
94
95    fn replace_and_mark_text_in_range(
96        &mut self,
97        range_utf16: Option<Range<usize>>,
98        new_text: &str,
99        new_selected_range: Option<Range<usize>>,
100    ) {
101        self.cx
102            .update(|window, cx| {
103                self.handler.replace_and_mark_text_in_range(
104                    range_utf16,
105                    new_text,
106                    new_selected_range,
107                    window,
108                    cx,
109                )
110            })
111            .ok();
112    }
113
114    fn unmark_text(&mut self) {
115        self.cx
116            .update(|window, cx| self.handler.unmark_text(window, cx))
117            .ok();
118    }
119
120    fn paste(&mut self, item: ClipboardItem) {
121        self.cx
122            .update(|window, cx| self.handler.paste(item, window, cx))
123            .ok();
124    }
125
126    fn bounds_for_range(&mut self, range_utf16: Range<usize>) -> Option<Bounds<Pixels>> {
127        self.cx
128            .update(|window, cx| self.handler.bounds_for_range(range_utf16, window, cx))
129            .ok()
130            .flatten()
131    }
132
133    fn apple_press_and_hold_enabled(&mut self) -> bool {
134        self.handler.apple_press_and_hold_enabled()
135    }
136
137    fn character_index_for_point(&mut self, point: Point<Pixels>) -> Option<usize> {
138        self.cx
139            .update(|window, cx| self.handler.character_index_for_point(point, window, cx))
140            .ok()
141            .flatten()
142    }
143
144    fn set_selected_text_range(&mut self, range_utf16: Range<usize>) {
145        self.cx
146            .update(|window, cx| {
147                self.handler
148                    .set_selected_text_range(range_utf16, window, cx)
149            })
150            .ok();
151    }
152
153    fn element_bounds(&mut self) -> Option<Bounds<Pixels>> {
154        self.cx
155            .update(|window, cx| self.handler.element_bounds(window, cx))
156            .ok()
157            .flatten()
158    }
159
160    fn text_length_utf16(&mut self) -> Option<usize> {
161        self.cx
162            .update(|window, cx| self.handler.text_length_utf16(window, cx))
163            .ok()
164            .flatten()
165    }
166
167    fn query_accepts_text_input(&mut self) -> bool {
168        self.cx
169            .update(|window, cx| self.handler.accepts_text_input(window, cx))
170            .unwrap_or(true)
171    }
172
173    fn query_prefers_ime_for_printable_keys(&mut self) -> bool {
174        self.cx
175            .update(|window, cx| {
176                // The next printable key may complete a chord whose prefix bypassed the IME.
177                !window.has_pending_keystrokes()
178                    && self.handler.prefers_ime_for_printable_keys(window, cx)
179            })
180            .unwrap_or(false)
181    }
182
183    fn text_input_editable_range(&mut self) -> Option<Range<usize>> {
184        self.cx
185            .update(|window, cx| self.handler.text_input_editable_range(window, cx))
186            .ok()
187            .flatten()
188    }
189
190    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
191        self
192    }
193}
194
195/// Creates a [`PlatformInputHandler`] backed by the given gpui [`InputHandler`].
196pub fn new_platform_input_handler(
197    cx: AsyncWindowContext,
198    handler: Box<dyn InputHandler>,
199) -> PlatformInputHandler {
200    PlatformInputHandler::from_delegate(Box::new(GpuiInputHandler { cx, handler }))
201}
202
203/// The [`PlatformInputHandler`] operations that need the owning window.
204pub trait PlatformInputHandlerExt {
205    /// Applies text input directly to the focused input, bypassing the IME.
206    fn dispatch_input(&mut self, input: &str, window: &mut Window, cx: &mut App);
207    /// Bounds of the current IME candidate region.
208    fn selected_bounds(&mut self, window: &mut Window, cx: &mut App) -> Option<Bounds<Pixels>>;
209    /// Whether the focused input currently accepts text.
210    fn accepts_text_input(&mut self, window: &mut Window, cx: &mut App) -> bool;
211    /// The text input configuration of the focused input.
212    fn text_input_configuration(
213        &mut self,
214        window: &mut Window,
215        cx: &mut App,
216    ) -> TextInputConfiguration;
217}
218
219impl PlatformInputHandlerExt for PlatformInputHandler {
220    fn dispatch_input(&mut self, input: &str, window: &mut Window, cx: &mut App) {
221        if let Some(delegate) = self
222            .delegate_as_any_mut()
223            .downcast_mut::<GpuiInputHandler>()
224        {
225            delegate
226                .handler
227                .replace_text_in_range(None, input, window, cx);
228        }
229    }
230
231    fn selected_bounds(&mut self, window: &mut Window, cx: &mut App) -> Option<Bounds<Pixels>> {
232        let delegate = self
233            .delegate_as_any_mut()
234            .downcast_mut::<GpuiInputHandler>()?;
235        let marked_range = delegate.handler.marked_text_range(window, cx);
236        let selection = delegate.handler.selected_text_range(true, window, cx)?;
237        PlatformInputHandler::compute_ime_candidate_bounds(marked_range, &selection, |range| {
238            delegate.handler.bounds_for_range(range, window, cx)
239        })
240    }
241
242    fn accepts_text_input(&mut self, window: &mut Window, cx: &mut App) -> bool {
243        self.delegate_as_any_mut()
244            .downcast_mut::<GpuiInputHandler>()
245            .map(|delegate| delegate.handler.accepts_text_input(window, cx))
246            .unwrap_or(false)
247    }
248
249    fn text_input_configuration(
250        &mut self,
251        window: &mut Window,
252        cx: &mut App,
253    ) -> TextInputConfiguration {
254        self.delegate_as_any_mut()
255            .downcast_mut::<GpuiInputHandler>()
256            .map(|delegate| delegate.handler.text_input_configuration(window, cx))
257            .unwrap_or_default()
258    }
259}
260
261/// Zed's interface for handling text input from the platform's IME system
262/// This is currently a 1:1 exposure of the NSTextInputClient API:
263///
264/// <https://developer.apple.com/documentation/appkit/nstextinputclient>
265pub trait InputHandler: 'static {
266    /// Get the range of the user's currently selected text, if any
267    /// Corresponds to [selectedRange()](https://developer.apple.com/documentation/appkit/nstextinputclient/1438242-selectedrange)
268    ///
269    /// Return value is in terms of UTF-16 characters, from 0 to the length of the document
270    fn selected_text_range(
271        &mut self,
272        ignore_disabled_input: bool,
273        window: &mut Window,
274        cx: &mut App,
275    ) -> Option<UTF16Selection>;
276
277    /// Get the range of the currently marked text, if any
278    /// Corresponds to [markedRange()](https://developer.apple.com/documentation/appkit/nstextinputclient/1438250-markedrange)
279    ///
280    /// Return value is in terms of UTF-16 characters, from 0 to the length of the document
281    fn marked_text_range(&mut self, window: &mut Window, cx: &mut App) -> Option<Range<usize>>;
282
283    /// Get the text for the given document range in UTF-16 characters
284    /// Corresponds to [attributedSubstring(forProposedRange: actualRange:)](https://developer.apple.com/documentation/appkit/nstextinputclient/1438238-attributedsubstring)
285    ///
286    /// range_utf16 is in terms of UTF-16 characters
287    fn text_for_range(
288        &mut self,
289        range_utf16: Range<usize>,
290        adjusted_range: &mut Option<Range<usize>>,
291        window: &mut Window,
292        cx: &mut App,
293    ) -> Option<String>;
294
295    /// Replace the text in the given document range with the given text
296    /// Corresponds to [insertText(_:replacementRange:)](https://developer.apple.com/documentation/appkit/nstextinputclient/1438258-inserttext)
297    ///
298    /// replacement_range is in terms of UTF-16 characters
299    fn replace_text_in_range(
300        &mut self,
301        replacement_range: Option<Range<usize>>,
302        text: &str,
303        window: &mut Window,
304        cx: &mut App,
305    );
306
307    /// Replace the text in the given document range with the given text,
308    /// and mark the given text as part of an IME 'composing' state
309    /// Corresponds to [setMarkedText(_:selectedRange:replacementRange:)](https://developer.apple.com/documentation/appkit/nstextinputclient/1438246-setmarkedtext)
310    ///
311    /// range_utf16 is in terms of UTF-16 characters
312    /// new_selected_range is in terms of UTF-16 characters
313    fn replace_and_mark_text_in_range(
314        &mut self,
315        range_utf16: Option<Range<usize>>,
316        new_text: &str,
317        new_selected_range: Option<Range<usize>>,
318        window: &mut Window,
319        cx: &mut App,
320    );
321
322    /// Remove the IME 'composing' state from the document
323    /// Corresponds to [unmarkText()](https://developer.apple.com/documentation/appkit/nstextinputclient/1438239-unmarktext)
324    fn unmark_text(&mut self, window: &mut Window, cx: &mut App);
325
326    /// Insert a platform-initiated paste at the current selection.
327    ///
328    /// Platforms that deliver paste as an input event rather than through an
329    /// application-defined action (e.g. the DOM `paste` event on web) call
330    /// this with the full clipboard contents. The default implementation
331    /// inserts only the plain-text portion of the item.
332    fn paste(&mut self, item: ClipboardItem, window: &mut Window, cx: &mut App) {
333        if let Some(text) = item.text() {
334            self.replace_text_in_range(None, &text, window, cx);
335        }
336    }
337
338    /// Get the bounds of the given document range in screen coordinates
339    /// Corresponds to [firstRect(forCharacterRange:actualRange:)](https://developer.apple.com/documentation/appkit/nstextinputclient/1438240-firstrect)
340    ///
341    /// This is used for positioning the IME candidate window
342    fn bounds_for_range(
343        &mut self,
344        range_utf16: Range<usize>,
345        window: &mut Window,
346        cx: &mut App,
347    ) -> Option<Bounds<Pixels>>;
348
349    /// Get the character offset for the given point in terms of UTF16 characters
350    ///
351    /// Corresponds to [characterIndexForPoint:](https://developer.apple.com/documentation/appkit/nstextinputclient/characterindex(for:))
352    fn character_index_for_point(
353        &mut self,
354        point: Point<Pixels>,
355        window: &mut Window,
356        cx: &mut App,
357    ) -> Option<usize>;
358
359    /// Set the range of the user's currently selected text.
360    ///
361    /// This is the reverse data-flow direction from [`Self::selected_text_range`]:
362    /// platforms call it when the system text machinery moves the selection on the
363    /// application's behalf — e.g. the user drags a system selection handle or
364    /// invokes Select All from system UI (iOS `UITextInput setSelectedTextRange:`,
365    /// Android `InputConnection.setSelection`).
366    ///
367    /// range_utf16 is in terms of UTF-16 characters, from 0 to the length of the document
368    fn set_selected_text_range(
369        &mut self,
370        _range_utf16: Range<usize>,
371        _window: &mut Window,
372        _cx: &mut App,
373    ) {
374    }
375
376    /// Get the bounds of the focused text element in window coordinates, if known.
377    ///
378    /// This is the pull counterpart to the [`crate::PlatformWindow::update_ime_position`]
379    /// push: mobile platforms ask for the focused element's geometry when they
380    /// need it (e.g. to frame system text-interaction UI overlaid on the focused
381    /// element).
382    fn element_bounds(&mut self, _window: &mut Window, _cx: &mut App) -> Option<Bounds<Pixels>> {
383        None
384    }
385
386    /// Get the length of the document in UTF-16 characters, if known.
387    fn text_length_utf16(&mut self, _window: &mut Window, _cx: &mut App) -> Option<usize> {
388        None
389    }
390
391    /// Allows a given input context to opt into getting raw key repeats instead of
392    /// sending these to the platform.
393    /// TODO: Ideally we should be able to set ApplePressAndHoldEnabled in NSUserDefaults
394    /// (which is how iTerm does it) but it doesn't seem to work for me.
395    #[allow(dead_code)]
396    fn apple_press_and_hold_enabled(&mut self) -> bool {
397        true
398    }
399
400    /// Returns whether this handler is accepting text input to be inserted.
401    fn accepts_text_input(&mut self, _window: &mut Window, _cx: &mut App) -> bool {
402        true
403    }
404
405    /// The contiguous range of text, in UTF-16 code units, that platform text
406    /// input may read and edit around the current selection.
407    ///
408    /// Platforms that mirror document text into an IME-editable buffer clamp
409    /// the mirrored window to this range, so multi-step IME edit gestures
410    /// (word deletion, autocorrect rewrites, suggestion picks) cannot reach
411    /// content outside it. The range should contain the current selection;
412    /// when it cannot (a selection spanning a region boundary), platforms
413    /// degrade the mirrored IME context rather than widening the range.
414    /// `None` places no bound.
415    fn text_input_editable_range(
416        &mut self,
417        _window: &mut Window,
418        _cx: &mut App,
419    ) -> Option<Range<usize>> {
420        None
421    }
422
423    /// Returns whether printable keys should be routed to the IME before keybinding
424    /// matching when a non-ASCII input source (e.g. Japanese, Korean, Chinese IME)
425    /// is active. This prevents multi-stroke keybindings like `jj` from intercepting
426    /// keys that the IME should compose.
427    ///
428    /// Defaults to `false`. The editor overrides this based on whether it expects
429    /// character input (e.g. Vim insert mode returns `true`, normal mode returns `false`).
430    /// The terminal keeps the default `false` so that raw keys reach the terminal process.
431    fn prefers_ime_for_printable_keys(&mut self, _window: &mut Window, _cx: &mut App) -> bool {
432        false
433    }
434
435    /// Get this handler's preferences for platform text assistance.
436    ///
437    /// GPUI re-queries this every frame and forwards it to the platform window
438    /// only when it changes, so implementations must be cheap and may vary the
439    /// result with application state (e.g. with the cursor's position).
440    fn text_input_configuration(
441        &mut self,
442        _window: &mut Window,
443        _cx: &mut App,
444    ) -> TextInputConfiguration {
445        TextInputConfiguration::default()
446    }
447}
448
449/// Window-bounds helpers that require access to the application's display state.
450pub trait WindowBoundsExt: Sized {
451    /// Creates a new window bounds that centers the window on the screen.
452    fn centered(size: Size<Pixels>, cx: &App) -> Self;
453}
454
455impl WindowBoundsExt for WindowBounds {
456    fn centered(size: Size<Pixels>, cx: &App) -> Self {
457        WindowBounds::Windowed(Bounds::centered(None, size, cx))
458    }
459}
460
461pub(crate) fn decode_static_image(
462    bytes: &[u8],
463    format: image::ImageFormat,
464) -> Result<SmallVec<[Frame; 1]>> {
465    let decoder = image::ImageReader::with_format(Cursor::new(bytes), format)
466        .into_decoder()
467        .context("creating image decoder")?;
468    decode_static_image_from_decoder(decoder)
469}
470
471pub(crate) fn decode_static_image_from_decoder(
472    mut decoder: impl image::ImageDecoder,
473) -> Result<SmallVec<[Frame; 1]>> {
474    let orientation = decoder
475        .orientation()
476        .context("reading decoder's orientation")?;
477    let mut image = DynamicImage::from_decoder(decoder).context("decoding image")?;
478    image.apply_orientation(orientation);
479
480    let mut data = image.into_rgba8();
481    for pixel in data.chunks_exact_mut(4) {
482        pixel.swap(0, 2);
483    }
484
485    Ok(SmallVec::from_elem(Frame::new(data), 1))
486}
487
488/// Operations on [`Image`] that integrate it with GPUI's rendering and asset
489/// systems.
490///
491/// The data-only parts of [`Image`] live in `gpui_platform`; this trait adds
492/// the operations that require [`App`], a [`Window`], or the render pipeline.
493pub trait ImageExt: Sized {
494    /// Use the GPUI `use_asset` API to make this image renderable
495    fn use_render_image(
496        self: Arc<Self>,
497        window: &mut Window,
498        cx: &mut App,
499    ) -> Option<Arc<RenderImage>>;
500
501    /// Use the GPUI `get_asset` API to make this image renderable
502    fn get_render_image(
503        self: Arc<Self>,
504        window: &mut Window,
505        cx: &mut App,
506    ) -> Option<Arc<RenderImage>>;
507
508    /// Use the GPUI `remove_asset` API to drop this image, if possible.
509    fn remove_asset(self: Arc<Self>, cx: &mut App);
510
511    /// Check whether this image is present in GPUI's asset cache (loading or
512    /// loaded), without fetching it.
513    #[cfg(any(test, feature = "test-support"))]
514    fn is_asset_cached(self: &Arc<Self>, cx: &App) -> bool;
515
516    /// Convert the clipboard image to an `ImageData` object.
517    fn to_image_data(&self, svg_renderer: SvgRenderer) -> Result<Arc<RenderImage>>;
518}
519
520impl ImageExt for Image {
521    fn use_render_image(
522        self: Arc<Self>,
523        window: &mut Window,
524        cx: &mut App,
525    ) -> Option<Arc<RenderImage>> {
526        ImageSource::Image(self)
527            .use_data(None, window, cx)
528            .and_then(|result| result.ok())
529    }
530
531    fn get_render_image(
532        self: Arc<Self>,
533        window: &mut Window,
534        cx: &mut App,
535    ) -> Option<Arc<RenderImage>> {
536        ImageSource::Image(self)
537            .get_data(None, window, cx)
538            .and_then(|result| result.ok())
539    }
540
541    fn remove_asset(self: Arc<Self>, cx: &mut App) {
542        ImageSource::Image(self).remove_asset(cx);
543    }
544
545    #[cfg(any(test, feature = "test-support"))]
546    fn is_asset_cached(self: &Arc<Self>, cx: &App) -> bool {
547        ImageSource::Image(self.clone()).is_asset_cached(cx)
548    }
549
550    fn to_image_data(&self, svg_renderer: SvgRenderer) -> Result<Arc<RenderImage>> {
551        let frames = match self.format {
552            ImageFormat::Gif => {
553                let decoder = GifDecoder::new(Cursor::new(&self.bytes))?;
554                let mut frames = SmallVec::new();
555
556                for frame in decoder.into_frames() {
557                    match frame {
558                        Ok(mut frame) => {
559                            // Convert from RGBA to BGRA.
560                            for pixel in frame.buffer_mut().chunks_exact_mut(4) {
561                                pixel.swap(0, 2);
562                            }
563                            frames.push(frame);
564                        }
565                        Err(err) => {
566                            log::debug!("Skipping GIF frame due to decode error: {err}");
567                        }
568                    }
569                }
570
571                if frames.is_empty() {
572                    anyhow::bail!("GIF could not be decoded: all frames failed");
573                }
574
575                frames
576            }
577            ImageFormat::Png => decode_static_image(&self.bytes, image::ImageFormat::Png)?,
578            ImageFormat::Jpeg => decode_static_image(&self.bytes, image::ImageFormat::Jpeg)?,
579            ImageFormat::Webp => decode_static_image(&self.bytes, image::ImageFormat::WebP)?,
580            ImageFormat::Bmp => decode_static_image(&self.bytes, image::ImageFormat::Bmp)?,
581            ImageFormat::Tiff => decode_static_image(&self.bytes, image::ImageFormat::Tiff)?,
582            ImageFormat::Ico => decode_static_image(&self.bytes, image::ImageFormat::Ico)?,
583            ImageFormat::Svg => {
584                return svg_renderer
585                    .render_single_frame(&self.bytes, 1.0)
586                    .map_err(Into::into);
587            }
588            ImageFormat::Pnm => decode_static_image(&self.bytes, image::ImageFormat::Pnm)?,
589        };
590
591        Ok(Arc::new(RenderImage::new(frames)))
592    }
593}
594
595#[cfg(test)]
596mod image_tests {
597    use super::*;
598    use crate::size;
599    use std::sync::Arc;
600
601    #[test]
602    fn test_image_to_image_data_applies_exif_orientation() {
603        let image = Image::from_bytes(
604            ImageFormat::Jpeg,
605            // Sourced from the image example, which stays with the `gpui` facade.
606            include_bytes!("../../gpui/examples/image/exif-orientation-rotate-180.jpg").to_vec(),
607        );
608
609        let render_image = image.to_image_data(SvgRenderer::new(Arc::new(()))).unwrap();
610
611        assert_eq!(render_image.size(0), size(16.into(), 32.into()));
612
613        let bytes = render_image.as_bytes(0).unwrap();
614        assert_eq!(&bytes[..4], &[255, 255, 255, 255]);
615        assert_eq!(&bytes[(16 * 32 - 1) * 4..], &[0, 0, 0, 255]);
616    }
617
618    #[test]
619    fn test_svg_image_to_image_data_converts_to_bgra() {
620        let image = Image::from_bytes(
621            ImageFormat::Svg,
622            br##"<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1">
623<rect width="1" height="1" fill="#38BDF8"/>
624</svg>"##
625                .to_vec(),
626        );
627
628        let render_image = image.to_image_data(SvgRenderer::new(Arc::new(()))).unwrap();
629        let bytes = render_image.as_bytes(0).unwrap();
630
631        for pixel in bytes.chunks_exact(4) {
632            assert_eq!(pixel, &[0xF8, 0xBD, 0x38, 0xFF]);
633        }
634    }
635}
636
637#[cfg(all(test, any(target_os = "linux", target_os = "freebsd")))]
638mod tests {
639    use crate::{WindowButton, WindowButtonLayout};
640    use std::collections::HashSet;
641
642    #[test]
643    fn test_window_button_layout_parse_standard() {
644        let layout = WindowButtonLayout::parse("close,minimize:maximize").unwrap();
645        assert_eq!(
646            layout.left,
647            [
648                Some(WindowButton::Close),
649                Some(WindowButton::Minimize),
650                None
651            ]
652        );
653        assert_eq!(layout.right, [Some(WindowButton::Maximize), None, None]);
654    }
655
656    #[test]
657    fn test_window_button_layout_parse_right_only() {
658        let layout = WindowButtonLayout::parse("minimize,maximize,close").unwrap();
659        assert_eq!(layout.left, [None, None, None]);
660        assert_eq!(
661            layout.right,
662            [
663                Some(WindowButton::Minimize),
664                Some(WindowButton::Maximize),
665                Some(WindowButton::Close)
666            ]
667        );
668    }
669
670    #[test]
671    fn test_window_button_layout_parse_left_only() {
672        let layout = WindowButtonLayout::parse("close,minimize,maximize:").unwrap();
673        assert_eq!(
674            layout.left,
675            [
676                Some(WindowButton::Close),
677                Some(WindowButton::Minimize),
678                Some(WindowButton::Maximize)
679            ]
680        );
681        assert_eq!(layout.right, [None, None, None]);
682    }
683
684    #[test]
685    fn test_window_button_layout_parse_with_whitespace() {
686        let layout = WindowButtonLayout::parse(" close , minimize : maximize ").unwrap();
687        assert_eq!(
688            layout.left,
689            [
690                Some(WindowButton::Close),
691                Some(WindowButton::Minimize),
692                None
693            ]
694        );
695        assert_eq!(layout.right, [Some(WindowButton::Maximize), None, None]);
696    }
697
698    #[test]
699    fn test_window_button_layout_parse_empty() {
700        let layout = WindowButtonLayout::parse("").unwrap();
701        assert_eq!(layout.left, [None, None, None]);
702        assert_eq!(layout.right, [None, None, None]);
703    }
704
705    #[test]
706    fn test_window_button_layout_parse_intentionally_empty() {
707        let layout = WindowButtonLayout::parse(":").unwrap();
708        assert_eq!(layout.left, [None, None, None]);
709        assert_eq!(layout.right, [None, None, None]);
710    }
711
712    #[test]
713    fn test_window_button_layout_parse_invalid_buttons() {
714        let layout = WindowButtonLayout::parse("close,invalid,minimize:maximize,foo").unwrap();
715        assert_eq!(
716            layout.left,
717            [
718                Some(WindowButton::Close),
719                Some(WindowButton::Minimize),
720                None
721            ]
722        );
723        assert_eq!(layout.right, [Some(WindowButton::Maximize), None, None]);
724    }
725
726    #[test]
727    fn test_window_button_layout_parse_deduplicates_same_side_buttons() {
728        let layout = WindowButtonLayout::parse("close,close,minimize").unwrap();
729        assert_eq!(
730            layout.right,
731            [
732                Some(WindowButton::Close),
733                Some(WindowButton::Minimize),
734                None
735            ]
736        );
737        assert_eq!(layout.format(), ":close,minimize");
738    }
739
740    #[test]
741    fn test_window_button_layout_parse_deduplicates_buttons_across_sides() {
742        let layout = WindowButtonLayout::parse("close:maximize,close,minimize").unwrap();
743        assert_eq!(layout.left, [Some(WindowButton::Close), None, None]);
744        assert_eq!(
745            layout.right,
746            [
747                Some(WindowButton::Maximize),
748                Some(WindowButton::Minimize),
749                None
750            ]
751        );
752
753        let button_ids: Vec<_> = layout
754            .left
755            .iter()
756            .chain(layout.right.iter())
757            .flatten()
758            .map(WindowButton::id)
759            .collect();
760        let unique_button_ids = button_ids.iter().copied().collect::<HashSet<_>>();
761        assert_eq!(unique_button_ids.len(), button_ids.len());
762        assert_eq!(layout.format(), "close:maximize,minimize");
763    }
764
765    #[test]
766    fn test_window_button_layout_parse_gnome_style() {
767        let layout = WindowButtonLayout::parse("close").unwrap();
768        assert_eq!(layout.left, [None, None, None]);
769        assert_eq!(layout.right, [Some(WindowButton::Close), None, None]);
770    }
771
772    #[test]
773    fn test_window_button_layout_parse_elementary_style() {
774        let layout = WindowButtonLayout::parse("close:maximize").unwrap();
775        assert_eq!(layout.left, [Some(WindowButton::Close), None, None]);
776        assert_eq!(layout.right, [Some(WindowButton::Maximize), None, None]);
777    }
778
779    #[test]
780    fn test_window_button_layout_round_trip() {
781        let cases = [
782            "close:minimize,maximize",
783            "minimize,maximize,close:",
784            ":close",
785            "close:",
786            "close:maximize",
787            ":",
788        ];
789
790        for case in cases {
791            let layout = WindowButtonLayout::parse(case).unwrap();
792            assert_eq!(layout.format(), case, "Round-trip failed for: {}", case);
793        }
794    }
795
796    #[test]
797    fn test_window_button_layout_linux_default() {
798        let layout = WindowButtonLayout::linux_default();
799        assert_eq!(layout.left, [None, None, None]);
800        assert_eq!(
801            layout.right,
802            [
803                Some(WindowButton::Minimize),
804                Some(WindowButton::Maximize),
805                Some(WindowButton::Close)
806            ]
807        );
808
809        let round_tripped = WindowButtonLayout::parse(&layout.format()).unwrap();
810        assert_eq!(round_tripped, layout);
811    }
812
813    #[test]
814    fn test_window_button_layout_parse_all_invalid() {
815        assert!(WindowButtonLayout::parse("asdfghjkl").is_err());
816    }
817}