1use 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
24const 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;
31const HOVER_TINT: f64 = 0.06;
34const PRESSED_TINT: f64 = 0.12;
35
36#[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 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 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
196pub(crate) struct RowLook {
199 row: Weak<NSBox>,
200 selected: Cell<bool>,
201 hovered: Cell<bool>,
202 pressed: Cell<bool>,
203}
204
205impl RowLook {
206 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 #[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 #[unsafe(method(mouseDown:))]
271 fn mouse_down(&self, event: &NSEvent) {
272 let look = Rc::clone(self.ivars());
273 look.set(&look.pressed, true);
274 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 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 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 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}