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