Skip to main content

nifty_macos/
widgets.rs

1//! The native control behind each Nifty widget, and the objects that report its events.
2
3use std::path::Path;
4
5use objc2::rc::Retained;
6use objc2::runtime::ProtocolObject;
7use objc2::{AnyThread, MainThreadMarker, MainThreadOnly, Message};
8use objc2_app_kit::{
9    NSAccessibility, NSBezelStyle, NSBorderType, NSBox, NSBoxType, NSButton, NSButtonType,
10    NSCellImagePosition, NSColor, NSControlSize, NSFont, NSFontTextStyleBody,
11    NSFontTextStyleCaption1, NSFontTextStyleHeadline, NSFontTextStyleLargeTitle,
12    NSFontTextStyleTitle1, NSImage, NSImageScaling, NSImageView, NSPopUpButton,
13    NSProgressIndicator, NSProgressIndicatorStyle, NSScrollView, NSScroller, NSScrollerStyle,
14    NSSlider, NSTextAlignment, NSTextField, NSTextFieldBezelStyle, NSTextView, NSTitlePosition,
15    NSUserInterfaceLayoutDirection, NSView, NSVisualEffectBlendingMode, NSVisualEffectMaterial,
16    NSVisualEffectState, NSVisualEffectView,
17};
18use objc2_foundation::{NSDictionary, NSRect, NSSize, NSString, ns_string};
19
20use nifty_runtime::{
21    ButtonStyle, ClickHandler, DropHandler, Frame, Icon, ImageSize, LabelStyle, MenuEntry,
22    MenuHandler, NumberHandler, Size, Surface, TextAlign, TextColor, TextHandler, TextStyle,
23    ToggleHandler,
24};
25
26use crate::classes::{
27    ClickTarget, FlippedView, NumberTarget, PickTarget, TextFieldDelegate, TextViewDelegate,
28    ToggleTarget, control_state,
29};
30use crate::context_menu::MenuArea;
31use crate::convert::{to_double, to_ns_rect, to_ns_size, to_points, to_size};
32use crate::drop::DropArea;
33use crate::icons::symbol;
34use crate::nav_item::NavItem;
35use crate::switch::SwitchRow;
36
37/// Text fields have no useful natural width when empty, so they start at least this wide.
38const MIN_TEXT_FIELD_WIDTH: f32 = 200.0;
39const DIVIDER_SIZE: Size = Size::new(1.0, 1.0);
40/// Progress bars and sliders have no natural width, so they start at least this wide.
41const MIN_TRACK_WIDTH: f32 = 100.0;
42/// A progress bar is full at this value.
43const FULL_PROGRESS: f64 = 100.0;
44/// The space between a text area's border and its text, in points.
45const TEXT_AREA_INSET: NSSize = NSSize::new(2.0, 4.0);
46/// The index that selects no item of a pop-up button.
47const NO_ITEM: isize = -1;
48/// Matches the corner radius of macOS's own grouped panels, such as System Settings'.
49const CARD_CORNER_RADIUS: f64 = 10.0;
50const CARD_BORDER_WIDTH: f64 = 1.0;
51
52#[derive(Debug)]
53pub struct MacWidget(WidgetKind);
54
55/// `AppKit` holds targets and delegates weakly, so each widget owns its own.
56#[derive(Debug)]
57enum WidgetKind {
58    Label(Retained<NSTextField>),
59    Button {
60        button: Retained<NSButton>,
61        target: Retained<ClickTarget>,
62        /// Shows only its icon, so its title is the tooltip.
63        icon_only: bool,
64    },
65    TextField {
66        field: Retained<NSTextField>,
67        delegate: Retained<TextFieldDelegate>,
68    },
69    /// A text field several lines tall: a text view in a scroll view with a border.
70    TextArea {
71        scroll: Retained<NSScrollView>,
72        text_view: Retained<NSTextView>,
73        delegate: Retained<TextViewDelegate>,
74        lines: u32,
75    },
76    Checkbox {
77        checkbox: Retained<NSButton>,
78        target: Retained<ToggleTarget>,
79    },
80    Switch(SwitchRow),
81    Slider {
82        slider: Retained<NSSlider>,
83        target: Retained<NumberTarget>,
84    },
85    Picker {
86        popup: Retained<NSPopUpButton>,
87        target: Retained<PickTarget>,
88    },
89    NavItem(NavItem),
90    Progress(Retained<NSProgressIndicator>),
91    Image {
92        view: Retained<NSImageView>,
93        size: ImageSize,
94    },
95    Divider(Retained<NSBox>),
96    /// The hidden area a stack's context menu covers.
97    ContextMenu(Retained<MenuArea>),
98    /// The hidden area a stack's drop target covers.
99    DropTarget(Retained<DropArea>),
100    Surface(Retained<NSView>),
101    /// Its content is a flipped view, so content starts at the top as Nifty lays it out.
102    Scroll {
103        view: Retained<NSScrollView>,
104        content: Retained<FlippedView>,
105    },
106}
107
108impl MacWidget {
109    pub(crate) fn label(mtm: MainThreadMarker, text: &str, style: LabelStyle) -> Self {
110        let label = NSTextField::labelWithString(&NSString::from_str(text), mtm);
111        label.setFont(Some(&system_font(style.font)));
112        label.setTextColor(Some(&text_color(style.color)));
113        label.setAlignment(text_alignment(
114            style.align,
115            label.userInterfaceLayoutDirection(),
116        ));
117        label.setSelectable(style.selectable);
118        Self(WidgetKind::Label(label))
119    }
120
121    /// A button made with a selection is an on-off button, shown on while selected.
122    pub(crate) fn button(
123        mtm: MainThreadMarker,
124        title: &str,
125        (style, icon): (ButtonStyle, Icon),
126        selected: Option<bool>,
127        on_click: ClickHandler,
128    ) -> Self {
129        let target = match selected {
130            Some(selected) => ClickTarget::selecting(mtm, on_click, selected),
131            None => ClickTarget::new(mtm, on_click),
132        };
133        let button = click_button(mtm, title, &target);
134        if let Some(selected) = selected {
135            button.setButtonType(NSButtonType::PushOnPushOff);
136            button.setState(control_state(selected));
137        }
138        if let Some(symbol) = symbol(icon) {
139            button.setImage(Some(&symbol));
140            button.setImagePosition(NSCellImagePosition::ImageLeading);
141        }
142        let icon_only = style == ButtonStyle::Icon;
143        match style {
144            // Return is the key equivalent of a window's default button, drawn in the accent color.
145            ButtonStyle::Primary => button.setKeyEquivalent(ns_string!("\r")),
146            ButtonStyle::Plain => {
147                border_on_hover(&button);
148                button.setContentTintColor(Some(&NSColor::controlAccentColor()));
149            }
150            ButtonStyle::Icon => {
151                border_on_hover(&button);
152                button.setImagePosition(NSCellImagePosition::ImageOnly);
153                button.setContentTintColor(Some(&NSColor::secondaryLabelColor()));
154                show_title_beside(&button, &NSString::from_str(title));
155            }
156            ButtonStyle::Normal => {}
157        }
158        Self(WidgetKind::Button {
159            button,
160            target,
161            icon_only,
162        })
163    }
164
165    pub(crate) fn nav_item(
166        mtm: MainThreadMarker,
167        title: &str,
168        icon: Icon,
169        badge: &str,
170        selected: bool,
171        on_click: ClickHandler,
172    ) -> Self {
173        Self(WidgetKind::NavItem(NavItem::new(
174            mtm, title, icon, badge, selected, on_click,
175        )))
176    }
177
178    pub(crate) fn text_field(
179        mtm: MainThreadMarker,
180        text: &str,
181        placeholder: &str,
182        on_change: TextHandler,
183        on_submit: Option<ClickHandler>,
184    ) -> Self {
185        let field = NSTextField::textFieldWithString(&NSString::from_str(text), mtm);
186        field.setBezelStyle(NSTextFieldBezelStyle::RoundedBezel);
187        field.setPlaceholderString(Some(&NSString::from_str(placeholder)));
188        let delegate = TextFieldDelegate::new(mtm, &field, on_change, on_submit);
189        // SAFETY: the delegate lives as long as this widget, which owns both.
190        unsafe { field.setDelegate(Some(ProtocolObject::from_ref(&*delegate))) };
191        Self(WidgetKind::TextField { field, delegate })
192    }
193
194    /// Plain text over `lines` lines, which wraps and scrolls; **Return** starts a new line.
195    pub(crate) fn text_area(
196        mtm: MainThreadMarker,
197        text: &str,
198        lines: u32,
199        on_change: TextHandler,
200    ) -> Self {
201        let scroll = NSTextView::scrollableTextView(mtm);
202        scroll.setBorderType(NSBorderType::BezelBorder);
203        let text_view = scroll
204            .documentView()
205            .and_then(|view| view.downcast::<NSTextView>().ok())
206            .expect("a scrollable text view holds a text view");
207        text_view.setRichText(false);
208        text_view.setAllowsUndo(true);
209        text_view.setFont(Some(&system_font(TextStyle::Body)));
210        text_view.setTextContainerInset(TEXT_AREA_INSET);
211        text_view.setString(&NSString::from_str(text));
212        let delegate = TextViewDelegate::new(mtm, &text_view, on_change);
213        text_view.setDelegate(Some(ProtocolObject::from_ref(&*delegate)));
214        Self(WidgetKind::TextArea {
215            scroll,
216            text_view,
217            delegate,
218            lines,
219        })
220    }
221
222    pub(crate) fn checkbox(
223        mtm: MainThreadMarker,
224        title: &str,
225        checked: bool,
226        on_toggle: ToggleHandler,
227    ) -> Self {
228        let target = ToggleTarget::new(mtm, on_toggle);
229        // SAFETY: `ToggleTarget` implements the action selector, and this widget keeps it alive.
230        let checkbox = unsafe {
231            NSButton::checkboxWithTitle_target_action(
232                &NSString::from_str(title),
233                Some(&target),
234                Some(ToggleTarget::action()),
235                mtm,
236            )
237        };
238        checkbox.setState(control_state(checked));
239        Self(WidgetKind::Checkbox { checkbox, target })
240    }
241
242    /// A bar filled to `percent`, or an animated one while work goes on without one.
243    pub(crate) fn progress(mtm: MainThreadMarker, percent: Option<i64>) -> Self {
244        let bar = NSProgressIndicator::new(mtm);
245        bar.setStyle(NSProgressIndicatorStyle::Bar);
246        bar.setMinValue(0.0);
247        bar.setMaxValue(FULL_PROGRESS);
248        bar.setIndeterminate(percent.is_none());
249        match percent {
250            Some(percent) => bar.setDoubleValue(to_double(percent)),
251            // SAFETY: `startAnimation:` takes any sender, and nil is allowed.
252            None => unsafe { bar.startAnimation(None) },
253        }
254        Self(WidgetKind::Progress(bar))
255    }
256
257    pub(crate) fn switch(
258        mtm: MainThreadMarker,
259        title: &str,
260        on: bool,
261        on_toggle: ToggleHandler,
262    ) -> Self {
263        Self(WidgetKind::Switch(SwitchRow::new(
264            mtm, title, on, on_toggle,
265        )))
266    }
267
268    /// Slides smoothly, reporting the whole number nearest the knob as it changes.
269    pub(crate) fn slider(
270        mtm: MainThreadMarker,
271        value: i64,
272        min: i64,
273        max: i64,
274        on_change: NumberHandler,
275    ) -> Self {
276        let target = NumberTarget::new(mtm, value, on_change);
277        // SAFETY: `NumberTarget` implements the action selector, and this widget keeps it
278        // alive.
279        let slider = unsafe {
280            NSSlider::sliderWithValue_minValue_maxValue_target_action(
281                to_double(value),
282                to_double(min),
283                to_double(max),
284                Some(&target),
285                Some(NumberTarget::action()),
286                mtm,
287            )
288        };
289        slider.setContinuous(true);
290        Self(WidgetKind::Slider { slider, target })
291    }
292
293    pub(crate) fn picker(
294        mtm: MainThreadMarker,
295        options: &[String],
296        selected: &str,
297        on_pick: TextHandler,
298    ) -> Self {
299        let target = PickTarget::new(mtm, on_pick);
300        let popup =
301            NSPopUpButton::initWithFrame_pullsDown(NSPopUpButton::alloc(mtm), NSRect::ZERO, false);
302        // SAFETY: `PickTarget` implements the action selector, and this widget keeps it
303        // alive.
304        unsafe {
305            popup.setTarget(Some(&target));
306            popup.setAction(Some(PickTarget::action()));
307        }
308        let picker = Self(WidgetKind::Picker { popup, target });
309        picker.set_options(options);
310        picker.set_text(selected);
311        picker
312    }
313
314    /// An image view that scales its image to fit, keeping its shape, and centres it.
315    pub(crate) fn image(mtm: MainThreadMarker, path: Option<&Path>, size: ImageSize) -> Self {
316        let view = NSImageView::new(mtm);
317        view.setImageScaling(NSImageScaling::ScaleProportionallyUpOrDown);
318        let image = Self(WidgetKind::Image { view, size });
319        image.set_image(path);
320        image
321    }
322
323    /// Shows the image in the file at `path`, or nothing when there is none or it can't be
324    /// read.
325    pub(crate) fn set_image(&self, path: Option<&Path>) {
326        if let WidgetKind::Image { view, .. } = &self.0 {
327            let image = path.and_then(|path| {
328                let path = NSString::from_str(&path.to_string_lossy());
329                NSImage::initWithContentsOfFile(NSImage::alloc(), &path)
330            });
331            view.setImage(image.as_deref());
332        }
333    }
334
335    pub(crate) fn divider(mtm: MainThreadMarker) -> Self {
336        let divider = NSBox::new(mtm);
337        divider.setBoxType(NSBoxType::Separator);
338        Self(WidgetKind::Divider(divider))
339    }
340
341    pub(crate) fn context_menu(
342        mtm: MainThreadMarker,
343        entries: &[MenuEntry<usize>],
344        on_select: MenuHandler,
345    ) -> Self {
346        Self(WidgetKind::ContextMenu(MenuArea::new(
347            mtm, entries, on_select,
348        )))
349    }
350
351    pub(crate) fn drop_target(mtm: MainThreadMarker, on_drop: DropHandler) -> Self {
352        Self(WidgetKind::DropTarget(DropArea::new(mtm, on_drop)))
353    }
354
355    pub(crate) fn surface(mtm: MainThreadMarker, surface: Surface) -> Self {
356        let view = match surface {
357            Surface::Sidebar => {
358                Retained::into_super(behind_window(mtm, NSVisualEffectMaterial::Sidebar))
359            }
360            Surface::Content => {
361                Retained::into_super(panel(mtm, &NSColor::controlBackgroundColor()))
362            }
363            Surface::Card => {
364                let card = panel(mtm, &NSColor::controlBackgroundColor());
365                card.setCornerRadius(CARD_CORNER_RADIUS);
366                card.setBorderWidth(CARD_BORDER_WIDTH);
367                card.setBorderColor(&NSColor::separatorColor());
368                Retained::into_super(card)
369            }
370            Surface::None => unreachable!("the runtime only creates surfaces it draws"),
371        };
372        Self(WidgetKind::Surface(view))
373    }
374
375    /// A borderless area that scrolls its content up and down, on whatever is behind it.
376    /// Where scroll bars take space, the bar always shows, in the strip kept for it.
377    pub(crate) fn scroll(mtm: MainThreadMarker) -> Self {
378        let view = NSScrollView::new(mtm);
379        view.setBorderType(NSBorderType::NoBorder);
380        view.setDrawsBackground(false);
381        view.setHasVerticalScroller(true);
382        // Legacy scroll bars stay in the strip kept for them; overlay ones hide whenever
383        // the content isn't scrolling, either way. The scroll view follows the system's
384        // preferred style by itself.
385        view.setAutohidesScrollers(false);
386        let content = FlippedView::new(mtm);
387        view.setDocumentView(Some(&content));
388        Self(WidgetKind::Scroll { view, content })
389    }
390
391    /// The width a scroll bar takes from the content beside it; overlay scroll bars, the
392    /// default with a trackpad, take none.
393    pub(crate) fn scroll_bar_width(mtm: MainThreadMarker) -> f32 {
394        match NSScroller::preferredScrollerStyle(mtm) {
395            NSScrollerStyle::Overlay => 0.0,
396            style => to_points(NSScroller::scrollerWidthForControlSize_scrollerStyle(
397                NSControlSize::Regular,
398                style,
399                mtm,
400            )),
401        }
402    }
403
404    /// The view a scroll area's content goes in.
405    ///
406    /// # Panics
407    /// When this isn't a scroll area, which only a runtime bug would ask.
408    pub(crate) fn content_view(&self) -> &NSView {
409        match &self.0 {
410            WidgetKind::Scroll { content, .. } => content,
411            _ => panic!("only scroll areas hold content"),
412        }
413    }
414
415    pub(crate) fn set_scroll_content(&self, size: Size) {
416        if let WidgetKind::Scroll { content, .. } = &self.0 {
417            content.setFrameSize(to_ns_size(size));
418        }
419    }
420
421    pub(crate) fn view(&self) -> &NSView {
422        match &self.0 {
423            WidgetKind::Label(label) => label,
424            WidgetKind::Button { button, .. } => button,
425            WidgetKind::TextField { field, .. } => field,
426            WidgetKind::TextArea { scroll, .. } => scroll,
427            WidgetKind::Checkbox { checkbox, .. } => checkbox,
428            WidgetKind::Switch(row) => row.view(),
429            WidgetKind::Slider { slider, .. } => slider,
430            WidgetKind::Picker { popup, .. } => popup,
431            WidgetKind::NavItem(item) => item.view(),
432            WidgetKind::Progress(bar) => bar,
433            WidgetKind::Image { view, .. } => view,
434            WidgetKind::Divider(divider) => divider,
435            WidgetKind::ContextMenu(area) => area,
436            WidgetKind::DropTarget(area) => area,
437            WidgetKind::Surface(view) => view,
438            WidgetKind::Scroll { view, .. } => view,
439        }
440    }
441
442    pub(crate) fn set_text(&self, text: &str) {
443        match &self.0 {
444            WidgetKind::Label(label) => label.setStringValue(&NSString::from_str(text)),
445            WidgetKind::Button {
446                button, icon_only, ..
447            } => {
448                let text = NSString::from_str(text);
449                if *icon_only {
450                    show_title_beside(button, &text);
451                } else {
452                    button.setTitle(&text);
453                }
454            }
455            WidgetKind::NavItem(item) => item.set_title(text),
456            WidgetKind::Checkbox { checkbox, .. } => checkbox.setTitle(&NSString::from_str(text)),
457            WidgetKind::Switch(row) => row.set_title(text),
458            WidgetKind::Picker { popup, .. } => {
459                let index = (0..popup.numberOfItems())
460                    .find(|&index| popup.itemTitleAtIndex(index).to_string() == text)
461                    .unwrap_or(NO_ITEM);
462                popup.selectItemAtIndex(index);
463            }
464            // Re-setting an unchanged value would move the cursor while the user types.
465            WidgetKind::TextField { field, .. } => {
466                if field.stringValue().to_string() != text {
467                    field.setStringValue(&NSString::from_str(text));
468                }
469            }
470            WidgetKind::TextArea { text_view, .. } => {
471                if text_view.string().to_string() != text {
472                    text_view.setString(&NSString::from_str(text));
473                }
474            }
475            WidgetKind::Progress(_)
476            | WidgetKind::Image { .. }
477            | WidgetKind::Slider { .. }
478            | WidgetKind::Divider(_)
479            | WidgetKind::ContextMenu(_)
480            | WidgetKind::DropTarget(_)
481            | WidgetKind::Surface(_)
482            | WidgetKind::Scroll { .. } => {}
483        }
484    }
485
486    pub(crate) fn set_checked(&self, checked: bool) {
487        match &self.0 {
488            WidgetKind::Checkbox { checkbox, .. } => checkbox.setState(control_state(checked)),
489            WidgetKind::Button { button, target, .. } => {
490                target.set_selected(checked);
491                button.setState(control_state(checked));
492            }
493            WidgetKind::Switch(row) => row.set_on(checked),
494            WidgetKind::NavItem(item) => item.set_selected(checked),
495            _ => {}
496        }
497    }
498
499    pub(crate) fn set_badge(&self, badge: &str) {
500        if let WidgetKind::NavItem(item) = &self.0 {
501            item.set_badge(badge);
502        }
503    }
504
505    pub(crate) fn set_value(&self, value: i64) {
506        match &self.0 {
507            WidgetKind::Progress(bar) => bar.setDoubleValue(to_double(value)),
508            WidgetKind::Slider { slider, target } => {
509                target.shown(value);
510                slider.setDoubleValue(to_double(value));
511            }
512            _ => {}
513        }
514    }
515
516    /// Replaces a picker's items, keeping the one it shows if it is still there. Items
517    /// are added through the menu, which keeps any with the same title.
518    pub(crate) fn set_options(&self, options: &[String]) {
519        let WidgetKind::Picker { popup, .. } = &self.0 else {
520            return;
521        };
522        let shown = popup.titleOfSelectedItem();
523        let menu = popup.menu().expect("a pop-up button has a menu");
524        menu.removeAllItems();
525        for option in options {
526            // SAFETY: the item has no action of its own; picking it sends the button's.
527            unsafe {
528                menu.addItemWithTitle_action_keyEquivalent(
529                    &NSString::from_str(option),
530                    None,
531                    ns_string!(""),
532                );
533            }
534        }
535        self.set_text(&shown.map(|title| title.to_string()).unwrap_or_default());
536    }
537
538    pub(crate) fn measure(&self) -> Size {
539        match &self.0 {
540            WidgetKind::TextField { .. } => {
541                let fitting = to_size(self.view().fittingSize());
542                Size::new(fitting.width.max(MIN_TEXT_FIELD_WIDTH), fitting.height)
543            }
544            WidgetKind::TextArea {
545                text_view, lines, ..
546            } => text_area_size(text_view, *lines),
547            WidgetKind::NavItem(item) => item.measure(),
548            WidgetKind::Switch(row) => row.measure(),
549            WidgetKind::Progress(_) | WidgetKind::Slider { .. } => {
550                let fitting = to_size(self.view().fittingSize());
551                Size::new(fitting.width.max(MIN_TRACK_WIDTH), fitting.height)
552            }
553            WidgetKind::Image { view, size } => {
554                let natural = view
555                    .image()
556                    .map_or(Size::ZERO, |image| to_size(image.size()));
557                size.frame(natural)
558            }
559            WidgetKind::Divider(_) => DIVIDER_SIZE,
560            _ => to_size(self.view().fittingSize()),
561        }
562    }
563
564    pub(crate) fn set_frame(&self, frame: Frame) {
565        match &self.0 {
566            WidgetKind::NavItem(item) => item.set_frame(frame),
567            WidgetKind::Switch(row) => row.set_frame(frame),
568            _ => self.view().setFrame(to_ns_rect(frame)),
569        }
570    }
571
572    /// Takes the control off screen. Its handler may be running right now (a button that
573    /// removes itself), so the control and its handler are released at the end of the
574    /// current event instead of immediately.
575    pub(crate) fn remove(self) {
576        self.view().removeFromSuperview();
577        match self.0 {
578            WidgetKind::Label(label) => release_after_event(label),
579            WidgetKind::NavItem(item) => item.remove(),
580            WidgetKind::Button { button, target, .. } => {
581                release_after_event(button);
582                release_after_event(target);
583            }
584            WidgetKind::TextField { field, delegate } => {
585                release_after_event(field);
586                release_after_event(delegate);
587            }
588            WidgetKind::TextArea {
589                scroll,
590                text_view,
591                delegate,
592                ..
593            } => {
594                release_after_event(scroll);
595                release_after_event(text_view);
596                release_after_event(delegate);
597            }
598            WidgetKind::Checkbox { checkbox, target } => {
599                release_after_event(checkbox);
600                release_after_event(target);
601            }
602            WidgetKind::Switch(row) => row.remove(),
603            WidgetKind::Slider { slider, target } => {
604                release_after_event(slider);
605                release_after_event(target);
606            }
607            WidgetKind::Picker { popup, target } => {
608                release_after_event(popup);
609                release_after_event(target);
610            }
611            WidgetKind::Progress(bar) => {
612                // SAFETY: `stopAnimation:` takes any sender, and nil is allowed.
613                unsafe { bar.stopAnimation(None) };
614                release_after_event(bar);
615            }
616            WidgetKind::Image { view, .. } => release_after_event(view),
617            WidgetKind::Divider(divider) => release_after_event(divider),
618            WidgetKind::ContextMenu(area) => release_after_event(area),
619            WidgetKind::DropTarget(area) => release_after_event(area),
620            WidgetKind::Surface(view) => release_after_event(view),
621            WidgetKind::Scroll { view, content } => {
622                release_after_event(view);
623                release_after_event(content);
624            }
625        }
626    }
627}
628
629/// A translucent `material` that shows the desktop through the window and dims with the
630/// window when it is inactive.
631pub(crate) fn behind_window(
632    mtm: MainThreadMarker,
633    material: NSVisualEffectMaterial,
634) -> Retained<NSVisualEffectView> {
635    let view = NSVisualEffectView::new(mtm);
636    view.setMaterial(material);
637    view.setBlendingMode(NSVisualEffectBlendingMode::BehindWindow);
638    view.setState(NSVisualEffectState::FollowsWindowActiveState);
639    view
640}
641
642/// A plain filled box. `NSBox` resolves its colors when it draws, so it follows dark mode
643/// and accessibility contrast without being told.
644pub(crate) fn panel(mtm: MainThreadMarker, fill: &NSColor) -> Retained<NSBox> {
645    let panel = NSBox::new(mtm);
646    panel.setBoxType(NSBoxType::Custom);
647    panel.setTitlePosition(NSTitlePosition::NoTitle);
648    panel.setContentViewMargins(NSSize::ZERO);
649    panel.setBorderWidth(0.0);
650    panel.setFillColor(fill);
651    panel
652}
653
654/// A text area's natural size: the text field's least width, and `lines` lines of its font
655/// inside its inset and border.
656fn text_area_size(text_view: &NSTextView, lines: u32) -> Size {
657    let font = system_font(TextStyle::Body);
658    // SAFETY: a text view made by `scrollableTextView` lays out with a layout manager.
659    let line_height = unsafe { text_view.layoutManager() }
660        .expect("a scrollable text view has a layout manager")
661        .defaultLineHeightForFont(&font);
662    let content = NSSize::new(
663        f64::from(MIN_TEXT_FIELD_WIDTH),
664        f64::from(lines) * line_height + 2.0 * TEXT_AREA_INSET.height,
665    );
666    let mtm = MainThreadMarker::from(text_view);
667    // SAFETY: no scroller classes are passed, so only the border adds to the size.
668    let frame = unsafe {
669        NSScrollView::frameSizeForContentSize_horizontalScrollerClass_verticalScrollerClass_borderType_controlSize_scrollerStyle(
670            content,
671            None,
672            None,
673            NSBorderType::BezelBorder,
674            NSControlSize::Regular,
675            NSScroller::preferredScrollerStyle(mtm),
676            mtm,
677        )
678    };
679    to_size(frame)
680}
681
682/// Gives an icon-only button its title as its tooltip and what screen readers say. The
683/// button itself keeps no title: setting one on an image-only button once it is shown
684/// makes `AppKit` draw it over the image.
685fn show_title_beside(button: &NSButton, title: &NSString) {
686    button.setTitle(ns_string!(""));
687    button.setToolTip(Some(title));
688    button.setAccessibilityLabel(Some(title));
689}
690
691/// Draws no bezel until the pointer is over the button, as toolbar buttons do.
692fn border_on_hover(button: &NSButton) {
693    button.setBezelStyle(NSBezelStyle::AccessoryBar);
694    button.setShowsBorderOnlyWhileMouseInside(true);
695}
696
697/// A push button that tells `target` when it is clicked.
698pub(crate) fn click_button(
699    mtm: MainThreadMarker,
700    title: &str,
701    target: &ClickTarget,
702) -> Retained<NSButton> {
703    // SAFETY: `ClickTarget` implements the action selector, and the widget that owns the
704    // button keeps its target alive.
705    unsafe {
706        NSButton::buttonWithTitle_target_action(
707            &NSString::from_str(title),
708            Some(target),
709            Some(ClickTarget::action()),
710            mtm,
711        )
712    }
713}
714
715/// Hands the object to the run loop's autorelease pool, which drains after each event.
716pub(crate) fn release_after_event<T: Message>(object: Retained<T>) {
717    let _ = Retained::autorelease_ptr(object);
718}
719
720pub(crate) fn system_font(style: TextStyle) -> Retained<NSFont> {
721    // SAFETY: AppKit defines these text style names as immutable constants.
722    let text_style = unsafe {
723        match style {
724            TextStyle::Body => NSFontTextStyleBody,
725            TextStyle::Title => NSFontTextStyleTitle1,
726            TextStyle::Headline => NSFontTextStyleHeadline,
727            TextStyle::Caption => NSFontTextStyleCaption1,
728            TextStyle::LargeTitle => NSFontTextStyleLargeTitle,
729        }
730    };
731    // SAFETY: an empty options dictionary asks for the style's default font.
732    unsafe { NSFont::preferredFontForTextStyle_options(text_style, &NSDictionary::new()) }
733}
734
735fn text_color(color: TextColor) -> Retained<NSColor> {
736    match color {
737        TextColor::Primary => NSColor::labelColor(),
738        TextColor::Secondary => NSColor::secondaryLabelColor(),
739        TextColor::Tertiary => NSColor::tertiaryLabelColor(),
740        TextColor::Accent => NSColor::controlAccentColor(),
741        TextColor::Success => NSColor::systemGreenColor(),
742        TextColor::Warning => NSColor::systemOrangeColor(),
743        TextColor::Danger => NSColor::systemRedColor(),
744    }
745}
746
747/// `AppKit` has a natural alignment but no opposite of it, so the end follows the direction.
748fn text_alignment(align: TextAlign, direction: NSUserInterfaceLayoutDirection) -> NSTextAlignment {
749    match align {
750        TextAlign::Start => NSTextAlignment::Natural,
751        TextAlign::Center => NSTextAlignment::Center,
752        TextAlign::End if direction == NSUserInterfaceLayoutDirection::RightToLeft => {
753            NSTextAlignment::Left
754        }
755        TextAlign::End => NSTextAlignment::Right,
756    }
757}