diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b7a1057fb..62bbf08bb 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -9,7 +9,7 @@ on: - 'crates/rift-protocol/src/**' - 'rift.default.toml' - 'src/bin/rift-cli.rs' - - 'src/common/config.rs' + - 'src/common/config/**' permissions: contents: read diff --git a/Cargo.lock b/Cargo.lock index 67720e36b..06ebd7eea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -185,6 +185,20 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "cgs" +version = "0.1.2" +dependencies = [ + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "tracing", +] + [[package]] name = "chrono" version = "0.4.43" @@ -987,6 +1001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.11.0", + "block2", "libc", "objc2", "objc2-core-foundation", @@ -1079,17 +1094,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "objc2-metal" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" -dependencies = [ - "bitflags 2.11.0", - "objc2", - "objc2-foundation", -] - [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -1101,7 +1105,6 @@ dependencies = [ "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation", - "objc2-metal", ] [[package]] @@ -1334,10 +1337,20 @@ dependencies = [ "thiserror", ] +[[package]] +name = "rift-config-derive" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "rift-protocol" version = "0.1.0" dependencies = [ + "rift-config-derive", "serde", "serde_json", ] @@ -1351,6 +1364,7 @@ dependencies = [ "ascii_tree", "bitflags 2.11.0", "block2", + "cgs", "clap", "crossbeam-channel", "dashmap", @@ -1377,6 +1391,7 @@ dependencies = [ "pretty_assertions", "regex", "rift-client", + "rift-config-derive", "rift-protocol", "ron", "rustc-hash", @@ -1396,6 +1411,7 @@ dependencies = [ "tokio-stream", "tokio-util", "toml", + "toml_edit", "tracing", "tracing-subscriber", "tracing-timing", @@ -1841,8 +1857,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ "indexmap 2.13.0", + "serde_core", + "serde_spanned", "toml_datetime", "toml_parser", + "toml_writer", "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index 0badc32cb..79d69d63e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" build = "build.rs" [workspace] -members = [".", "crates/rift-client", "crates/rift-protocol"] +members = [".", "crates/rift-client", "crates/rift-protocol", "crates/cgs", "crates/rift-config-derive"] resolver = "3" [[bin]] @@ -14,12 +14,12 @@ test = false [profile.dev] opt-level = 0 -debug = 1 +debug = "line-tables-only" incremental = true -codegen-units = 16 +codegen-units = 256 debug-assertions = true overflow-checks = true -split-debuginfo = "packed" +split-debuginfo = "unpacked" panic = "unwind" [profile.release] @@ -53,6 +53,8 @@ custom-event-loop = ["objc2-foundation/NSDate", "objc2-foundation/NSRunLoop"] disable-axuielement-destroyed = [] [dependencies] +rift-config-derive = { path = "crates/rift-config-derive" } +cgs = { path = "crates/cgs" } anyhow = { version = "1.0.83", default-features = false } arc-swap = "1" ascii_tree = "0.1.1" @@ -66,39 +68,26 @@ nix = { version = "0.30.1", features = ["process", "user"] } objc2 = { version = "0.6.2", default-features = false, features = ["exception"] } objc2-app-kit = { version = "0.3.2", default-features = false, features = [ "NSAlert", - "NSAppearance", "NSApplication", "NSAttributedString", - "NSButton", "NSColor", - "NSControl", - "NSCell", + "NSColorSpace", "NSCursor", "NSEvent", "NSFont", - "NSFontDescriptor", "NSGraphics", - "NSGraphicsContext", "NSImage", "NSImageRep", "NSMenu", "NSMenuItem", - "NSOpenPanel", - "NSPanel", - "NSStringDrawing", "NSResponder", "NSRunningApplication", "NSSavePanel", "NSScreen", - "NSStatusBar", - "NSStatusBarButton", - "NSStatusItem", "NSTouch", "NSView", "NSWindow", "NSWorkspace", - "NSTextField", - "NSText", "libc", "objc2-core-foundation", "objc2-core-graphics", @@ -166,17 +155,10 @@ objc2-quartz-core = { version = "0.3.2", default-features = false, features = [ "CADisplayLink", "CAFrameRateRange", "CABase", - "CAEAGLLayer", - "CAEDRMetadata", - "CAGradientLayer", - "CAMediaTimingFunction", - "CAOpenGLLayer", - "CARenderer", "CATextLayer", "CATransaction", "objc2-core-foundation", "objc2-core-graphics", - "objc2-metal" ]} once_cell = { version = "1.21.3", default-features = false } parking_lot = "0.12" @@ -189,6 +171,8 @@ static_assertions = "1.1.0" tokio = { version = "1.35.1", features = ["libc", "macros"] } tokio-stream = "0.1.16" tokio-util = "0.7.15" +toml_edit = { version = "0.23.10", features = ["serde"] } +tempfile = { version = "3.17.1", default-features = false } toml = { version = "0.9.2", features = ["display", "parse", "serde"], default-features = false } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["std", "tracing", "env-filter"], default-features = false } @@ -215,4 +199,7 @@ pretty_assertions = "1.4.0" tokio = { version = "1.35.1", features = ["sync", "tokio-macros"] } test-log = { version = "0.2.16", default-features = false } test_bin = "0.5.0" -tempfile = { version = "3.17.1", default-features = false } + +[[test]] +name = "settings_native" +harness = false diff --git a/crates/cgs/Cargo.toml b/crates/cgs/Cargo.toml new file mode 100644 index 000000000..2317017cb --- /dev/null +++ b/crates/cgs/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "cgs" +version = "0.1.2" +description = "a ui library for macos" +license = "Apache-2.0" +edition = "2024" + +[dependencies] +objc2 = { version = "0.6.2", default-features = false, features = ["std"] } +block2 = { version = "0.6.2", default-features = false, features = ["alloc"] } +objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSString", "NSNumberFormatter", "NSFormatter", "NSValue", "NSKeyValueCoding", "NSNotification", "NSIndexSet", "NSURL", "NSAttributedString", "NSGeometry", "NSTimer", "NSDate"] } +objc2-core-foundation = { version = "0.3.2", default-features = false, features = ["CFString", "CFNumber"] } +objc2-core-graphics = { version = "0.3.2", default-features = false, features = ["CGContext", "CGColorSpace", "CGError"] } +objc2-quartz-core = { version = "0.3.2", default-features = false, features = ["CALayer", "CATransaction", "objc2-core-foundation", "objc2-core-graphics"] } +objc2-app-kit = { version = "0.3.2", default-features = false, features = [ + "std", "NSAttributedString", "NSAccessibility", "NSAccessibilityProtocols", "NSAccessibilityConstants", "NSApplication", "NSAnimation", "NSAnimationContext", "NSRunningApplication", "NSAppearance", "NSWindow", "NSPanel", "NSView", "NSViewController", "NSLayoutAnchor", "NSLayoutGuide", "NSLayoutConstraint", "NSStackView", "NSGridView", "NSScrollView", "NSClipView", "NSScroller", "NSSplitView", "NSSplitViewController", "NSSplitViewItem", "NSBox", "NSVisualEffectView", "NSGlassEffectView", "NSControl", "NSCell", "NSActionCell", "NSButton", "NSButtonCell", "NSSwitch", "NSSegmentedControl", "NSSegmentedCell", "NSPopUpButton", "NSPopUpButtonCell", "NSComboBox", "NSTextField", "NSTextFieldCell", "NSText", "NSTextView", "NSTextContainer", "NSSearchField", "NSTokenField", "NSSlider", "NSSliderCell", "NSStepper", "NSColor", "NSColorWell", "NSFont", "NSFontDescriptor", "NSImage", "NSImageView", "NSMenu", "NSMenuItem", "NSMenuItemBadge", "NSMenuItemCell", "NSStatusBar", "NSStatusItem", "NSStatusBarButton", "NSTableView", "NSTableColumn", "NSTableHeaderView", "NSTableCellView", "NSOutlineView", "NSDragging", "NSDraggingSession", "NSPasteboard", "NSPasteboardItem", "NSPopover", "NSAlert", "NSOpenPanel", "NSSavePanel", "NSToolbar", "NSToolbarItem", "NSToolbarItemGroup", "NSSearchToolbarItem", "NSMenuToolbarItem", "NSProgressIndicator", "NSEvent", "NSTrackingArea", "NSResponder", "NSGraphicsContext", "NSGraphics", "NSUserInterfaceLayout", "NSUserInterfaceValidation", "NSUserInterfaceItemIdentification", "NSWorkspace", "block2", "objc2-core-foundation", "objc2-core-graphics", "objc2-quartz-core" +] } +tracing = { version = "0.1.40", default-features = false } + +[[test]] +name = "native" +harness = false + +[dev-dependencies] +objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSDraggingItem"] } + +[package.metadata.docs.rs] +default-target = "aarch64-apple-darwin" +targets = ["aarch64-apple-darwin", "x86_64-apple-darwin"] diff --git a/crates/cgs/build.rs b/crates/cgs/build.rs new file mode 100644 index 000000000..3aafd9782 --- /dev/null +++ b/crates/cgs/build.rs @@ -0,0 +1,3 @@ +fn main() { + println!("cargo:rustc-link-search=framework=/System/Library/PrivateFrameworks"); +} diff --git a/crates/cgs/readme.md b/crates/cgs/readme.md new file mode 100644 index 000000000..be3033554 --- /dev/null +++ b/crates/cgs/readme.md @@ -0,0 +1,3 @@ +# cgs + +> cgs aka core graphics diff --git a/crates/cgs/src/app.rs b/crates/cgs/src/app.rs new file mode 100644 index 000000000..4a89a03d8 --- /dev/null +++ b/crates/cgs/src/app.rs @@ -0,0 +1,16 @@ +use objc2::rc::Retained; +use objc2_app_kit::NSApplication; + +use crate::Ui; +pub struct Application(Retained); +impl Application { + pub fn shared(ui: &Ui) -> Self { Self(NSApplication::sharedApplication(ui.mtm())) } + + pub fn ns_application(&self) -> &NSApplication { &self.0 } + + pub fn activate(&self) { self.0.activate(); } + + pub fn hide(&self) { self.0.hide(None); } + + pub fn is_active(&self) -> bool { self.0.isActive() } +} diff --git a/crates/cgs/src/bridge.rs b/crates/cgs/src/bridge.rs new file mode 100644 index 000000000..b5d98e52a --- /dev/null +++ b/crates/cgs/src/bridge.rs @@ -0,0 +1,121 @@ +use std::cell::RefCell; + +use objc2::rc::Retained; +use objc2::runtime::{AnyObject, NSObjectProtocol, ProtocolObject}; +use objc2::{DefinedClass, MainThreadOnly, Message, define_class, msg_send, sel}; +use objc2_app_kit::{ + NSControl, NSControlTextEditingDelegate, NSMenu, NSMenuDelegate, NSPopoverDelegate, + NSSearchFieldDelegate, NSTextDelegate, NSTextFieldDelegate, NSTextViewDelegate, + NSTokenFieldDelegate, NSWindow, NSWindowDelegate, +}; +use objc2_foundation::{NSNotification, NSObject}; + +use crate::Ui; + +pub(crate) fn callback(f: impl FnOnce()) { + // Rust unwinding must never cross an Objective-C callback boundary. + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).is_err() { + tracing::error!("panic in native UI callback"); + } +} +type Action = Box; +define_class!( + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "CgUiActionTarget"] + #[ivars = RefCell>] + pub(crate) struct ActionTarget; + unsafe impl NSObjectProtocol for ActionTarget {} + impl ActionTarget { + #[unsafe(method(invoke:))] + fn invoke(&self, sender: &AnyObject) { + let _keep_alive = self.retain(); + if let Ok(mut cb) = self.ivars().try_borrow_mut() { + if let Some(cb) = cb.as_mut() { callback(|| cb(sender)); } + } + } + } +); +impl ActionTarget { + pub(crate) fn new(ui: &Ui) -> Retained { + let this = Self::alloc(ui.mtm()).set_ivars(RefCell::new(None)); + unsafe { msg_send![super(this), init] } + } + + pub(crate) fn set(&self, f: impl FnMut(&AnyObject) + 'static) { + *self.ivars().borrow_mut() = Some(Box::new(f)); + } + + pub(crate) fn attach(&self, control: &NSControl) { + unsafe { + control.setTarget(Some(self)); + control.setAction(Some(sel!(invoke:))); + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Event { + TextChanged, + TextCommitted, + MenuOpened, + MenuClosed, + WindowClosed, + PopoverClosed, +} +type EventCallback = Box; +define_class!( + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "CgUiDelegateBridge"] + #[ivars = RefCell>] + pub(crate) struct DelegateBridge; + unsafe impl NSObjectProtocol for DelegateBridge {} + unsafe impl NSControlTextEditingDelegate for DelegateBridge { + #[unsafe(method(controlTextDidChange:))] + fn changed(&self, note: &NSNotification) { if let Some(sender) = note.object() { self.emit(Event::TextChanged, &sender); } } + #[unsafe(method(controlTextDidEndEditing:))] + fn committed(&self, note: &NSNotification) { if let Some(sender) = note.object() { self.emit(Event::TextCommitted, &sender); } } + } + unsafe impl NSTextDelegate for DelegateBridge { + #[unsafe(method(textDidChange:))] + fn text_changed(&self,note:&NSNotification){if let Some(sender)=note.object(){self.emit(Event::TextChanged,&sender);}} + } + unsafe impl NSTextViewDelegate for DelegateBridge {} + unsafe impl NSTextFieldDelegate for DelegateBridge {} + unsafe impl NSSearchFieldDelegate for DelegateBridge {} + unsafe impl NSTokenFieldDelegate for DelegateBridge {} + unsafe impl NSMenuDelegate for DelegateBridge { + #[unsafe(method(menuWillOpen:))] + fn menu_open(&self, menu: &NSMenu) { self.emit(Event::MenuOpened, menu); } + #[unsafe(method(menuDidClose:))] + fn menu_close(&self, menu: &NSMenu) { self.emit(Event::MenuClosed, menu); } + } + unsafe impl NSWindowDelegate for DelegateBridge { + #[unsafe(method(windowWillClose:))] + fn window_close(&self, note: &NSNotification) { if let Some(sender) = note.object() { self.emit(Event::WindowClosed, &sender); } } + } + unsafe impl NSPopoverDelegate for DelegateBridge { + #[unsafe(method(popoverDidClose:))] + fn popover_close(&self, note: &NSNotification) { if let Some(sender) = note.object() { self.emit(Event::PopoverClosed, &sender); } } + } +); +impl DelegateBridge { + pub(crate) fn new(ui: &Ui, f: impl FnMut(Event, &AnyObject) + 'static) -> Retained { + let this = Self::alloc(ui.mtm()).set_ivars(RefCell::new(Some(Box::new(f)))); + unsafe { msg_send![super(this), init] } + } + + fn emit(&self, event: Event, sender: &AnyObject) { + let _keep_alive = self.retain(); + if let Ok(mut cb) = self.ivars().try_borrow_mut() { + if let Some(cb) = cb.as_mut() { + callback(|| cb(event, sender)); + } + } + } + + pub(crate) fn for_window(&self, window: &NSWindow) { + window.setDelegate(Some(ProtocolObject::from_ref(self))); + } +} diff --git a/crates/cgs/src/collection.rs b/crates/cgs/src/collection.rs new file mode 100644 index 000000000..d1244a59c --- /dev/null +++ b/crates/cgs/src/collection.rs @@ -0,0 +1,1421 @@ +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use objc2::rc::{Retained, Weak}; +use objc2::runtime::{AnyObject, ProtocolObject}; +use objc2::{DefinedClass, MainThreadOnly, Message, define_class, msg_send}; +use objc2_app_kit::*; +use objc2_foundation::{ + NSArray, NSIndexSet, NSNotification, NSNumber, NSObject, NSObjectProtocol, NSString, +}; + +use crate::bridge::{ActionTarget, callback}; +use crate::{AddRemoveControl, Label, NativeControl, NativeView, ScrollView, Ui, VStack}; + +// The native reuse pool owns the Rust content and its action targets, not the datasource. +// A legacy arbitrary cell factory can replace content; reusable factories configure it in place. +define_class!( + #[unsafe(super(NSTableCellView))] + #[thread_kind = MainThreadOnly] + #[name = "CgUiCollectionCell"] + #[ivars = RefCell>>] + struct CollectionCell; +); +impl CollectionCell { + fn new(ui: &Ui) -> Retained { + unsafe { + msg_send![ + super(Self::alloc(ui.mtm()).set_ivars(RefCell::new(None))), + init + ] + } + } + + fn set_content(&self, content: Box) { + unsafe { + self.setTextField(None); + self.setImageView(None); + } + if let Some(old) = self.ivars().borrow_mut().take() { + old.ns_view().removeFromSuperview(); + } + self.addSubview(content.ns_view()); + crate::view::pin(self, content.ns_view(), crate::Insets { + top: 0.0, + left: 0.0, + bottom: 0.0, + right: 0.0, + }); + // Expose outlets to AppKit's native selected-cell appearance. + let cell = content.ns_view().downcast_ref::(); + unsafe { + self.setTextField(cell.and_then(|cell| cell.textField()).as_deref()); + self.setImageView(cell.and_then(|cell| cell.imageView()).as_deref()); + } + *self.ivars().borrow_mut() = Some(content); + } +} + +fn drag_type() -> Retained { NSString::from_str("org.cgs.local-row") } +type CellFactory = Box Box>; +type NativeCellFactory = Box Retained>; +type Selection = Box)>; +type Reorder = Box; +struct Node { + object: Retained, + children: Vec, +} +struct CollectionState { + count: Box usize>, + cell: RefCell, + native_cell: RefCell>, + selection: RefCell>, + selectable: RefCell bool>>, + reorder: RefCell>, + reorderable: Cell, + group_parents: Cell, + table_groups: RefCell bool>>, + row_height: RefCell f64>>>, + roots: RefCell>, + nodes: RefCell>, +} +define_class!( + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "CgUiCollectionBridge"] + #[ivars = CollectionState] + struct CollectionBridge; + unsafe impl NSObjectProtocol for CollectionBridge {} + unsafe impl NSControlTextEditingDelegate for CollectionBridge {} + unsafe impl NSTableViewDataSource for CollectionBridge { + #[unsafe(method(numberOfRowsInTableView:))] + fn count(&self, _table: &NSTableView) -> isize { (self.ivars().count)() as isize } + + #[unsafe(method_id(tableView:pasteboardWriterForRow:))] + fn writer( + &self, + _table: &NSTableView, + row: isize, + ) -> Option>> { + if !self.ivars().reorderable.get() { + None + } else { + let item = NSPasteboardItem::new(); + item.setString_forType(&NSString::from_str(&row.to_string()), &drag_type()); + Some(ProtocolObject::from_retained(item)) + } + } + + #[unsafe(method(tableView:validateDrop:proposedRow:proposedDropOperation:))] + fn validate_drop( + &self, + table: &NSTableView, + info: &ProtocolObject, + row: isize, + operation: NSTableViewDropOperation, + ) -> NSDragOperation { + if operation == NSTableViewDropOperation::Above + && row >= 0 + && row as usize <= (self.ivars().count)() + && self.local_drag(table, info) + { + NSDragOperation::Move + } else { + NSDragOperation::None + } + } + + #[unsafe(method(tableView:acceptDrop:row:dropOperation:))] + fn accept_drop( + &self, + table: &NSTableView, + info: &ProtocolObject, + row: isize, + operation: NSTableViewDropOperation, + ) -> bool { + self.do_accept_drop(table, info, row, operation) + } + } + unsafe impl NSTableViewDelegate for CollectionBridge { + #[unsafe(method(tableView:isGroupRow:))] + fn table_group(&self, _table: &NSTableView, row: isize) -> bool { + (self.ivars().table_groups.borrow())(row as usize) + } + #[unsafe(method(tableView:heightOfRow:))] + fn table_height(&self, table: &NSTableView, row: isize) -> f64 { + self.ivars().row_height.borrow().as_ref().map_or(table.rowHeight(), |height| height(row as usize)) + } + + #[unsafe(method_id(tableView:viewForTableColumn:row:))] + fn table_cell( + &self, + table: &NSTableView, + column: Option<&NSTableColumn>, + row: isize, + ) -> Option> { + usize::try_from(row).ok().and_then(|row| { + self.make_cell( + table, + row, + column + .map(|c| { + table + .tableColumns() + .iter() + .position(|v| std::ptr::eq(&*v, c)) + .unwrap_or(0) + }) + .unwrap_or(0), + ) + }) + } + + #[unsafe(method(tableViewSelectionDidChange:))] + fn selection_changed(&self, note: &NSNotification) { + if let Some(table) = note.object().and_then(|o| o.downcast::().ok()) { + self.select(usize::try_from(table.selectedRow()).ok()); + } + } + + #[unsafe(method(tableView:shouldSelectRow:))] + fn should_select_row(&self, _table: &NSTableView, row: isize) -> bool { + let _keep_alive = self.retain(); + let mut result = false; + callback(|| { + result = usize::try_from(row) + .ok() + .is_some_and(|row| (self.ivars().selectable.borrow())(row)) + }); + result + } + } + unsafe impl NSOutlineViewDataSource for CollectionBridge { + #[unsafe(method(outlineView:numberOfChildrenOfItem:))] + unsafe fn children_count(&self, _view: &NSOutlineView, item: Option<&AnyObject>) -> isize { + self.children(item).len() as isize + } + + #[unsafe(method_id(outlineView:child:ofItem:))] + unsafe fn child( + &self, + _view: &NSOutlineView, + index: isize, + item: Option<&AnyObject>, + ) -> Retained { + let child = self.children(item)[index as usize]; + self.ivars().nodes.borrow()[child].object.clone().into() + } + + #[unsafe(method(outlineView:isItemExpandable:))] + unsafe fn expandable(&self, _view: &NSOutlineView, item: &AnyObject) -> bool { + !self.children(Some(item)).is_empty() + } + } + unsafe impl NSOutlineViewDelegate for CollectionBridge { + #[unsafe(method(outlineView:isGroupItem:))] + fn is_group(&self, _view: &NSOutlineView, item: &AnyObject) -> bool { + self.ivars().group_parents.get() && node_index(item).is_some_and(|index| { + self.ivars().nodes.borrow().get(index).is_some_and(|node| !node.children.is_empty()) + }) + } + + #[unsafe(method(outlineView:shouldSelectItem:))] + fn should_select_item(&self, _view: &NSOutlineView, item: &AnyObject) -> bool { + !self.ivars().group_parents.get() || node_index(item).is_some_and(|index| { + self.ivars().nodes.borrow().get(index).is_some_and(|node| node.children.is_empty()) + }) + } + + #[unsafe(method_id(outlineView:viewForTableColumn:item:))] + unsafe fn outline_cell( + &self, + view: &NSOutlineView, + _column: Option<&NSTableColumn>, + item: &AnyObject, + ) -> Option> { + node_index(item).and_then(|row| self.make_cell(view, row, 0)) + } + + #[unsafe(method(outlineViewSelectionDidChange:))] + fn outline_selection(&self, note: &NSNotification) { + if let Some(view) = note.object().and_then(|o| o.downcast::().ok()) { + let row = view.selectedRow(); + let index = if row < 0 { + None + } else { + view.itemAtRow(row).as_deref().and_then(node_index) + }; + self.select(index); + } + } + } +); +fn node_index(item: &AnyObject) -> Option { + item.downcast_ref::() + .and_then(|n| usize::try_from(n.unsignedLongLongValue()).ok()) +} +impl CollectionBridge { + fn do_accept_drop( + &self, + table: &NSTableView, + info: &ProtocolObject, + row: isize, + operation: NSTableViewDropOperation, + ) -> bool { + let _keep_alive = self.retain(); + if operation != NSTableViewDropOperation::Above || !self.local_drag(table, info) { + return false; + } + let Some(from) = info + .draggingPasteboard() + .stringForType(&drag_type()) + .and_then(|s| s.to_string().parse::().ok()) + else { + return false; + }; + let count = (self.ivars().count)(); + if from >= count || row < 0 || row as usize > count { + return false; + } + let to = row as usize - usize::from(from < row as usize); + if from == to { + return true; + } + if let Ok(mut cb) = self.ivars().reorder.try_borrow_mut() { + if let Some(cb) = cb.as_mut() { + callback(|| cb(from, to)); + } + } + self.reload(table); + table.selectRowIndexes_byExtendingSelection(&NSIndexSet::indexSetWithIndex(to), false); + true + } + + fn new( + ui: &Ui, + count: impl Fn() -> usize + 'static, + cell: impl FnMut(usize, usize) -> Box + 'static, + ) -> Retained { + let this = Self::alloc(ui.mtm()).set_ivars(CollectionState { + count: Box::new(count), + cell: RefCell::new(Box::new(cell)), + native_cell: RefCell::new(None), + selection: RefCell::new(None), + selectable: RefCell::new(Box::new(|_| true)), + reorder: RefCell::new(None), + reorderable: Cell::new(false), + group_parents: Cell::new(false), + table_groups: RefCell::new(Box::new(|_| false)), + row_height: RefCell::new(None), + roots: RefCell::new(Vec::new()), + nodes: RefCell::new(Vec::new()), + }); + unsafe { msg_send![super(this), init] } + } + + fn make_cell( + &self, + table: &NSTableView, + row: usize, + column: usize, + ) -> Option> { + let _keep_alive = self.retain(); + if row >= (self.ivars().count)() { + return None; + } + let mut result = None; + callback(|| { + if let Some(make_cell) = self.ivars().native_cell.borrow_mut().as_mut() { + result = Some(make_cell(table, row, column)); + } else { + let identifier = NSString::from_str(&format!("cgs.cell.{column}")); + let native = unsafe { table.makeViewWithIdentifier_owner(&identifier, None) } + .and_then(|view| view.downcast::().ok()) + .unwrap_or_else(|| { + let cell = CollectionCell::new(&Ui::new(table.mtm())); + cell.setIdentifier(Some(&identifier)); + cell + }); + native.set_content((self.ivars().cell.borrow_mut())(row, column)); + result = Some(native.into_super().into_super()); + } + }); + result + } + + fn reload(&self, table: &NSTableView) { table.reloadData(); } + + fn select(&self, index: Option) { + let _keep_alive = self.retain(); + if let Ok(mut f) = self.ivars().selection.try_borrow_mut() { + if let Some(f) = f.as_mut() { + callback(|| f(index)); + } + } + } + + fn local_drag(&self, table: &NSTableView, info: &ProtocolObject) -> bool { + self.ivars().reorderable.get() + && info + .draggingSource() + .is_some_and(|source| std::ptr::eq(&*source, table as &AnyObject)) + } + + fn children(&self, item: Option<&AnyObject>) -> Vec { + match item { + None => self.ivars().roots.borrow().clone(), + Some(item) => node_index(item) + .and_then(|i| self.ivars().nodes.borrow().get(i).map(|n| n.children.clone())) + .unwrap_or_default(), + } + } +} + +pub struct Table { + native: Retained, + scroll: ScrollView, + bridge: Retained, + rows: Rc>>, + double: Retained, + menu: RefCell>, +} +impl Table { + pub fn new(ui: &Ui) -> Self { + let native = NSTableView::new(ui.mtm()); + native.setAllowsEmptySelection(true); + native.setAllowsMultipleSelection(false); + native.setUsesAutomaticRowHeights(false); + native.setStyle(NSTableViewStyle::Inset); + native.setRowSizeStyle(NSTableViewRowSizeStyle::Custom); + native.setColumnAutoresizingStyle( + NSTableViewColumnAutoresizingStyle::LastColumnOnlyAutoresizingStyle, + ); + let rows = Rc::new(RefCell::new(Vec::new())); + let r = rows.clone(); + let ui_copy = *ui; + let bridge = CollectionBridge::new( + ui, + move || r.borrow().len(), + move |_, _| Box::new(Label::new(&ui_copy, "")), + ); + unsafe { + native.setDataSource(Some(ProtocolObject::from_ref(&*bridge))); + native.setDelegate(Some(ProtocolObject::from_ref(&*bridge))); + } + let double = ActionTarget::new(ui); + unsafe { + native.setTarget(Some(&double)); + native.setDoubleAction(Some(objc2::sel!(invoke:))); + } + let view: Retained = native.clone().into_super().into_super(); + let scroll = ScrollView::new(ui, view); + scroll.ns_scroll_view().setDrawsBackground(true); + scroll.ns_scroll_view().setBorderType(NSBorderType::NoBorder); + { + let r = rows.clone(); + *bridge.ivars().reorder.borrow_mut() = Some(Box::new(move |from, to| { + let value = r.borrow_mut().remove(from); + r.borrow_mut().insert(to, value); + })); + Self { + native, + scroll, + bridge, + rows, + double, + menu: RefCell::new(None), + } + } + } + + pub fn column(self, id: &str, title: &str, width: f64) -> Self { + let column = NSTableColumn::initWithIdentifier( + NSTableColumn::alloc(self.native.mtm()), + &NSString::from_str(id), + ); + column.setTitle(&NSString::from_str(title)); + if width > 0.0 { + column.setWidth(width); + } + self.native.addTableColumn(&column); + self + } + + pub fn cells(self, mut f: impl FnMut(&T, usize) -> Box + 'static) -> Self { + self.cells_with_index(move |item, column, _| f(item, column)) + } + + pub fn cells_with_index( + self, + mut f: impl FnMut(&T, usize, usize) -> Box + 'static, + ) -> Self { + let rows = self.rows.clone(); + *self.bridge.ivars().native_cell.borrow_mut() = None; + *self.bridge.ivars().cell.borrow_mut() = + Box::new(move |row, column| f(&rows.borrow()[row], column, row)); + self.bridge.reload(&self.native); + self + } + + pub fn set_rows_if_changed(&self, rows: Vec) + where T: PartialEq { + if *self.rows.borrow() != rows { + self.set_rows(rows); + } + } + + pub fn rows(self, rows: Vec) -> Self { + self.set_rows(rows); + self + } + + pub fn set_rows(&self, rows: Vec) { + *self.rows.borrow_mut() = rows; + self.bridge.reload(&self.native); + } + + pub fn selection(&self) -> Option { usize::try_from(self.native.selectedRow()).ok() } + + pub fn set_selected(&self, index: Option) { + if self.selection() == index { + return; + } + if let Some(index) = index.filter(|i| *i < self.rows.borrow().len()) { + self.native.selectRowIndexes_byExtendingSelection( + &NSIndexSet::indexSetWithIndex(index), + false, + ); + } else { + unsafe { + self.native.deselectAll(None); + } + } + } + + pub fn on_select_item(self, mut f: impl FnMut(Option) + 'static) -> Self + where T: Clone { + let rows = self.rows.clone(); + *self.bridge.ivars().selection.borrow_mut() = Some(Box::new(move |index| { + let item = index.and_then(|i| rows.borrow().get(i).cloned()); + f(item); + })); + self + } + + pub fn on_select(self, f: impl FnMut(Option) + 'static) -> Self { + self.set_on_select(f); + self + } + + pub fn set_on_select(&self, f: impl FnMut(Option) + 'static) { + *self.bridge.ivars().selection.borrow_mut() = Some(Box::new(f)); + } + + /// Use AppKit source-list section rows and intrinsic per-item heights. + pub fn group_rows(self, group: impl Fn(&T) -> bool + 'static) -> Self { + let rows = self.rows.clone(); + *self.bridge.ivars().table_groups.borrow_mut() = Box::new(move |row| group(&rows.borrow()[row])); + self + } + pub fn row_heights(self, height: impl Fn(&T) -> f64 + 'static) -> Self { + let rows = self.rows.clone(); + *self.bridge.ivars().row_height.borrow_mut() = Some(Box::new(move |row| height(&rows.borrow()[row]))); + self + } + + pub fn selectable(self, f: impl Fn(&T) -> bool + 'static) -> Self { + let rows = self.rows.clone(); + *self.bridge.ivars().selectable.borrow_mut() = + Box::new(move |row| rows.borrow().get(row).is_some_and(&f)); + self + } + + pub fn on_double_click(self, mut f: impl FnMut(usize) + 'static) -> Self { + self.double.set(move |sender| { + if let Some(table) = sender.downcast_ref::() { + if let Ok(row) = usize::try_from(if table.clickedRow() >= 0 { + table.clickedRow() + } else { + table.selectedRow() + }) { + f(row); + } + } + }); + self + } + + pub fn context_menu(self, menu: crate::Menu) -> Self { + self.set_context_menu(menu); + self + } + + pub fn set_context_menu(&self, menu: crate::Menu) { + let table = objc2::rc::Weak::new(&*self.native); + let native_menu = objc2::rc::Weak::new(menu.ns_menu()); + let menu = menu.on_tracking(move |opening| { + if !opening { + return; + } + if let (Some(table), Some(menu)) = (table.load(), native_menu.load()) { + let row = table.clickedRow(); + if row >= 0 { + table.selectRowIndexes_byExtendingSelection( + &NSIndexSet::indexSetWithIndex(row as usize), + false, + ); + } + for item in menu.itemArray() { + item.setEnabled(row >= 0); + } + } + }); + unsafe { + self.native.setMenu(Some(menu.ns_menu())); + } + *self.menu.borrow_mut() = Some(menu); + } + + pub fn reorderable(self, value: bool) -> Self { + self.bridge.ivars().reorderable.set(value); + self.native.registerForDraggedTypes(&NSArray::from_slice(&[&*drag_type()])); + self.native.setDraggingSourceOperationMask_forLocal(NSDragOperation::Move, true); + self + } + + pub fn on_reorder(self, mut f: impl FnMut(usize, usize) + 'static) -> Self { + let rows = self.rows.clone(); + *self.bridge.ivars().reorder.borrow_mut() = Some(Box::new(move |from, to| { + let value = rows.borrow_mut().remove(from); + rows.borrow_mut().insert(to, value); + f(from, to); + })); + self + } + + pub fn ns_table_view(&self) -> &NSTableView { &self.native } + + pub fn ns_scroll_view(&self) -> &NSScrollView { self.scroll.ns_scroll_view() } +} +impl NativeView for Table { + fn ns_view(&self) -> &NSView { self.scroll.ns_view() } +} +impl Drop for Table { + fn drop(&mut self) { + unsafe { + self.native.setDataSource(None); + self.native.setDelegate(None); + self.native.setTarget(None); + self.native.setDoubleAction(None); + } + } +} + +pub struct List(Table); +impl List { + pub fn new(ui: &Ui, label: impl Fn(&T) -> String + 'static) -> Self { + let ui_copy = *ui; + let table = Table::new(ui) + .column("item", "", 240.0) + .cells(move |item, _| Box::new(Label::new(&ui_copy, &label(item)))); + table.native.setHeaderView(None); + Self(table) + } + + pub fn items(self, items: Vec) -> Self { Self(self.0.rows(items)) } + + pub fn set_items(&self, items: Vec) { self.0.set_rows(items); } + + pub fn on_select(self, f: impl FnMut(Option) + 'static) -> Self { + Self(self.0.on_select(f)) + } + + pub fn selection(&self) -> Option { self.0.selection() } + + pub fn set_selected(&self, index: Option) { self.0.set_selected(index); } + + pub fn ns_table_view(&self) -> &NSTableView { self.0.ns_table_view() } + + pub fn defer_scrolling(&self) { self.0.scroll.defer_scrolling(); } +} +impl NativeView for List { + fn ns_view(&self) -> &NSView { self.0.ns_view() } +} + +pub struct EditableList { + stack: VStack, + list: SettingsList, + controls: AddRemoveControl, + selection: Rc>>, +} +impl EditableList { + pub fn new(ui: &Ui, label: impl Fn(&T) -> String + 'static) -> Self { + let controls = AddRemoveControl::new(ui); + controls.show_add(false); + controls.set_remove_enabled(false); + let selection: Rc>> = Rc::new(RefCell::new(None)); + let cb = selection.clone(); + let remove = Weak::new(controls.remove_button()); + let list = SettingsList::new(ui, label, |_| String::new()).fit_content(220.0).on_select( + move |index| { + if let Some(remove) = remove.load() { + remove.setEnabled(index.is_some()); + } + if let Ok(mut f) = cb.try_borrow_mut() { + if let Some(f) = f.as_mut() { + f(index); + } + } + }, + ); + let stack = VStack::new(ui); + stack.ns_stack_view().addArrangedSubview(list.ns_view()); + crate::view::prepare(list.ns_view()); + list.ns_view() + .widthAnchor() + .constraintEqualToAnchor(&stack.ns_view().widthAnchor()) + .setActive(true); + + stack.ns_stack_view().addArrangedSubview(controls.ns_view()); + Self { + stack, + list, + controls, + selection, + } + } + + /// Let the enclosing settings page scroll the entire collection. + pub fn full_length(mut self) -> Self { + self.list = self.list.full_length(); + self + } + + pub fn items(self, items: Vec) -> Self { + self.list.set_rows(items); + self + } + + pub fn set_items(&self, items: Vec) { + self.list.set_rows(items); + self.controls.set_remove_enabled(self.list.selection().is_some()); + } + + pub fn reorderable(mut self, value: bool) -> Self { + self.list = self.list.reorderable(value); + self + } + + pub fn on_reorder(mut self, f: impl FnMut(usize, usize) + 'static) -> Self { + self.list = self.list.on_reorder(f); + self + } + + pub fn on_add(mut self, f: impl FnMut() + 'static) -> Self { + self.controls.show_add(true); + self.controls = self.controls.on_add(f); + self + } + + pub fn on_remove(mut self, mut f: impl FnMut(usize) + 'static) -> Self { + let table = Weak::new(self.list.ns_table_view()); + self.controls = self.controls.on_remove(move || { + if let Some(t) = table.load() { + if let Ok(i) = usize::try_from(t.selectedRow()) { + f(i); + } + } + }); + self + } + + pub fn on_select(self, f: impl FnMut(Option) + 'static) -> Self { + *self.selection.borrow_mut() = Some(Box::new(f)); + self + } + + pub fn set_selected(&self, index: Option) { + self.list.set_selected(index); + self.controls.set_remove_enabled(self.list.selection().is_some()); + } + + pub fn selection(&self) -> Option { self.list.selection() } + + pub fn ns_table_view(&self) -> &NSTableView { self.list.ns_table_view() } +} +impl NativeView for EditableList { + fn ns_view(&self) -> &NSView { self.stack.ns_view() } +} + +struct SettingsCellContent { + content: crate::HStack, + title: Rc