Skip to main content

nifty_macos/
nav_item.rs

1//! A sidebar row the way Finder draws one: an accent-colored symbol and a title, with a
2//! quieter badge such as a count at the end, on a rounded highlight that shows the row is
3//! selected, pressed or under the pointer.
4
5use std::cell::Cell;
6use std::fmt;
7use std::rc::Rc;
8
9use objc2::rc::{Retained, Weak};
10use objc2::{AnyThread, DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send};
11use objc2_app_kit::{
12    NSAccessibility, NSBox, NSButton, NSColor, NSControl, NSEvent, NSImageView, NSLineBreakMode,
13    NSResponder, NSTextAlignment, NSTextField, NSTrackingArea, NSTrackingAreaOptions, NSView,
14};
15use objc2_foundation::{NSObject, NSPoint, NSRect, NSSize, NSString};
16
17use nifty_runtime::{ClickHandler, Frame, Icon, Size, TextStyle};
18
19use crate::classes::ClickTarget;
20use crate::convert::{centered, to_ns_rect, to_points, to_size};
21use crate::icons::tinted_symbol;
22use crate::widgets::{panel, release_after_event, system_font};
23
24/// The height and corner radius of Finder's sidebar rows, the space at each end, the
25/// symbol's square and the space after it, in points.
26const ROW_HEIGHT: f32 = 28.0;
27const CORNER_RADIUS: f64 = 6.0;
28const INSET: f32 = 8.0;
29const ICON_SIZE: f32 = 16.0;
30const ICON_GAP: f32 = 6.0;
31/// How strongly the text color shows through the row while under the pointer, and while
32/// pressed. Tints of the label color follow dark mode.
33const HOVER_TINT: f64 = 0.06;
34const PRESSED_TINT: f64 = 0.12;
35
36/// The row's views, under a button that covers the whole row and draws nothing itself:
37/// it takes clicks anywhere on the row, tracks the pointer, and carries the title for
38/// screen readers.
39#[derive(Debug)]
40pub(crate) struct NavItem {
41    row: Retained<NSBox>,
42    icon: Option<Retained<NSImageView>>,
43    title: Retained<NSTextField>,
44    badge: Retained<NSTextField>,
45    button: Retained<RowButton>,
46    target: Retained<ClickTarget>,
47    look: Rc<RowLook>,
48}
49
50impl NavItem {
51    pub(crate) fn new(
52        mtm: MainThreadMarker,
53        title: &str,
54        icon: Icon,
55        badge: &str,
56        selected: bool,
57        on_click: ClickHandler,
58    ) -> Self {
59        let row = panel(mtm, &NSColor::clearColor());
60        row.setCornerRadius(CORNER_RADIUS);
61        let look = Rc::new(RowLook {
62            row: Weak::from_retained(&row),
63            selected: Cell::new(selected),
64            hovered: Cell::new(false),
65            pressed: Cell::new(false),
66        });
67
68        let icon = tinted_symbol(icon, &NSColor::controlAccentColor())
69            .map(|symbol| NSImageView::imageViewWithImage(&symbol, mtm));
70        let title_label = label(mtm, title, &NSColor::labelColor());
71        title_label.setLineBreakMode(NSLineBreakMode::ByTruncatingTail);
72        let badge_label = label(mtm, badge, &NSColor::secondaryLabelColor());
73        badge_label.setAlignment(NSTextAlignment::Right);
74        let target = ClickTarget::new(mtm, on_click);
75        let button = RowButton::new(mtm, title, &target, Rc::clone(&look));
76
77        let content = row.contentView().expect("a box has a content view");
78        if let Some(icon) = &icon {
79            content.addSubview(icon);
80        }
81        content.addSubview(&title_label);
82        content.addSubview(&badge_label);
83        content.addSubview(&button);
84
85        let item = Self {
86            row,
87            icon,
88            title: title_label,
89            badge: badge_label,
90            button,
91            target,
92            look,
93        };
94        item.look.show();
95        item.set_badge(badge);
96        item
97    }
98
99    pub(crate) fn view(&self) -> &NSView {
100        &self.row
101    }
102
103    pub(crate) fn set_title(&self, title: &str) {
104        let title = NSString::from_str(title);
105        self.title.setStringValue(&title);
106        self.button.setAccessibilityLabel(Some(&title));
107    }
108
109    pub(crate) fn set_selected(&self, selected: bool) {
110        self.look.selected.set(selected);
111        self.look.show();
112    }
113
114    pub(crate) fn set_badge(&self, badge: &str) {
115        self.badge.setStringValue(&NSString::from_str(badge));
116        self.badge.setHidden(badge.is_empty());
117    }
118
119    pub(crate) fn measure(&self) -> Size {
120        let title = to_size(self.title.fittingSize());
121        Size::new(
122            INSET + self.icon_width() + title.width + self.badge_width() + INSET,
123            ROW_HEIGHT.max(title.height),
124        )
125    }
126
127    /// Places the row, then lays out its views across it, each centered vertically.
128    pub(crate) fn set_frame(&self, frame: Frame) {
129        self.row.setFrame(to_ns_rect(frame));
130        let height = frame.size.height;
131        let mut left = INSET;
132        if let Some(icon) = &self.icon {
133            icon.setFrame(centered(
134                left,
135                ICON_SIZE,
136                Size::new(ICON_SIZE, ICON_SIZE),
137                height,
138            ));
139            left += ICON_SIZE + ICON_GAP;
140        }
141        let badge = to_size(self.badge.fittingSize());
142        let badge_left = frame.size.width - INSET - badge.width;
143        self.badge
144            .setFrame(centered(badge_left, badge.width, badge, height));
145        let title_height = to_size(self.title.fittingSize()).height;
146        let title_width = (frame.size.width - left - INSET - self.badge_width()).max(0.0);
147        self.title.setFrame(centered(
148            left,
149            title_width,
150            Size::new(title_width, title_height),
151            height,
152        ));
153        self.button.setFrame(NSRect::new(
154            NSPoint::ZERO,
155            NSSize::new(f64::from(frame.size.width), f64::from(height)),
156        ));
157    }
158
159    /// Released at the end of the current event, since the click may be running now.
160    pub(crate) fn remove(self) {
161        self.row.removeFromSuperview();
162        release_after_event(self.row);
163        if let Some(icon) = self.icon {
164            release_after_event(icon);
165        }
166        release_after_event(self.title);
167        release_after_event(self.badge);
168        release_after_event(self.button);
169        release_after_event(self.target);
170    }
171
172    fn icon_width(&self) -> f32 {
173        if self.icon.is_some() {
174            ICON_SIZE + ICON_GAP
175        } else {
176            0.0
177        }
178    }
179
180    fn badge_width(&self) -> f32 {
181        if self.badge.isHidden() {
182            0.0
183        } else {
184            INSET + to_points(self.badge.fittingSize().width)
185        }
186    }
187}
188
189fn label(mtm: MainThreadMarker, text: &str, color: &NSColor) -> Retained<NSTextField> {
190    let label = NSTextField::labelWithString(&NSString::from_str(text), mtm);
191    label.setFont(Some(&system_font(TextStyle::Body)));
192    label.setTextColor(Some(color));
193    label
194}
195
196/// What the row's highlight shows. The row is held weakly, since the row owns the button
197/// that owns this.
198pub(crate) struct RowLook {
199    row: Weak<NSBox>,
200    selected: Cell<bool>,
201    hovered: Cell<bool>,
202    pressed: Cell<bool>,
203}
204
205impl RowLook {
206    /// Pressing shows over selection, which shows over hovering.
207    fn show(&self) {
208        let Some(row) = self.row.load() else {
209            return;
210        };
211        let fill = if self.pressed.get() {
212            Some(NSColor::labelColor().colorWithAlphaComponent(PRESSED_TINT))
213        } else if self.selected.get() {
214            Some(NSColor::unemphasizedSelectedContentBackgroundColor())
215        } else if self.hovered.get() {
216            Some(NSColor::labelColor().colorWithAlphaComponent(HOVER_TINT))
217        } else {
218            None
219        };
220        match fill {
221            Some(fill) => {
222                row.setFillColor(&fill);
223                row.setTransparent(false);
224            }
225            None => row.setTransparent(true),
226        }
227    }
228
229    fn set(&self, state: &Cell<bool>, on: bool) {
230        if state.replace(on) != on {
231            self.show();
232        }
233    }
234}
235
236impl fmt::Debug for RowLook {
237    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238        f.debug_struct("RowLook")
239            .field("selected", &self.selected)
240            .field("hovered", &self.hovered)
241            .field("pressed", &self.pressed)
242            .finish_non_exhaustive()
243    }
244}
245
246define_class!(
247    // SAFETY: NSButton has no subclassing requirements, and `RowButton` does not implement
248    // Drop. The overrides call through to NSButton's own handling.
249    #[unsafe(super(NSButton, NSControl, NSView, NSResponder, NSObject))]
250    #[thread_kind = MainThreadOnly]
251    #[name = "NiftyRowButton"]
252    #[ivars = Rc<RowLook>]
253    #[derive(Debug)]
254    pub(crate) struct RowButton;
255
256    impl RowButton {
257        #[unsafe(method(mouseEntered:))]
258        fn mouse_entered(&self, _event: &NSEvent) {
259            let look = self.ivars();
260            look.set(&look.hovered, true);
261        }
262
263        #[unsafe(method(mouseExited:))]
264        fn mouse_exited(&self, _event: &NSEvent) {
265            let look = self.ivars();
266            look.set(&look.hovered, false);
267        }
268
269        /// NSButton tracks the click inside `mouseDown:` and returns once the mouse is up.
270        #[unsafe(method(mouseDown:))]
271        fn mouse_down(&self, event: &NSEvent) {
272            let look = Rc::clone(self.ivars());
273            look.set(&look.pressed, true);
274            // SAFETY: NSButton's `mouseDown:` takes the event that was passed here.
275            let () = unsafe { msg_send![super(self), mouseDown: event] };
276            look.set(&look.pressed, false);
277        }
278    }
279);
280
281impl RowButton {
282    fn new(
283        mtm: MainThreadMarker,
284        title: &str,
285        target: &ClickTarget,
286        look: Rc<RowLook>,
287    ) -> Retained<Self> {
288        let this = Self::alloc(mtm).set_ivars(look);
289        // SAFETY: `initWithFrame:` is NSView's designated initializer.
290        let button: Retained<Self> = unsafe { msg_send![super(this), initWithFrame: NSRect::ZERO] };
291        button.setBordered(false);
292        button.setTitle(&NSString::new());
293        button.setAccessibilityLabel(Some(&NSString::from_str(title)));
294        // SAFETY: `ClickTarget` implements the action selector, and the nav item that owns
295        // the button keeps its target alive.
296        unsafe {
297            button.setTarget(Some(target));
298            button.setAction(Some(ClickTarget::action()));
299        }
300        let options = NSTrackingAreaOptions::MouseEnteredAndExited
301            | NSTrackingAreaOptions::ActiveInActiveApp
302            | NSTrackingAreaOptions::InVisibleRect;
303        // SAFETY: the button owns the area it tracks, and `InVisibleRect` keeps the area's
304        // rect in step with the button, so the rect passed is ignored.
305        let area = unsafe {
306            NSTrackingArea::initWithRect_options_owner_userInfo(
307                NSTrackingArea::alloc(),
308                NSRect::ZERO,
309                options,
310                Some(&button),
311                None,
312            )
313        };
314        button.addTrackingArea(&area);
315        button
316    }
317}