macOS: add a dedicated Editors window switcher

we are now replacing the native tabs switcher into a Neovide-owned custom editor switcher.

Why?

The native tab overview was a temporary choice for this feature. 

editors should be about moving between Neovide editors without limitations,
but tying it to the system tabs overview made the behavior depend on whether native
tabs to be enabled, plus hacking to make it work properly not affecting separate
windows. 

users who prefers separate-window has been forced to merge
and detach windows just to present a choice to switcher windows easier which
made the single-window case fall back to pinned-window toggling. That
was a known gap.

now with a Neovide-owned custom switcher it gives both modes the
outcome.

NOTE: behavior-wise shouldn't change, besides the switcher appearing now even
for separate-window mode.
This commit is contained in:
Alexsander Falcucci 2026-06-19 07:42:49 +02:00
parent 618605b79d
commit ea98ee5584
No known key found for this signature in database
GPG key ID: E2BCBE04A869DDF8
21 changed files with 1562 additions and 427 deletions

View file

@ -132,18 +132,29 @@ objc2-app-kit = {
default-features = false,
features = [
"NSApplication",
"NSAttributedString",
"NSButton",
"NSButtonCell",
"NSCell",
"NSColor",
"NSColorSpace",
"NSControl",
"NSEvent",
"NSFont",
"NSFontDescriptor",
"NSGraphics",
"NSImage",
"NSImageCell",
"NSImageView",
"NSLayoutConstraint",
"NSMenu",
"NSMenuItem",
"NSResponder",
"NSScrollView",
"NSScreen",
"NSText",
"NSTextField",
"NSTextView",
"NSView",
"NSWindow",
"NSWindowTabGroup",

View file

@ -179,7 +179,7 @@ impl Handler for NeovimHandler {
arguments: Vec<Value>,
_neovim: Neovim<Self::Writer>,
) -> Result<Value, Value> {
trace!("Neovim request: {:?}", &event_name);
trace!("Neovim request: {:?}", event_name);
match event_name.as_ref() {
"neovide.get_clipboard" => handle_clipboard_request(&self.clipboard, |clipboard| {
@ -208,7 +208,7 @@ impl Handler for NeovimHandler {
arguments: Vec<Value>,
neovim: Neovim<Self::Writer>,
) {
trace!("Neovim notification: {:?}", &event_name);
trace!("Neovim notification: {:?}", event_name);
match event_name.as_ref() {
"redraw" => {

View file

@ -30,7 +30,7 @@ where
pub fn send(&self, message: T) -> Result<(), TokioSendError<T>> {
tracy_dynamic_zone!(&format!("{}::{}", self.channel_name, message.as_ref()));
trace!("{} {:?}", self.channel_name, &message);
trace!("{} {:?}", self.channel_name, message);
self.tx.send(message)
}
}
@ -57,7 +57,7 @@ where
match rx.recv().await {
Some(message) => {
tracy_dynamic_zone!(&format!("{}::{}", self.channel_name, message.as_ref()));
trace!("{} {:?}", self.channel_name, &message);
trace!("{} {:?}", self.channel_name, message);
Some(message)
}
None => None,
@ -69,7 +69,7 @@ where
match rx.try_recv() {
Ok(message) => {
tracy_dynamic_zone!(&format!("{}::{}", self.channel_name, message.as_ref()));
trace!("{} {:?}", self.channel_name, &message);
trace!("{} {:?}", self.channel_name, message);
Ok(message)
}
Err(e) => Err(e),

View file

@ -172,7 +172,7 @@ pub struct CmdLineSettings {
)]
pub system_fullscreen_hotkey: String,
/// Set the Window > Editors shortcut when native tabs are visible
/// Set the Window > Editors shortcut
#[cfg(target_os = "macos")]
#[arg(
long = "system-show-all-tabs-hotkey",

View file

@ -1217,7 +1217,7 @@ impl Editor {
}
fn set_option(&mut self, gui_option: GuiOption) {
trace!("Option set {:?}", &gui_option);
trace!("Option set {:?}", gui_option);
match gui_option {
GuiOption::GuiFont(guifont) => {

View file

@ -1,5 +1,11 @@
pub mod settings;
use std::cell::Cell;
mod switcher;
pub use switcher::{
EditorSwitcherRow, close_editor_switcher_if_open, editor_switcher_key_event_matches,
show_editor_switcher_panel,
};
use std::sync::{
OnceLock,
atomic::{AtomicBool, Ordering},
@ -8,7 +14,7 @@ use std::{cell::RefCell, ffi::CString, os::raw::c_void, path::Path, ptr, str, sy
use glamour::Point2;
use objc2::{
AnyThread, MainThreadOnly, Message, class, define_class, msg_send,
AnyThread, MainThreadOnly, class, define_class, msg_send,
rc::Retained,
runtime::{AnyClass, AnyObject, ClassBuilder, ProtocolObject},
sel,
@ -38,22 +44,20 @@ use crate::settings::Settings;
use crate::utils::expand_tilde;
use crate::window::macos::tab_navigation::KeyCombo;
use crate::{cmd_line::CmdLineSettings, frame::Frame};
use winit::{event_loop::EventLoopProxy, window::Window};
use winit::{
event_loop::EventLoopProxy,
window::{Window, WindowId},
};
use crate::units::{Pixel, PixelRect};
use crate::window::macos::hotkey::GlobalHotkeys;
use crate::window::{
EventPayload, ForceClickKind, UserEvent, WindowSettings, WindowSettingsChanged,
EventPayload, ForceClickKind, MacShortcutCommand, UserEvent, WindowSettings,
WindowSettingsChanged,
};
thread_local! {
static APP_MENU: RefCell<Option<Menu>> = const { RefCell::new(None) };
static TAB_OVERVIEW_ACTIVE: Cell<bool> = const { Cell::new(false) };
static PENDING_DETACH_WINDOW: Cell<usize> = const { Cell::new(0) };
static SUPPRESS_FOCUS_EVENTS: Cell<bool> = const { Cell::new(false) };
static ACTIVE_HOST_WINDOW: Cell<usize> = const { Cell::new(0) };
static SUPPRESS_UNTIL_NEXT_KEY_EVENT: Cell<bool> = const { Cell::new(false) };
static LAST_HOST_WINDOW: Cell<usize> = const { Cell::new(0) };
static QUICKLOOK_PREVIEW_ITEM: RefCell<Option<Retained<NSURL>>> = const { RefCell::new(None) };
static QUICKLOOK_CONTROLLER: RefCell<Option<Retained<QuickLookPreviewController>>> =
const { RefCell::new(None) };
@ -79,13 +83,6 @@ fn should_show_native_tab_bar() -> bool {
native_tabs_enabled() && SHOW_NATIVE_TAB_BAR.load(Ordering::Relaxed)
}
fn reset_tab_overview_state() {
TAB_OVERVIEW_ACTIVE.with(|active| active.set(false));
SUPPRESS_UNTIL_NEXT_KEY_EVENT.with(|cell| cell.set(false));
PENDING_DETACH_WINDOW.with(|ptr| ptr.set(0));
ACTIVE_HOST_WINDOW.with(|cell| cell.set(0));
}
fn store_event_loop_proxy(proxy: EventLoopProxy<EventPayload>) {
let _ = EVENT_LOOP_PROXY.set(proxy);
}
@ -101,6 +98,29 @@ fn request_new_window() {
}
}
fn request_editor_switcher() {
let Some(proxy) = EVENT_LOOP_PROXY.get() else {
log::warn!("Editor switcher requested before event loop proxy became available");
return;
};
let event = UserEvent::MacShortcut(MacShortcutCommand::ShowEditorSwitcher);
if let Err(error) = proxy.send_event(EventPayload::all(event)) {
log::error!("Failed to request editor switcher: {:?}", error);
}
}
pub fn request_activate_window(window_id: WindowId) {
let Some(proxy) = EVENT_LOOP_PROXY.get() else {
log::warn!("Window activation requested before event loop proxy became available");
return;
};
if let Err(error) = proxy.send_event(EventPayload::all(UserEvent::ActivateWindow(window_id))) {
log::error!("Failed to request window activation: {:?}", error);
}
}
fn refresh_windows_menu_for_ns_window(app: &NSApplication, window: &NSWindow) {
let title = window.title();
let title_ref: &NSString = title.as_ref();
@ -131,25 +151,6 @@ fn merge_all_windows_if_native_tabs(ns_window: &NSWindow) {
}
}
pub fn is_focus_suppressed() -> bool {
SUPPRESS_FOCUS_EVENTS.with(|cell| cell.get())
|| SUPPRESS_UNTIL_NEXT_KEY_EVENT.with(|cell| cell.get())
}
struct FocusSuppressionGuard;
impl FocusSuppressionGuard {
fn new() -> Self {
SUPPRESS_FOCUS_EVENTS.with(|flag| flag.set(true));
FocusSuppressionGuard
}
}
impl Drop for FocusSuppressionGuard {
fn drop(&mut self) {
SUPPRESS_FOCUS_EVENTS.with(|flag| flag.set(false));
}
}
#[link(name = "Quartz", kind = "framework")]
unsafe extern "C" {}
@ -298,7 +299,7 @@ fn load_icon_from_default_bytes() -> Option<Retained<NSImage>> {
}
}
fn load_neovide_icon(custom_icon_path: Option<&String>) -> Option<Retained<NSImage>> {
pub fn load_neovide_icon(custom_icon_path: Option<&String>) -> Option<Retained<NSImage>> {
custom_icon_path
.and_then(|path| {
let expanded = expand_tilde(path);
@ -422,7 +423,7 @@ impl MacosWindowFeature {
let is_fullscreen = ns_window.styleMask().contains(NSWindowStyleMask::FullScreen);
store_event_loop_proxy(proxy.clone());
let activation_hotkey = GlobalHotkeys::register(proxy, show_native_tabs);
let activation_hotkey = GlobalHotkeys::register(proxy, true);
let macos_window_feature = MacosWindowFeature {
ns_window,
@ -463,104 +464,6 @@ impl MacosWindowFeature {
}
}
fn begin_tab_overview(ns_window: &NSWindow) {
if ns_window.tabbingMode() == NSWindowTabbingMode::Disallowed {
return;
}
if Self::merge_windows_for_overview(ns_window) {
TAB_OVERVIEW_ACTIVE.with(|active| active.set(true));
ACTIVE_HOST_WINDOW.with(|cell| cell.set(0));
SUPPRESS_UNTIL_NEXT_KEY_EVENT.with(|cell| cell.set(true));
ns_window.toggleTabOverview(None);
}
}
fn merge_windows_for_overview(ns_window: &NSWindow) -> bool {
ns_window.mergeAllWindows(None);
if let Some(tab_group) = ns_window.tabGroup() {
let windows = tab_group.windows();
if windows.len() <= 1 {
return false;
}
tab_group.setSelectedWindow(Some(ns_window));
true
} else {
false
}
}
fn detach_tabs_after_overview(ns_window: &NSWindow) {
let should_detach = TAB_OVERVIEW_ACTIVE.with(|active| active.get());
if !should_detach {
return;
}
if should_show_native_tab_bar() {
TAB_OVERVIEW_ACTIVE.with(|active| active.set(false));
PENDING_DETACH_WINDOW.with(|ptr| ptr.set(0));
ACTIVE_HOST_WINDOW.with(|cell| cell.set(0));
ns_window.makeKeyAndOrderFront(None);
ns_window.orderFrontRegardless();
record_host_window(ns_window);
Self::apply_tab_bar_preference(ns_window);
if let Some(mtm) = MainThreadMarker::new() {
let app = NSApplication::sharedApplication(mtm);
app.setWindowsNeedUpdate(true);
}
return;
}
let Some(tab_group) = ns_window.tabGroup() else {
TAB_OVERVIEW_ACTIVE.with(|active| active.set(false));
return;
};
TAB_OVERVIEW_ACTIVE.with(|active| active.set(false));
PENDING_DETACH_WINDOW.with(|ptr| ptr.set(0));
ACTIVE_HOST_WINDOW.with(|cell| cell.set(0));
let _focus_guard = FocusSuppressionGuard::new();
PENDING_DETACH_WINDOW.with(|ptr| ptr.set(0));
if tab_group.isOverviewVisible() {
return;
}
let windows_array = tab_group.windows();
if windows_array.len() <= 1 {
TAB_OVERVIEW_ACTIVE.with(|active| active.set(false));
return;
}
let retained_windows: Vec<Retained<NSWindow>> =
windows_array.iter().map(|window| window.retain()).collect();
for window in &retained_windows {
let window_ref: &NSWindow = window.as_ref();
if ptr::eq(window_ref, ns_window) {
continue;
}
window_ref.moveTabToNewWindow(None);
window_ref.orderBack(None);
log::trace!(
"Detached tab window ptr={:?} from host={:?}",
window_identifier(window_ref),
window_identifier(ns_window)
);
Self::apply_tab_bar_preference(window_ref);
}
ns_window.makeKeyAndOrderFront(None);
ns_window.orderFrontRegardless();
record_host_window(ns_window);
Self::apply_tab_bar_preference(ns_window);
if let Some(mtm) = MainThreadMarker::new() {
let app = NSApplication::sharedApplication(mtm);
app.setWindowsNeedUpdate(true);
}
}
fn activate_app_and_focus_window(window: &NSWindow) {
let mtm = MainThreadMarker::new().expect("Window activation must be on the main thread.");
let app = NSApplication::sharedApplication(mtm);
@ -776,10 +679,6 @@ impl MacosWindowFeature {
}
}
pub fn is_simple_fullscreen_enabled(&self) -> bool {
self.simple_fullscreen
}
pub fn is_native_fullscreen_enabled(&self) -> bool {
self.is_fullscreen
}
@ -1224,170 +1123,29 @@ impl NewWindowHandler {
}
#[derive(Clone, Debug)]
struct TabOverviewHandlerIvars {}
struct EditorSwitcherMenuHandlerIvars {}
define_class!(
#[derive(Debug)]
#[unsafe(super = NSObject)]
#[thread_kind = MainThreadOnly]
#[ivars = TabOverviewHandlerIvars]
struct TabOverviewHandler;
#[ivars = EditorSwitcherMenuHandlerIvars]
struct EditorSwitcherMenuHandler;
impl TabOverviewHandler {
#[unsafe(method(neovideShowAllTabs:))]
fn show_all_tabs(&self, _sender: Option<&AnyObject>) {
trigger_tab_overview();
impl EditorSwitcherMenuHandler {
#[unsafe(method(neovideShowEditorSwitcher:))]
fn show_editor_switcher(&self, _sender: Option<&AnyObject>) {
request_editor_switcher();
}
}
);
impl TabOverviewHandler {
fn new(mtm: MainThreadMarker) -> Retained<TabOverviewHandler> {
impl EditorSwitcherMenuHandler {
fn new(mtm: MainThreadMarker) -> Retained<EditorSwitcherMenuHandler> {
unsafe { msg_send![Self::alloc(mtm), init] }
}
}
#[derive(Clone, Debug)]
struct TabOverviewNotificationHandlerIvars {}
define_class!(
#[derive(Debug)]
#[unsafe(super = NSObject)]
#[thread_kind = MainThreadOnly]
#[ivars = TabOverviewNotificationHandlerIvars]
struct TabOverviewNotificationHandler;
impl TabOverviewNotificationHandler {
#[unsafe(method(neovideWindowDidBecomeKey:))]
fn window_did_become_key(&self, notification: &NSNotification) {
if !TAB_OVERVIEW_ACTIVE.with(|active| active.get()) {
return;
}
let Some(object) = notification.object() else {
return;
};
let window: Retained<NSWindow> = object
.downcast()
.expect("notification object was not an NSWindow");
let window_ref: &NSWindow = window.as_ref();
let identifier = window_ref.tabbingIdentifier();
let identifier_ref: &NSString = identifier.as_ref();
if identifier_ref != ns_string!(NEOVIDE_TABBING_IDENTIFIER) {
log::trace!(
"WindowDidBecomeKey ignored (tab id = {})",
identifier_ref
);
return;
}
SUPPRESS_UNTIL_NEXT_KEY_EVENT.with(|cell| cell.set(false));
let ptr_value = window_identifier(window_ref);
let previous_host = ACTIVE_HOST_WINDOW.with(|cell| {
let previous = cell.get();
cell.set(ptr_value);
previous
});
if previous_host != 0 && previous_host != ptr_value {
log::trace!(
"WindowDidBecomeKey host switched from {:?} to {:?}",
previous_host as *const (),
window_identifier(window_ref)
);
}
let already_pending = PENDING_DETACH_WINDOW.with(|ptr| ptr.get() == ptr_value);
if already_pending {
log::trace!(
"WindowDidBecomeKey skipping duplicate scheduling (window ptr = {:?})",
window_identifier(window_ref)
);
return;
}
PENDING_DETACH_WINDOW.with(|ptr| ptr.set(ptr_value));
log::trace!(
"WindowDidBecomeKey scheduling detach (window ptr = {:?})",
window_identifier(window_ref)
);
unsafe {
self.schedule_detach(window);
}
}
#[unsafe(method(neovidePerformDetach:))]
fn perform_detach(&self, timer: &NSTimer) {
let Some(user_info) = timer.userInfo() else {
return;
};
let window: Retained<NSWindow> = user_info
.downcast()
.expect("timer userInfo was not an NSWindow");
let ptr_value = window_identifier(window.as_ref());
let host_ptr = ACTIVE_HOST_WINDOW.with(|cell| cell.get());
if host_ptr != 0 && host_ptr != ptr_value {
log::trace!(
"Detach timer ignoring stale window ptr = {:?} (active host = {:?})",
window_identifier(window.as_ref()),
host_ptr
);
return;
}
PENDING_DETACH_WINDOW.with(|ptr| ptr.set(0));
log::trace!(
"Detach timer fired for window ptr = {:?}",
window_identifier(window.as_ref())
);
MacosWindowFeature::detach_tabs_after_overview(window.as_ref());
}
}
);
impl TabOverviewNotificationHandler {
fn register(mtm: MainThreadMarker) -> Retained<TabOverviewNotificationHandler> {
let handler: Retained<TabOverviewNotificationHandler> =
unsafe { msg_send![mtm.alloc(), init] };
let center = NSNotificationCenter::defaultCenter();
unsafe {
center.addObserver_selector_name_object(
&handler,
sel!(neovideWindowDidBecomeKey:),
Some(NSWindowDidBecomeKeyNotification),
None,
);
}
log::trace!("Registered NSWindowDidBecomeKey observer");
handler
}
unsafe fn schedule_detach(&self, window: Retained<NSWindow>) {
log::trace!(
"Scheduling detach timer for window ptr = {:?}",
window_identifier(window.as_ref())
);
let _: Retained<NSTimer> = unsafe {
NSTimer::scheduledTimerWithTimeInterval_target_selector_userInfo_repeats(
0.0,
self,
sel!(neovidePerformDetach:),
Some(window.as_ref()),
false,
)
};
}
}
#[derive(Clone, Debug)]
struct WindowMenuDelegateIvars {}
@ -1537,8 +1295,7 @@ struct Menu {
quit_handler: Retained<QuitHandler>,
help_menu_handler: Retained<HelpMenuHandler>,
new_window_handler: Retained<NewWindowHandler>,
tab_overview_handler: Retained<TabOverviewHandler>,
_tab_overview_observer: Retained<TabOverviewNotificationHandler>,
editor_switcher_menu_handler: Retained<EditorSwitcherMenuHandler>,
_window_menu_observer: Retained<WindowMenuNotificationHandler>,
window_menu_delegate: Retained<WindowMenuDelegate>,
}
@ -1549,8 +1306,7 @@ impl Menu {
quit_handler: QuitHandler::new(mtm),
help_menu_handler: HelpMenuHandler::new(mtm),
new_window_handler: NewWindowHandler::new(mtm),
tab_overview_handler: TabOverviewHandler::new(mtm),
_tab_overview_observer: TabOverviewNotificationHandler::register(mtm),
editor_switcher_menu_handler: EditorSwitcherMenuHandler::new(mtm),
_window_menu_observer: WindowMenuNotificationHandler::register(mtm),
window_menu_delegate: WindowMenuDelegate::new(mtm),
};
@ -1674,18 +1430,16 @@ impl Menu {
create_new_window.setTarget(Some(&self.new_window_handler));
menu.addItem(&create_new_window);
if should_show_native_tab_bar() {
let show_all_tabs_item = NSMenuItem::new(mtm);
show_all_tabs_item.setTitle(ns_string!("Editors"));
apply_menu_item_hotkey(
&show_all_tabs_item,
&settings.system_show_all_tabs_hotkey,
"system_show_all_tabs_hotkey",
);
show_all_tabs_item.setAction(Some(sel!(neovideShowAllTabs:)));
show_all_tabs_item.setTarget(Some(&self.tab_overview_handler));
menu.addItem(&show_all_tabs_item);
}
let show_all_tabs_item = NSMenuItem::new(mtm);
show_all_tabs_item.setTitle(ns_string!("Editors"));
apply_menu_item_hotkey(
&show_all_tabs_item,
&settings.system_show_all_tabs_hotkey,
"system_show_all_tabs_hotkey",
);
show_all_tabs_item.setAction(Some(sel!(neovideShowEditorSwitcher:)));
show_all_tabs_item.setTarget(Some(&self.editor_switcher_menu_handler));
menu.addItem(&show_all_tabs_item);
let min_item = NSMenuItem::new(mtm);
min_item.setTitle(ns_string!("Minimize"));
@ -1753,7 +1507,7 @@ impl Menu {
let is_show_all_tabs_title = title_ref == ns_string!("Show All Tabs");
let is_neovide_show_all_tabs_action =
action.is_some_and(|sel| sel == sel!(neovideShowAllTabs:));
action.is_some_and(|sel| sel == sel!(neovideShowEditorSwitcher:));
let should_remove_system_show_all_tabs =
is_show_all_tabs_title && !is_neovide_show_all_tabs_action;
@ -1769,31 +1523,6 @@ impl Menu {
}
}
pub fn trigger_tab_overview() {
if !should_show_native_tab_bar() {
return;
}
if let Some(mtm) = MainThreadMarker::new() {
let app = NSApplication::sharedApplication(mtm);
if let Some(window) = app.keyWindow()
&& let Some(tab_group) = window.tabGroup()
&& tab_group.isOverviewVisible()
{
reset_tab_overview_state();
window.toggleTabOverview(None);
return;
}
if let Some(window) = app.keyWindow() {
MacosWindowFeature::begin_tab_overview(&window);
}
}
}
pub fn is_tab_overview_active() -> bool {
TAB_OVERVIEW_ACTIVE.with(|active| active.get())
}
pub fn register_file_handler() {
fn dispatch_file_drops(filenames: &NSArray<NSString>) {
for filename in filenames.iter() {
@ -1884,18 +1613,6 @@ pub fn register_file_handler() {
NSUserDefaults::standardUserDefaults().registerDefaults(&dict);
}
}
pub fn window_identifier(window: &NSWindow) -> usize {
window as *const _ as usize
}
pub fn record_host_window(window: &NSWindow) {
LAST_HOST_WINDOW.with(|cell| cell.set(window_identifier(window)));
}
pub fn get_last_host_window() -> usize {
LAST_HOST_WINDOW.with(|cell| cell.get())
}
pub fn hide_application() {
match MainThreadMarker::new() {
Some(mtm) => {

View file

@ -0,0 +1,192 @@
use objc2::{MainThreadOnly, define_class, msg_send, rc::Retained, runtime::Sel};
use objc2_app_kit::{
NSBackingStoreType, NSControl, NSControlTextEditingDelegate, NSEvent, NSEventType, NSTextField,
NSTextFieldDelegate, NSTextView, NSView, NSWindow, NSWindowStyleMask,
};
use objc2_foundation::{MainThreadMarker, NSNotification, NSObject, NSObjectProtocol, NSRect};
use super::{
activate_editor_switcher_row, activate_selected_editor_switcher_row, close_editor_switcher,
handle_editor_switcher_command_selector, handle_editor_switcher_key,
handle_editor_switcher_toggle_key, update_editor_switcher_query_from_search_field,
};
define_class!(
#[derive(Debug)]
#[unsafe(super = NSWindow)]
#[thread_kind = MainThreadOnly]
pub struct EditorSwitcherWindow;
impl EditorSwitcherWindow {
#[unsafe(method(sendEvent:))]
fn send_event(&self, event: &NSEvent) {
if event.r#type() == NSEventType::KeyDown && handle_editor_switcher_toggle_key(event) {
return;
}
unsafe {
let _: () = msg_send![super(self), sendEvent: event];
}
}
}
);
impl EditorSwitcherWindow {
pub fn new(
mtm: MainThreadMarker,
frame: NSRect,
style: NSWindowStyleMask,
) -> Retained<EditorSwitcherWindow> {
unsafe {
msg_send![
Self::alloc(mtm),
initWithContentRect: frame,
styleMask: style,
backing: NSBackingStoreType::Buffered,
defer: false,
]
}
}
}
define_class!(
#[derive(Debug)]
#[unsafe(super = NSView)]
#[thread_kind = MainThreadOnly]
pub struct EditorSwitcherView;
impl EditorSwitcherView {
#[unsafe(method(acceptsFirstResponder))]
fn accepts_first_responder(&self) -> bool {
true
}
#[unsafe(method(isFlipped))]
fn is_flipped(&self) -> bool {
true
}
#[unsafe(method(keyDown:))]
fn key_down(&self, event: &NSEvent) {
handle_editor_switcher_key(event);
}
}
);
impl EditorSwitcherView {
pub fn new(mtm: MainThreadMarker, frame: NSRect) -> Retained<Self> {
unsafe { msg_send![Self::alloc(mtm), initWithFrame: frame] }
}
}
define_class!(
#[derive(Debug)]
#[unsafe(super = NSTextField)]
#[thread_kind = MainThreadOnly]
pub struct EditorSwitcherSearchField;
impl EditorSwitcherSearchField {
#[unsafe(method(keyDown:))]
fn key_down(&self, event: &NSEvent) {
if handle_editor_switcher_toggle_key(event) {
return;
}
unsafe {
let _: () = msg_send![super(self), keyDown: event];
}
}
}
);
impl EditorSwitcherSearchField {
pub fn new(mtm: MainThreadMarker, frame: NSRect) -> Retained<Self> {
unsafe { msg_send![Self::alloc(mtm), initWithFrame: frame] }
}
}
define_class!(
#[derive(Debug)]
#[unsafe(super = NSView)]
#[thread_kind = MainThreadOnly]
pub struct EditorSwitcherDocumentView;
impl EditorSwitcherDocumentView {
#[unsafe(method(isFlipped))]
fn is_flipped(&self) -> bool {
true
}
}
);
impl EditorSwitcherDocumentView {
pub fn new(mtm: MainThreadMarker, frame: NSRect) -> Retained<Self> {
unsafe { msg_send![Self::alloc(mtm), initWithFrame: frame] }
}
}
define_class!(
#[derive(Debug)]
#[unsafe(super = NSObject)]
#[thread_kind = MainThreadOnly]
pub struct EditorSwitcherActionHandler;
impl EditorSwitcherActionHandler {
#[unsafe(method(editorSwitcherRowClicked:))]
fn row_clicked(&self, sender: &NSControl) {
activate_editor_switcher_row(sender.tag() as usize);
}
#[unsafe(method(editorSwitcherOpenSelected:))]
fn open_selected(&self, _sender: &NSControl) {
activate_selected_editor_switcher_row();
}
#[unsafe(method(editorSwitcherClose:))]
fn close(&self, _sender: &NSControl) {
close_editor_switcher();
}
}
unsafe impl NSObjectProtocol for EditorSwitcherActionHandler {}
);
impl EditorSwitcherActionHandler {
pub fn new(mtm: MainThreadMarker) -> Retained<Self> {
unsafe { msg_send![Self::alloc(mtm), init] }
}
}
define_class!(
#[derive(Debug)]
#[unsafe(super = NSObject)]
#[thread_kind = MainThreadOnly]
pub struct EditorSwitcherSearchDelegate;
impl EditorSwitcherSearchDelegate {
#[unsafe(method(controlTextDidChange:))]
fn control_text_did_change(&self, _obj: &NSNotification) {
update_editor_switcher_query_from_search_field();
}
#[unsafe(method(control:textView:doCommandBySelector:))]
unsafe fn control_text_view_do_command_by_selector(
&self,
_control: &NSControl,
_text_view: &NSTextView,
command_selector: Sel,
) -> bool {
handle_editor_switcher_command_selector(command_selector)
}
}
unsafe impl NSObjectProtocol for EditorSwitcherSearchDelegate {}
unsafe impl NSControlTextEditingDelegate for EditorSwitcherSearchDelegate {}
unsafe impl NSTextFieldDelegate for EditorSwitcherSearchDelegate {}
);
impl EditorSwitcherSearchDelegate {
pub fn new(mtm: MainThreadMarker) -> Retained<Self> {
unsafe { msg_send![Self::alloc(mtm), init] }
}
}

View file

@ -0,0 +1,19 @@
use winit::event::{ElementState, KeyEvent, Modifiers};
use crate::window::macos::tab_navigation::KeyCombo;
const SWITCHER_ENV_VAR: &str = "NEOVIDE_SYSTEM_SWITCHER_HOTKEY";
const DEFAULT_SWITCHER_HOTKEY: &str = "cmd+ctrl+n";
pub fn editor_switcher_key_event_matches(event: &KeyEvent, modifiers: &Modifiers) -> bool {
event.state == ElementState::Pressed
&& editor_switcher_toggle_shortcut()
.is_some_and(|shortcut| shortcut.matches_key_event(event, modifiers))
}
pub fn editor_switcher_toggle_shortcut() -> Option<KeyCombo> {
let shortcut =
std::env::var(SWITCHER_ENV_VAR).ok().unwrap_or_else(|| DEFAULT_SWITCHER_HOTKEY.to_string());
KeyCombo::parse(&shortcut)
}

View file

@ -0,0 +1,64 @@
pub struct Panel;
impl Panel {
pub const WIDTH: f64 = 600.0;
pub const HEIGHT: f64 = 620.0;
pub const TITLE_ORIGINAL_HEIGHT: f64 = 28.0;
pub const HORIZONTAL_PADDING: f64 = 20.0;
pub const TITLE_ADJUSTMENT: f64 = Self::TITLE_ORIGINAL_HEIGHT - Search::VERTICAL_PADDING;
pub const CONTENT_HEIGHT: f64 = Self::HEIGHT - Self::TITLE_ADJUSTMENT;
}
pub struct Search;
impl Search {
pub const FONT_SIZE: f64 = 20.0;
pub const VERTICAL_PADDING: f64 = 20.0;
pub const FIELD_TOP_OFFSET: f64 = -2.0;
pub const FIELD_HEIGHT_EXTRA: f64 = 8.0;
pub const SECTION_HEIGHT: f64 = Self::VERTICAL_PADDING * 2.0 + Self::FONT_SIZE;
}
pub struct Row;
impl Row {
pub const HEIGHT: f64 = 48.0;
pub const VERTICAL_GAP: f64 = 2.0;
pub const BUTTON_HEIGHT: f64 = Self::HEIGHT - Self::VERTICAL_GAP;
pub const CORNER_RADIUS: f64 = 6.0;
pub const ICON_SIZE: f64 = 16.0;
pub const ICON_TITLE_SPACING: f64 = 16.0;
pub const TITLE_FONT_SIZE: f64 = 16.0;
pub const LABEL_HEIGHT: f64 = 20.0;
}
pub struct Results;
impl Results {
pub const OUTER_PADDING: f64 = 6.0;
pub const INNER_PADDING: f64 = Panel::HORIZONTAL_PADDING - Self::OUTER_PADDING;
pub const BOTTOM_PADDING: f64 = 20.0;
pub const HEIGHT_ADJUSTMENT: f64 = 16.0;
pub const CONTAINER_HEIGHT: f64 =
Panel::HEIGHT - Search::SECTION_HEIGHT - BottomBar::HEIGHT - Self::HEIGHT_ADJUSTMENT;
pub const EMPTY_LABEL_Y: f64 = 22.0;
pub const EMPTY_LABEL_HEIGHT: f64 = 24.0;
pub const EMPTY_LABEL_FONT_SIZE: f64 = 16.0;
}
pub struct BottomBar;
impl BottomBar {
pub const VERTICAL_PADDING: f64 = 6.0;
pub const FONT_SIZE: f64 = 12.0;
pub const SHORTCUT_FONT_SIZE: f64 = Self::FONT_SIZE + 3.0;
pub const BUTTON_VERTICAL_PADDING: f64 = 2.0;
pub const BUTTON_LEADING_PADDING: f64 = 8.0;
pub const BUTTON_TRAILING_PADDING: f64 = 2.0;
pub const BUTTON_SPACING: f64 = 5.0;
pub const LABEL_SHORTCUT_SPACING: f64 = 8.0;
pub const SHORTCUT_SPACING: f64 = 2.0;
pub const SHORTCUT_HORIZONTAL_PADDING: f64 = 6.0;
pub const SHORTCUT_VERTICAL_PADDING: f64 = 1.0;
pub const BUTTON_HEIGHT: f64 = 20.0;
pub const BUTTON_CORNER_RADIUS: f64 = 3.0;
pub const SHORTCUT_CHIP_CORNER_RADIUS: f64 = 2.0;
pub const HEIGHT: f64 =
Self::VERTICAL_PADDING * 2.0 + Self::FONT_SIZE + Self::BUTTON_VERTICAL_PADDING * 2.0;
pub const HORIZONTAL_PADDING: f64 = Panel::HORIZONTAL_PADDING - Self::BUTTON_TRAILING_PADDING;
}

View file

@ -0,0 +1,389 @@
use std::cell::RefCell;
use objc2::{rc::Retained, runtime::Sel, sel};
use objc2_app_kit::{NSApplication, NSEvent, NSEventModifierFlags, NSImage, NSTextField, NSWindow};
use objc2_foundation::{MainThreadMarker, NSString};
use winit::window::WindowId;
use crate::window::macos::tab_navigation::KeyCombo;
use self::appkit::{
EditorSwitcherActionHandler, EditorSwitcherDocumentView, EditorSwitcherSearchDelegate,
};
pub use self::hotkey::editor_switcher_key_event_matches;
pub use self::row::EditorSwitcherRow;
use self::row::editor_switcher_row_matches;
use self::ui::rebuild_editor_switcher_rows;
pub use self::ui::show_editor_switcher_panel;
use super::request_activate_window;
mod appkit;
mod hotkey;
mod layout;
mod row;
mod ui;
const KEY_ESCAPE: u16 = 53;
const KEY_RETURN: u16 = 36;
const KEY_KEYPAD_ENTER: u16 = 76;
const KEY_DOWN: u16 = 125;
const KEY_UP: u16 = 126;
const KEY_P: u16 = 35;
const KEY_N: u16 = 45;
const KEY_TAB: u16 = 48;
const KEY_DELETE: u16 = 51;
const KEY_FORWARD_DELETE: u16 = 117;
thread_local! {
static EDITOR_SWITCHER_STATE: RefCell<Option<EditorSwitcherState>> = const { RefCell::new(None) };
}
struct EditorSwitcherState {
window: Retained<NSWindow>,
search_field: Retained<NSTextField>,
results_container: Retained<EditorSwitcherDocumentView>,
row_action_handler: Retained<EditorSwitcherActionHandler>,
_search_delegate: Retained<EditorSwitcherSearchDelegate>,
rows: Vec<EditorSwitcherRow>,
icon: Option<Retained<NSImage>>,
filtered_indices: Vec<usize>,
selected_index: usize,
query: String,
toggle_shortcut: Option<KeyCombo>,
}
struct EditorSwitcherStateParts {
window: Retained<NSWindow>,
search_field: Retained<NSTextField>,
results_container: Retained<EditorSwitcherDocumentView>,
row_action_handler: Retained<EditorSwitcherActionHandler>,
search_delegate: Retained<EditorSwitcherSearchDelegate>,
rows: Vec<EditorSwitcherRow>,
icon: Option<Retained<NSImage>>,
toggle_shortcut: Option<KeyCombo>,
}
impl EditorSwitcherState {
fn new(parts: EditorSwitcherStateParts) -> Self {
let EditorSwitcherStateParts {
window,
search_field,
results_container,
row_action_handler,
search_delegate,
rows,
icon,
toggle_shortcut,
} = parts;
let mut state = Self {
window,
search_field,
results_container,
row_action_handler,
_search_delegate: search_delegate,
rows,
icon,
filtered_indices: Vec::new(),
selected_index: 0,
query: String::new(),
toggle_shortcut,
};
state.refresh_filter();
state.selected_index = state
.filtered_indices
.iter()
.position(|index| !state.rows[*index].is_current)
.unwrap_or(0);
state
}
fn refresh_filter(&mut self) {
let query = self.query.trim().to_lowercase();
self.filtered_indices = self
.rows
.iter()
.enumerate()
.filter_map(|(index, row)| editor_switcher_row_matches(row, &query).then_some(index))
.collect();
if self.filtered_indices.is_empty() {
self.selected_index = 0;
} else if self.selected_index >= self.filtered_indices.len() {
self.selected_index = self.filtered_indices.len() - 1;
}
}
fn push_query(&mut self, text: &str) {
self.query.push_str(text);
self.selected_index = 0;
self.refresh_filter();
}
fn pop_query(&mut self) {
self.query.pop();
self.selected_index = 0;
self.refresh_filter();
}
fn move_selection(&mut self, delta: isize) {
let len = self.filtered_indices.len();
if len == 0 {
self.selected_index = 0;
return;
}
self.selected_index =
(self.selected_index as isize + delta).rem_euclid(len as isize) as usize;
}
fn selected_window(&self) -> Option<WindowId> {
self.filtered_indices.get(self.selected_index).map(|index| self.rows[*index].window_id)
}
fn update_view(&self) {
let query = NSString::from_str(&self.query);
self.search_field.setStringValue(&query);
self.update_results_view();
}
fn update_results_view(&self) {
rebuild_editor_switcher_rows(self);
}
}
enum EditorSwitcherKeyAction {
None,
Close,
Activate(WindowId),
Pass,
}
fn handle_editor_switcher_key(event: &NSEvent) {
let key_code = event.keyCode();
let flags = event.modifierFlags();
let action = EDITOR_SWITCHER_STATE.with(|cell| {
let mut state_ref = cell.borrow_mut();
let Some(state) = state_ref.as_mut() else {
return EditorSwitcherKeyAction::Pass;
};
let action = if state
.toggle_shortcut
.as_ref()
.is_some_and(|shortcut| shortcut.matches_nsevent(event))
{
EditorSwitcherKeyAction::Close
} else {
match key_code {
KEY_ESCAPE => EditorSwitcherKeyAction::Close,
KEY_RETURN | KEY_KEYPAD_ENTER => state
.selected_window()
.map(EditorSwitcherKeyAction::Activate)
.unwrap_or(EditorSwitcherKeyAction::Close),
KEY_DOWN => {
state.move_selection(1);
EditorSwitcherKeyAction::None
}
KEY_UP => {
state.move_selection(-1);
EditorSwitcherKeyAction::None
}
KEY_P if flags.contains(NSEventModifierFlags::Control) => {
state.move_selection(-1);
EditorSwitcherKeyAction::None
}
KEY_N if flags.contains(NSEventModifierFlags::Control) => {
state.move_selection(1);
EditorSwitcherKeyAction::None
}
KEY_TAB => {
let delta = if flags.contains(NSEventModifierFlags::Shift) { -1 } else { 1 };
state.move_selection(delta);
EditorSwitcherKeyAction::None
}
KEY_DELETE | KEY_FORWARD_DELETE => {
state.pop_query();
EditorSwitcherKeyAction::None
}
_ => {
if flags
.intersects(NSEventModifierFlags::Command | NSEventModifierFlags::Control)
{
EditorSwitcherKeyAction::None
} else if let Some(chars) = event.characters() {
let text = chars.to_string();
if text.chars().all(|character| !character.is_control()) {
state.push_query(&text);
}
EditorSwitcherKeyAction::None
} else {
EditorSwitcherKeyAction::None
}
}
}
};
if matches!(action, EditorSwitcherKeyAction::None) {
state.update_view();
}
action
});
match action {
EditorSwitcherKeyAction::None | EditorSwitcherKeyAction::Pass => {}
EditorSwitcherKeyAction::Close => close_editor_switcher(),
EditorSwitcherKeyAction::Activate(window_id) => {
close_editor_switcher();
request_activate_window(window_id);
}
}
}
fn handle_editor_switcher_toggle_key(event: &NSEvent) -> bool {
let should_close = EDITOR_SWITCHER_STATE.with(|cell| {
cell.borrow().as_ref().is_some_and(|state| {
state.toggle_shortcut.as_ref().is_some_and(|shortcut| shortcut.matches_nsevent(event))
})
});
if should_close {
close_editor_switcher();
}
should_close
}
fn handle_editor_switcher_current_toggle_key() -> bool {
let Some(mtm) = MainThreadMarker::new() else {
return false;
};
NSApplication::sharedApplication(mtm)
.currentEvent()
.as_deref()
.is_some_and(handle_editor_switcher_toggle_key)
}
fn update_editor_switcher_query_from_search_field() {
EDITOR_SWITCHER_STATE.with(|cell| {
let mut state_ref = cell.borrow_mut();
let Some(state) = state_ref.as_mut() else {
return;
};
state.query = state.search_field.stringValue().to_string();
state.selected_index = 0;
state.refresh_filter();
state.update_results_view();
});
}
fn editor_switcher_action_for_command_selector(
command_selector: Sel,
state: &mut EditorSwitcherState,
) -> EditorSwitcherKeyAction {
if command_selector == sel!(cancelOperation:) {
return EditorSwitcherKeyAction::Close;
}
if selector_is_newline(command_selector) {
return state
.selected_window()
.map(EditorSwitcherKeyAction::Activate)
.unwrap_or(EditorSwitcherKeyAction::Close);
}
if let Some(delta) = selector_selection_delta(command_selector) {
state.move_selection(delta);
state.update_results_view();
return EditorSwitcherKeyAction::None;
}
EditorSwitcherKeyAction::Pass
}
fn selector_is_newline(command_selector: Sel) -> bool {
command_selector == sel!(insertNewline:)
|| command_selector == sel!(insertNewlineIgnoringFieldEditor:)
}
fn selector_selection_delta(command_selector: Sel) -> Option<isize> {
if command_selector == sel!(moveUp:) || command_selector == sel!(insertBacktab:) {
Some(-1)
} else if command_selector == sel!(moveDown:) || command_selector == sel!(insertTab:) {
Some(1)
} else {
None
}
}
fn handle_editor_switcher_command_selector(command_selector: Sel) -> bool {
if handle_editor_switcher_current_toggle_key() {
return true;
}
let action = EDITOR_SWITCHER_STATE.with(|cell| {
let mut state_ref = cell.borrow_mut();
let Some(state) = state_ref.as_mut() else {
return EditorSwitcherKeyAction::Pass;
};
editor_switcher_action_for_command_selector(command_selector, state)
});
match action {
EditorSwitcherKeyAction::None => true,
EditorSwitcherKeyAction::Close => {
close_editor_switcher();
true
}
EditorSwitcherKeyAction::Activate(window_id) => {
close_editor_switcher();
request_activate_window(window_id);
true
}
EditorSwitcherKeyAction::Pass => false,
}
}
pub fn close_editor_switcher_if_open() -> bool {
EDITOR_SWITCHER_STATE.with(|cell| {
if let Some(state) = cell.borrow_mut().take() {
state.window.orderOut(None);
true
} else {
false
}
})
}
fn close_editor_switcher() {
close_editor_switcher_if_open();
}
fn activate_editor_switcher_row(row: usize) {
let selected_window = EDITOR_SWITCHER_STATE.with(|cell| {
let mut state_ref = cell.borrow_mut();
let state = state_ref.as_mut()?;
state.selected_index = row.min(state.filtered_indices.len().saturating_sub(1));
state.selected_window()
});
if let Some(window_id) = selected_window {
close_editor_switcher();
request_activate_window(window_id);
}
}
fn activate_selected_editor_switcher_row() {
let selected_window = EDITOR_SWITCHER_STATE
.with(|cell| cell.borrow().as_ref().and_then(|state| state.selected_window()));
if let Some(window_id) = selected_window {
close_editor_switcher();
request_activate_window(window_id);
}
}

View file

@ -0,0 +1,23 @@
use winit::window::WindowId;
#[derive(Clone, Debug)]
pub struct EditorSwitcherRow {
pub window_id: WindowId,
pub title: String,
pub subtitle: String,
pub modified: bool,
pub is_current: bool,
}
pub fn editor_switcher_row_matches(row: &EditorSwitcherRow, query: &str) -> bool {
if query.is_empty() {
return true;
}
let title = row.title.to_lowercase();
let subtitle = row.subtitle.to_lowercase();
let modified_state = if row.modified { "modified" } else { "" };
query.split_whitespace().all(|term| {
title.contains(term) || subtitle.contains(term) || modified_state.contains(term)
})
}

View file

@ -0,0 +1,678 @@
use objc2::{
MainThreadOnly,
rc::Retained,
runtime::{AnyObject, ProtocolObject, Sel},
sel,
};
use objc2_app_kit::{
NSApplication, NSBezelStyle, NSButton, NSButtonType, NSColor, NSControl, NSFloatingWindowLevel,
NSFocusRingType, NSFont, NSFontWeight, NSImage, NSImageScaling, NSImageView, NSLineBreakMode,
NSResponder, NSScrollView, NSTextAlignment, NSTextField, NSTextFieldDelegate, NSView, NSWindow,
NSWindowButton, NSWindowStyleMask, NSWindowTitleVisibility,
};
use objc2_foundation::{MainThreadMarker, NSInteger, NSPoint, NSRect, NSSize, NSString, ns_string};
use super::super::load_neovide_icon;
use super::appkit::{
EditorSwitcherActionHandler, EditorSwitcherDocumentView, EditorSwitcherSearchDelegate,
EditorSwitcherSearchField, EditorSwitcherView, EditorSwitcherWindow,
};
use super::hotkey::editor_switcher_toggle_shortcut;
use super::layout::{BottomBar, Panel, Results, Row, Search};
use super::row::EditorSwitcherRow;
use super::{
EDITOR_SWITCHER_STATE, EditorSwitcherState, EditorSwitcherStateParts, close_editor_switcher,
};
const SEARCH_PLACEHOLDER: &str = "Search...";
const EMPTY_RESULTS_TEXT: &str = "No matching windows";
const FOOTER_CLOSE_TEXT: &str = "Close";
const FOOTER_OPEN_SELECTED_TEXT: &str = "Open Selected";
const FOOTER_CLOSE_SHORTCUTS: &[&str] = &["esc"];
const FOOTER_OPEN_SELECTED_SHORTCUTS: &[&str] = &[""];
const SELECTED_ROW_ALPHA: f64 = 0.10;
const BOTTOM_BAR_ALPHA: f64 = 0.10;
const SHORTCUT_CHIP_ALPHA: f64 = 0.05;
struct FooterShortcutChip {
view: Retained<NSView>,
width: f64,
height: f64,
}
struct EditorSwitcherPanelShell {
container_view: Retained<NSView>,
root_view: Retained<EditorSwitcherView>,
}
struct EditorSwitcherResultsArea {
scroll_view: Retained<NSScrollView>,
results_container: Retained<EditorSwitcherDocumentView>,
}
struct EditorSwitcherSearchViews {
field: Retained<NSTextField>,
delegate: Retained<EditorSwitcherSearchDelegate>,
}
struct FooterButtonSpec {
text: &'static str,
shortcuts: &'static [&'static str],
action: Sel,
}
#[derive(Clone, Copy)]
enum EditorSwitcherLabelStyle {
Primary,
Secondary,
Footer,
Shortcut,
}
pub fn show_editor_switcher_panel(rows: Vec<EditorSwitcherRow>, custom_icon_path: Option<&String>) {
let Some(mtm) = MainThreadMarker::new() else {
log::warn!("Editor switcher requested off the main thread");
return;
};
close_editor_switcher();
let background = NSColor::controlBackgroundColor();
let window = editor_switcher_window(mtm, background.as_ref());
let EditorSwitcherPanelShell { container_view, root_view } =
editor_switcher_panel_shell(mtm, background.as_ref());
let EditorSwitcherSearchViews { field: search_field, delegate: search_delegate } =
editor_switcher_search_field(mtm);
let root_ns_view: &NSView = root_view.as_ref();
root_ns_view.addSubview(search_field.as_ref());
root_ns_view.addSubview(editor_switcher_divider(mtm).as_ref());
let EditorSwitcherResultsArea { scroll_view, results_container } =
editor_switcher_results_area(mtm);
root_ns_view.addSubview(scroll_view.as_ref());
let row_action_handler = EditorSwitcherActionHandler::new(mtm);
let bottom_bar_background = editor_switcher_bottom_bar_background();
root_ns_view.addSubview(
editor_switcher_bottom_bar(
mtm,
row_action_handler.as_ref(),
bottom_bar_background.as_ref(),
)
.as_ref(),
);
container_view.addSubview(
editor_switcher_bottom_bar_gap_fill(mtm, bottom_bar_background.as_ref()).as_ref(),
);
root_ns_view.setFrame(NSRect::new(
NSPoint::new(0.0, Panel::TITLE_ADJUSTMENT),
NSSize::new(Panel::WIDTH, Panel::CONTENT_HEIGHT),
));
container_view.addSubview(root_ns_view);
window.setContentView(Some(container_view.as_ref()));
let icon = load_neovide_icon(custom_icon_path);
let state = EditorSwitcherState::new(EditorSwitcherStateParts {
window,
search_field,
results_container,
row_action_handler,
search_delegate,
rows,
icon,
toggle_shortcut: editor_switcher_toggle_shortcut(),
});
present_editor_switcher(mtm, state);
}
pub fn rebuild_editor_switcher_rows(state: &EditorSwitcherState) {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let container: &NSView = state.results_container.as_ref();
for subview in container.subviews().iter() {
subview.removeFromSuperview();
}
let visible_width = Panel::WIDTH;
let scroll_view_height = Results::CONTAINER_HEIGHT;
if state.filtered_indices.is_empty() {
container.setFrameSize(NSSize::new(visible_width, scroll_view_height));
let empty_label = editor_switcher_empty_results_label(mtm, visible_width);
container.addSubview(empty_label.as_ref());
return;
}
let document_height = (state.filtered_indices.len() as f64 * Row::HEIGHT
+ Results::BOTTOM_PADDING)
.max(scroll_view_height);
container.setFrameSize(NSSize::new(visible_width, document_height));
for (row_index, row_data_index) in state.filtered_indices.iter().copied().enumerate() {
let row = &state.rows[row_data_index];
let selected = row_index == state.selected_index;
let y = row_index as f64 * Row::HEIGHT;
let row_view = editor_switcher_row_button(
mtm,
row,
row_index,
selected,
state.icon.as_deref(),
state.row_action_handler.as_ref(),
);
let row_ns_view: &NSView = row_view.as_ref();
row_ns_view.setFrame(NSRect::new(
NSPoint::new(Results::OUTER_PADDING, y),
NSSize::new(visible_width - Results::OUTER_PADDING * 2.0, Row::BUTTON_HEIGHT),
));
container.addSubview(row_view.as_ref());
}
scroll_selected_editor_switcher_row_into_view(container, state.selected_index);
}
fn present_editor_switcher(mtm: MainThreadMarker, state: EditorSwitcherState) {
state.update_results_view();
let app = NSApplication::sharedApplication(mtm);
#[allow(deprecated)]
app.activateIgnoringOtherApps(true);
let switcher_window = state.window.clone();
let switcher_search_field = state.search_field.clone();
switcher_window.makeKeyAndOrderFront(None);
EDITOR_SWITCHER_STATE.with(|cell| {
*cell.borrow_mut() = Some(state);
});
let search_view: &NSView = switcher_search_field.as_ref();
let responder: &NSResponder = search_view.as_ref();
switcher_window.makeFirstResponder(Some(responder));
}
fn editor_switcher_window(mtm: MainThreadMarker, background: &NSColor) -> Retained<NSWindow> {
let style = NSWindowStyleMask::Titled
| NSWindowStyleMask::Closable
| NSWindowStyleMask::FullSizeContentView;
let window: Retained<NSWindow> =
EditorSwitcherWindow::new(mtm, editor_switcher_panel_frame(), style).into_super();
window.setTitle(ns_string!("Editors"));
window.setTitleVisibility(NSWindowTitleVisibility::Hidden);
window.setTitlebarAppearsTransparent(true);
window.setLevel(NSFloatingWindowLevel);
window.setHasShadow(true);
window.setMovableByWindowBackground(true);
window.setOpaque(false);
window.setBackgroundColor(Some(background));
hide_editor_switcher_window_buttons(&window);
unsafe {
window.setReleasedWhenClosed(false);
}
window.center();
window
}
fn editor_switcher_panel_shell(
mtm: MainThreadMarker,
background: &NSColor,
) -> EditorSwitcherPanelShell {
let root_view = EditorSwitcherView::new(mtm, editor_switcher_content_frame());
let root_ns_view: &NSView = root_view.as_ref();
set_editor_switcher_view_background(root_ns_view, background, 0.0);
let container_view = NSView::initWithFrame(NSView::alloc(mtm), editor_switcher_panel_frame());
set_editor_switcher_view_background(container_view.as_ref(), background, 0.0);
EditorSwitcherPanelShell { container_view, root_view }
}
fn editor_switcher_search_field(mtm: MainThreadMarker) -> EditorSwitcherSearchViews {
let search_frame = NSRect::new(
NSPoint::new(
Panel::HORIZONTAL_PADDING,
Search::VERTICAL_PADDING + Search::FIELD_TOP_OFFSET,
),
NSSize::new(
Panel::WIDTH - Panel::HORIZONTAL_PADDING * 2.0,
Search::FONT_SIZE + Search::FIELD_HEIGHT_EXTRA,
),
);
let search_field: Retained<NSTextField> =
EditorSwitcherSearchField::new(mtm, search_frame).into_super();
let search_placeholder = NSString::from_str(SEARCH_PLACEHOLDER);
search_field.setPlaceholderString(Some(&search_placeholder));
search_field.setBezeled(false);
search_field.setBordered(false);
search_field.setDrawsBackground(false);
search_field.setEditable(true);
search_field.setSelectable(true);
search_field.setTextColor(Some(NSColor::labelColor().as_ref()));
let search_view: &NSView = search_field.as_ref();
search_view.setFocusRingType(NSFocusRingType::None);
let search_control: &NSControl = search_field.as_ref();
search_control.setFont(Some(NSFont::systemFontOfSize(Search::FONT_SIZE).as_ref()));
search_control.setRefusesFirstResponder(false);
let search_delegate = EditorSwitcherSearchDelegate::new(mtm);
unsafe {
let search_delegate_object: &EditorSwitcherSearchDelegate = search_delegate.as_ref();
let search_delegate_protocol: &ProtocolObject<dyn NSTextFieldDelegate> =
ProtocolObject::from_ref(search_delegate_object);
search_field.setDelegate(Some(search_delegate_protocol));
}
EditorSwitcherSearchViews { field: search_field, delegate: search_delegate }
}
fn editor_switcher_divider(mtm: MainThreadMarker) -> Retained<NSView> {
let divider = NSView::initWithFrame(
NSView::alloc(mtm),
NSRect::new(NSPoint::new(0.0, Search::SECTION_HEIGHT), NSSize::new(Panel::WIDTH, 1.0)),
);
set_editor_switcher_view_background(divider.as_ref(), NSColor::separatorColor().as_ref(), 0.0);
divider
}
fn editor_switcher_results_area(mtm: MainThreadMarker) -> EditorSwitcherResultsArea {
let scroll_y = Search::SECTION_HEIGHT + Results::OUTER_PADDING;
let scroll_height = Results::CONTAINER_HEIGHT;
let scroll_view = NSScrollView::initWithFrame(
NSScrollView::alloc(mtm),
NSRect::new(NSPoint::new(0.0, scroll_y), NSSize::new(Panel::WIDTH, scroll_height)),
);
scroll_view.setDrawsBackground(false);
scroll_view.setBorderType(objc2_app_kit::NSBorderType::NoBorder);
scroll_view.setHasVerticalScroller(true);
scroll_view.setHasHorizontalScroller(false);
scroll_view.setAutohidesScrollers(true);
let results_container = EditorSwitcherDocumentView::new(
mtm,
NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(Panel::WIDTH, scroll_height)),
);
let results_view: &NSView = results_container.as_ref();
scroll_view.setDocumentView(Some(results_view));
EditorSwitcherResultsArea { scroll_view, results_container }
}
fn editor_switcher_bottom_bar(
mtm: MainThreadMarker,
row_action_handler: &EditorSwitcherActionHandler,
background: &NSColor,
) -> Retained<NSView> {
let bottom_bar_height = editor_switcher_bottom_bar_height();
let bottom_bar = NSView::initWithFrame(
NSView::alloc(mtm),
NSRect::new(
NSPoint::new(0.0, editor_switcher_bottom_bar_y()),
NSSize::new(Panel::WIDTH, bottom_bar_height),
),
);
set_editor_switcher_view_background(bottom_bar.as_ref(), background, 0.0);
let mut trailing_edge = Panel::WIDTH - BottomBar::HORIZONTAL_PADDING;
for spec in editor_switcher_footer_buttons_from_trailing_edge() {
let button = editor_switcher_bottom_bar_button(mtm, spec.text, spec.shortcuts, spec.action);
let button_view: &NSView = button.as_ref();
let button_size = button_view.frame().size;
trailing_edge -= button_size.width;
button_view.setFrame(NSRect::new(
NSPoint::new(trailing_edge, (bottom_bar_height - button_size.height) / 2.0),
button_size,
));
trailing_edge -= BottomBar::BUTTON_SPACING;
let button_control: &NSControl = button.as_ref();
set_editor_switcher_control_target(button_control, row_action_handler);
bottom_bar.addSubview(button.as_ref());
}
bottom_bar
}
fn editor_switcher_bottom_bar_background() -> Retained<NSColor> {
NSColor::blackColor().colorWithAlphaComponent(BOTTOM_BAR_ALPHA)
}
fn editor_switcher_footer_buttons_from_trailing_edge() -> [FooterButtonSpec; 2] {
[
FooterButtonSpec {
text: FOOTER_CLOSE_TEXT,
shortcuts: FOOTER_CLOSE_SHORTCUTS,
action: sel!(editorSwitcherClose:),
},
FooterButtonSpec {
text: FOOTER_OPEN_SELECTED_TEXT,
shortcuts: FOOTER_OPEN_SELECTED_SHORTCUTS,
action: sel!(editorSwitcherOpenSelected:),
},
]
}
fn editor_switcher_bottom_bar_gap_fill(
mtm: MainThreadMarker,
background: &NSColor,
) -> Retained<NSView> {
let bottom_bar_gap_fill = NSView::initWithFrame(
NSView::alloc(mtm),
NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(Panel::WIDTH, Panel::TITLE_ADJUSTMENT)),
);
set_editor_switcher_view_background(bottom_bar_gap_fill.as_ref(), background, 0.0);
bottom_bar_gap_fill
}
fn editor_switcher_panel_frame() -> NSRect {
NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(Panel::WIDTH, Panel::HEIGHT))
}
fn editor_switcher_content_frame() -> NSRect {
NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(Panel::WIDTH, Panel::CONTENT_HEIGHT))
}
fn editor_switcher_bottom_bar_y() -> f64 {
Search::SECTION_HEIGHT + Results::OUTER_PADDING + Results::CONTAINER_HEIGHT
}
fn editor_switcher_bottom_bar_height() -> f64 {
(Panel::CONTENT_HEIGHT - editor_switcher_bottom_bar_y()).max(BottomBar::HEIGHT)
}
fn editor_switcher_empty_results_label(
mtm: MainThreadMarker,
visible_width: f64,
) -> Retained<NSTextField> {
let empty_label = editor_switcher_label(
mtm,
EMPTY_RESULTS_TEXT,
Results::EMPTY_LABEL_FONT_SIZE,
EditorSwitcherLabelStyle::Secondary,
);
let empty_label_view: &NSView = empty_label.as_ref();
empty_label_view.setFrame(NSRect::new(
NSPoint::new(Panel::HORIZONTAL_PADDING, Results::EMPTY_LABEL_Y),
NSSize::new(visible_width - Panel::HORIZONTAL_PADDING * 2.0, Results::EMPTY_LABEL_HEIGHT),
));
empty_label
}
fn scroll_selected_editor_switcher_row_into_view(container: &NSView, selected_index: usize) {
let visible_rect = container.visibleRect();
let visible_min_y = visible_rect.origin.y;
let visible_max_y = visible_rect.origin.y + visible_rect.size.height;
let selected_min_y = selected_index as f64 * Row::HEIGHT;
let selected_max_y = selected_min_y + Row::HEIGHT;
let scroll_y = if selected_min_y < visible_min_y {
selected_min_y
} else if selected_max_y > visible_max_y {
selected_max_y - visible_rect.size.height
} else {
return;
};
container.scrollPoint(NSPoint::new(0.0, scroll_y.max(0.0)));
}
fn hide_editor_switcher_window_buttons(window: &NSWindow) {
for button_kind in
[NSWindowButton::MiniaturizeButton, NSWindowButton::CloseButton, NSWindowButton::ZoomButton]
{
if let Some(button) = window.standardWindowButton(button_kind) {
let button_view: &NSView = button.as_ref();
button_view.setHidden(true);
}
}
}
fn editor_switcher_row_frame(row_width: f64, row_height: f64) -> NSRect {
NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(row_width, row_height))
}
fn editor_switcher_row_icon_frame(row_height: f64) -> NSRect {
NSRect::new(
NSPoint::new(Results::INNER_PADDING, (row_height - Row::ICON_SIZE) / 2.0),
NSSize::new(Row::ICON_SIZE, Row::ICON_SIZE),
)
}
fn editor_switcher_row_title_frame(row_width: f64, row_height: f64) -> NSRect {
let title_x = Results::INNER_PADDING + Row::ICON_SIZE + Row::ICON_TITLE_SPACING;
NSRect::new(
NSPoint::new(title_x, (row_height - Row::LABEL_HEIGHT) / 2.0),
NSSize::new(row_width - title_x - Results::INNER_PADDING, Row::LABEL_HEIGHT),
)
}
fn editor_switcher_row_button(
mtm: MainThreadMarker,
row: &EditorSwitcherRow,
row_index: usize,
selected: bool,
icon: Option<&NSImage>,
row_action_handler: &EditorSwitcherActionHandler,
) -> Retained<NSButton> {
let row_width = Panel::WIDTH - Results::OUTER_PADDING * 2.0;
let row_height = Row::BUTTON_HEIGHT;
let button = editor_switcher_button(mtm, editor_switcher_row_frame(row_width, row_height));
let row_control: &NSControl = button.as_ref();
row_control.setTag(row_index as NSInteger);
row_control.setRefusesFirstResponder(true);
set_editor_switcher_control_target(row_control, row_action_handler);
unsafe {
row_control.setAction(Some(sel!(editorSwitcherRowClicked:)));
}
let row_view: &NSView = button.as_ref();
let row_background = if selected {
NSColor::systemGrayColor().colorWithAlphaComponent(SELECTED_ROW_ALPHA)
} else {
NSColor::clearColor()
};
set_editor_switcher_view_background(row_view, &row_background, Row::CORNER_RADIUS);
if let Some(icon) = icon {
let image_view = NSImageView::initWithFrame(
NSImageView::alloc(mtm),
editor_switcher_row_icon_frame(row_height),
);
image_view.setImage(Some(icon));
image_view.setImageScaling(NSImageScaling::ScaleProportionallyDown);
row_view.addSubview(image_view.as_ref());
}
let title_label = editor_switcher_label(
mtm,
&row.title,
Row::TITLE_FONT_SIZE,
EditorSwitcherLabelStyle::Primary,
);
let title_label_view: &NSView = title_label.as_ref();
title_label_view.setFrame(editor_switcher_row_title_frame(row_width, row_height));
row_view.addSubview(title_label.as_ref());
button
}
fn editor_switcher_bottom_bar_button(
mtm: MainThreadMarker,
text: &str,
shortcuts: &[&str],
action: Sel,
) -> Retained<NSButton> {
let text_label =
editor_switcher_label(mtm, text, BottomBar::FONT_SIZE, EditorSwitcherLabelStyle::Footer);
let text_size = editor_switcher_fit_label(text_label.as_ref());
let text_width = text_size.width.ceil();
let text_height = text_size.height.ceil();
let shortcut_chips: Vec<FooterShortcutChip> = shortcuts
.iter()
.map(|shortcut| {
editor_switcher_footer_shortcut_chip(mtm, shortcut, BottomBar::SHORTCUT_FONT_SIZE)
})
.collect();
let shortcuts_width = shortcut_chips.iter().map(|chip| chip.width).sum::<f64>()
+ BottomBar::SHORTCUT_SPACING * shortcut_chips.len().saturating_sub(1) as f64;
let shortcuts_height = shortcut_chips.iter().map(|chip| chip.height).fold(0.0, f64::max);
let label_shortcut_spacing =
if shortcut_chips.is_empty() { 0.0 } else { BottomBar::LABEL_SHORTCUT_SPACING };
let content_height = text_height.max(shortcuts_height);
let button_height =
BottomBar::BUTTON_HEIGHT.max(content_height + BottomBar::BUTTON_VERTICAL_PADDING * 2.0);
let width = BottomBar::BUTTON_LEADING_PADDING
+ text_width
+ label_shortcut_spacing
+ shortcuts_width
+ BottomBar::BUTTON_TRAILING_PADDING;
let button = editor_switcher_button(
mtm,
NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(width, button_height)),
);
let button_control: &NSControl = button.as_ref();
button_control.setRefusesFirstResponder(true);
unsafe {
button_control.setAction(Some(action));
}
let button_view: &NSView = button.as_ref();
set_editor_switcher_view_background(
button_view,
&NSColor::clearColor(),
BottomBar::BUTTON_CORNER_RADIUS,
);
let text_label_view: &NSView = text_label.as_ref();
text_label_view.setFrame(NSRect::new(
NSPoint::new(BottomBar::BUTTON_LEADING_PADDING, (button_height - text_height) / 2.0),
NSSize::new(text_width, text_height),
));
button_view.addSubview(text_label.as_ref());
let mut x = BottomBar::BUTTON_LEADING_PADDING + text_width + label_shortcut_spacing;
for shortcut_chip in shortcut_chips {
let shortcut_chip_view: &NSView = shortcut_chip.view.as_ref();
shortcut_chip_view
.setFrameOrigin(NSPoint::new(x, (button_height - shortcut_chip.height) / 2.0));
button_view.addSubview(shortcut_chip.view.as_ref());
x += shortcut_chip.width + BottomBar::SHORTCUT_SPACING;
}
button
}
fn editor_switcher_footer_shortcut_chip(
mtm: MainThreadMarker,
text: &str,
size: f64,
) -> FooterShortcutChip {
let label = editor_switcher_label(mtm, text, size, EditorSwitcherLabelStyle::Shortcut);
let label_size = editor_switcher_fit_label(label.as_ref());
let label_width = label_size.width.ceil();
let label_height = label_size.height.ceil();
let width = label_width + BottomBar::SHORTCUT_HORIZONTAL_PADDING * 2.0;
let height = label_height + BottomBar::SHORTCUT_VERTICAL_PADDING * 2.0;
let chip = NSView::initWithFrame(
NSView::alloc(mtm),
NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(width, height)),
);
set_editor_switcher_view_background(
chip.as_ref(),
&NSColor::whiteColor().colorWithAlphaComponent(SHORTCUT_CHIP_ALPHA),
BottomBar::SHORTCUT_CHIP_CORNER_RADIUS,
);
let label_view: &NSView = label.as_ref();
label_view.setFrame(NSRect::new(
NSPoint::new(BottomBar::SHORTCUT_HORIZONTAL_PADDING, BottomBar::SHORTCUT_VERTICAL_PADDING),
NSSize::new(label_width, label_height),
));
chip.addSubview(label.as_ref());
FooterShortcutChip { view: chip, width, height }
}
fn editor_switcher_button(mtm: MainThreadMarker, frame: NSRect) -> Retained<NSButton> {
let button = NSButton::initWithFrame(NSButton::alloc(mtm), frame);
button.setTitle(ns_string!(""));
button.setButtonType(NSButtonType::MomentaryChange);
button.setBezelStyle(NSBezelStyle::Automatic);
button.setBordered(false);
button.setTransparent(true);
button
}
fn set_editor_switcher_control_target(control: &NSControl, target: &EditorSwitcherActionHandler) {
unsafe {
let target: &AnyObject = target.as_ref();
control.setTarget(Some(target));
}
}
fn editor_switcher_fit_label(label: &NSTextField) -> NSSize {
let control: &NSControl = label.as_ref();
control.sizeToFit();
let label_view: &NSView = label.as_ref();
label_view.frame().size
}
fn editor_switcher_label(
mtm: MainThreadMarker,
text: &str,
size: f64,
style: EditorSwitcherLabelStyle,
) -> Retained<NSTextField> {
let label = NSTextField::labelWithString(&NSString::from_str(text), mtm);
let control: &NSControl = label.as_ref();
let font = match style {
EditorSwitcherLabelStyle::Shortcut => {
NSFont::monospacedSystemFontOfSize_weight(size, NSFontWeight::from(0))
}
_ => NSFont::systemFontOfSize(size),
};
control.setFont(Some(font.as_ref()));
if matches!(style, EditorSwitcherLabelStyle::Shortcut) {
control.setAlignment(NSTextAlignment(2));
}
control.setLineBreakMode(NSLineBreakMode::ByTruncatingTail);
control.setUsesSingleLineMode(true);
let text_color = match style {
EditorSwitcherLabelStyle::Primary => NSColor::labelColor(),
EditorSwitcherLabelStyle::Secondary => NSColor::secondaryLabelColor(),
EditorSwitcherLabelStyle::Footer | EditorSwitcherLabelStyle::Shortcut => {
NSColor::systemGrayColor()
}
};
label.setTextColor(Some(text_color.as_ref()));
label.setMaximumNumberOfLines(1);
label
}
fn set_editor_switcher_view_background(view: &NSView, color: &NSColor, corner_radius: f64) {
view.setWantsLayer(true);
if let Some(layer) = view.layer() {
let cg_color = color.CGColor();
layer.setBackgroundColor(Some(cg_color.as_ref()));
layer.setCornerRadius(corner_radius);
layer.setMasksToBounds(true);
}
}

View file

@ -834,6 +834,10 @@ impl ApplicationHandler<EventPayload> for Application {
self.mark_should_render_all();
}
#[cfg(target_os = "macos")]
UserEvent::ActivateWindow(window_id) => {
self.window_wrapper.activate_and_focus_window(window_id);
}
#[cfg(target_os = "macos")]
UserEvent::MacShortcut(command) => {
self.window_wrapper.handle_mac_shortcut(command);
self.mark_should_render_all();

View file

@ -15,14 +15,13 @@ use crate::window::{EventPayload, MacShortcutCommand, UserEvent};
const PINNED_ENV_VAR: &str = "NEOVIDE_SYSTEM_PINNED_HOTKEY";
const SWITCHER_ENV_VAR: &str = "NEOVIDE_SYSTEM_SWITCHER_HOTKEY";
const LEGACY_ENV_VAR: &str = "NEOVIDE_MACOS_ACTIVATION_HOTKEY";
const PINNED_DEFAULT: &str = "cmd+ctrl+z";
const SWITCHER_DEFAULT: &str = "cmd+ctrl+n";
const HOTKEY_SIGNATURE: u32 = u32::from_be_bytes(*b"NEOV");
const EVENT_CLASS_KEYBOARD: u32 = u32::from_be_bytes(*b"keyb");
const EVENT_KIND_HOT_KEY_PRESSED: u32 = 6;
const EVENT_KIND_HOT_KEY_PRESSED: u32 = 5;
const EVENT_PARAM_DIRECT_OBJECT: u32 = u32::from_be_bytes(*b"----");
const TYPE_EVENT_HOT_KEY_ID: u32 = u32::from_be_bytes(*b"hkid");
const NO_ERR: OSStatus = 0;
@ -94,7 +93,7 @@ const HOTKEY_DEFINITIONS: &[HotkeyDefinition] = &[
},
HotkeyDefinition {
action: ShortcutAction::ShowEditorSwitcher,
env_vars: &[SWITCHER_ENV_VAR, LEGACY_ENV_VAR],
env_vars: &[SWITCHER_ENV_VAR],
default: SWITCHER_DEFAULT,
},
];

View file

@ -2,7 +2,7 @@ pub mod hotkey;
pub mod tab_navigation;
pub use crate::platform::macos::{
MacosWindowFeature, TouchpadStage, get_last_host_window, get_ns_window, hide_application,
is_focus_suppressed, is_tab_overview_active, native_tab_bar_enabled, register_file_handler,
trigger_tab_overview, window_identifier,
EditorSwitcherRow, MacosWindowFeature, TouchpadStage, close_editor_switcher_if_open,
editor_switcher_key_event_matches, hide_application, native_tab_bar_enabled,
register_file_handler, show_editor_switcher_panel,
};

View file

@ -1,6 +1,6 @@
use log::warn;
use objc2::rc::Retained;
use objc2_app_kit::NSEventModifierFlags;
use objc2_app_kit::{NSEvent, NSEventModifierFlags};
use objc2_foundation::NSString;
use winit::{
event::{ElementState, KeyEvent, Modifiers},
@ -125,11 +125,39 @@ impl KeyCombo {
}
}
pub fn matches_key_event(&self, event: &KeyEvent, modifiers: &Modifiers) -> bool {
self.matches(event, modifiers)
}
pub fn matches_nsevent(&self, event: &NSEvent) -> bool {
if !self.nsevent_modifiers_match(event.modifierFlags()) {
return false;
}
match self.key {
KeyMatch::Char(expected) => event
.charactersIgnoringModifiers()
.and_then(|characters| characters.to_string().chars().next())
.is_some_and(|pressed| expected.eq_ignore_ascii_case(&pressed)),
KeyMatch::Named(_) => false,
}
}
fn modifiers_match(&self, modifiers: &Modifiers) -> bool {
let state = modifiers.state();
(self.command, self.control, self.option, self.shift)
== (state.super_key(), state.control_key(), state.alt_key(), state.shift_key())
}
fn nsevent_modifiers_match(&self, flags: NSEventModifierFlags) -> bool {
(self.command, self.control, self.option, self.shift)
== (
flags.contains(NSEventModifierFlags::Command),
flags.contains(NSEventModifierFlags::Control),
flags.contains(NSEventModifierFlags::Option),
flags.contains(NSEventModifierFlags::Shift),
)
}
}
fn pressed_character(event: &KeyEvent) -> Option<char> {

View file

@ -163,6 +163,8 @@ pub enum UserEvent {
#[cfg(target_os = "macos")]
CreateWindow,
#[cfg(target_os = "macos")]
ActivateWindow(winit::window::WindowId),
#[cfg(target_os = "macos")]
MacShortcut(MacShortcutCommand),
}

View file

@ -32,8 +32,9 @@ use {
crate::window::MacShortcutCommand,
crate::window::macos::tab_navigation::{TabNavigationAction, TabNavigationHotkeys},
crate::window::macos::{
MacosWindowFeature, TouchpadStage, hide_application, is_focus_suppressed,
is_tab_overview_active, native_tab_bar_enabled, trigger_tab_overview,
EditorSwitcherRow, MacosWindowFeature, TouchpadStage, close_editor_switcher_if_open,
editor_switcher_key_event_matches, hide_application, native_tab_bar_enabled,
show_editor_switcher_panel,
},
crate::{error_msg, window::settings},
glamour::Point2,
@ -335,7 +336,6 @@ impl WinitWindowWrapper {
WindowSize::Grid(_) | WindowSize::NeovimGrid => Some(desired_window_size),
WindowSize::Maximized | WindowSize::Size(_) => None,
};
let config = Config::init();
let renderer = Rc::new(RefCell::new(Box::new(Renderer::new(
1.0,
@ -911,6 +911,15 @@ impl WinitWindowWrapper {
window_id: WindowId,
event: &WindowEvent,
) -> Option<OverlayEvent> {
#[cfg(target_os = "macos")]
if let WindowEvent::KeyboardInput { event: key_event, is_synthetic: false, .. } = event {
let modifiers = self.keyboard_manager.current_modifiers();
if editor_switcher_key_event_matches(key_event, &modifiers) {
self.show_editor_switcher();
return Some(OverlayEvent::Unchanged);
}
}
let route = self.routes.get_mut(&window_id)?;
let neovim_handler = &route.window.neovim_handler;
@ -1080,28 +1089,6 @@ impl WinitWindowWrapper {
should_render |= message_selection_needs_render;
if let Some(focus) = pending_focus_event {
#[cfg(target_os = "macos")]
{
if is_focus_suppressed() {
log::trace!("Suppressing focus event during tab detach (focus = {})", focus);
return self.ui_state >= UIState::FirstFrame && should_render;
}
if focus && let Some(route) = self.routes.get(&window_id) {
let ns_window =
crate::window::macos::get_ns_window(route.window.winit_window.as_ref());
let host_ptr = crate::window::macos::get_last_host_window();
let window_ptr = crate::window::macos::window_identifier(ns_window.as_ref());
if host_ptr != 0 && window_ptr != host_ptr {
log::trace!(
"Focus gained for non-host window; refocusing host {:?}",
host_ptr
);
ns_window.makeKeyAndOrderFront(None);
ns_window.orderFrontRegardless();
}
}
}
if focus {
self.handle_focus_gained(window_id);
} else {
@ -1307,6 +1294,52 @@ impl WinitWindowWrapper {
}
}
#[cfg(target_os = "macos")]
fn editor_switcher_rows(&mut self) -> Vec<EditorSwitcherRow> {
let focused_route = self.get_focused_route();
self.cleanup_window_mru();
let mut ordered_windows: Vec<WindowId> = self.window_mru.iter().copied().collect();
for window_id in self.routes.keys().copied() {
if !ordered_windows.contains(&window_id) {
ordered_windows.push(window_id);
}
}
ordered_windows
.into_iter()
.filter_map(|window_id| {
let route = self.routes.get(&window_id)?;
let title = if route.window.title.is_empty() {
"Untitled".to_string()
} else {
route.window.title.clone()
};
let subtitle = Self::editor_switcher_subtitle(route);
Some(EditorSwitcherRow {
window_id,
title,
subtitle,
modified: route.window.document_modified,
is_current: focused_route == Some(window_id),
})
})
.collect()
}
#[cfg(target_os = "macos")]
fn editor_switcher_subtitle(route: &Route) -> String {
if !route.window.document_path.is_empty() {
route.window.document_path.clone()
} else if let Some(cwd) = &route.cwd {
cwd.to_string_lossy().into_owned()
} else {
String::new()
}
}
#[cfg(target_os = "macos")]
fn capture_focus_target(&mut self, pinned_id: WindowId) {
let current = self.get_focused_route();
@ -1385,45 +1418,17 @@ impl WinitWindowWrapper {
#[cfg(target_os = "macos")]
fn show_editor_switcher(&mut self) {
if is_tab_overview_active() {
trigger_tab_overview();
if self.routes.is_empty() {
return;
}
let window_count = self.routes.len();
if window_count == 0 {
if close_editor_switcher_if_open() {
return;
}
if window_count == 1 {
self.toggle_pinned_window();
return;
}
#[cfg(target_os = "macos")]
{
let mut opened_overview = false;
if let Some(window_id) = self.pinned_candidate()
&& let Some(feature_rc) = self.macos_feature_for_window(window_id)
{
{
let feature = feature_rc.borrow();
if feature.is_simple_fullscreen_enabled() {
drop(feature);
self.toggle_pinned_window();
return;
}
}
feature_rc.borrow().activate_application();
opened_overview = true;
}
if opened_overview {
trigger_tab_overview();
} else {
self.toggle_pinned_window();
}
}
let rows = self.editor_switcher_rows();
let icon = self.settings.get::<CmdLineSettings>().icon;
show_editor_switcher_panel(rows, icon.as_ref());
}
pub fn draw_frame(&mut self, window_id: WindowId, dt: f32) {

View file

@ -272,10 +272,9 @@ as with `--reuse-instance` works.
--no-system-native-tabs, --system-native-tabs or $NEOVIDE_SYSTEM_NATIVE_TABS=0|1
```
Neovide merges macOS windows into a single host window automatically and hides the native tab bar by
default to mimic a standalone window. Enable this option to keep the tab bar visible so every window
shows up as a tab immediately. The setting applies to windows opened through both global shortcuts
and the Editors menu entry.
Neovide can merge macOS windows into a single native tab group. Enable this option to keep the tab
bar visible when multiple Neovide windows are grouped as tabs or leave it disabled to keep separate
windows.
### Menu Shortcuts
@ -294,6 +293,9 @@ Remaps the macOS menu shortcuts used by Neovide. The defaults are `cmd+h`, `cmd+
Minimize, Enter Full Screen, and Editors respectively. Set any of them to `false` or leave them
empty to remove the shortcut while keeping the menu item available.
`--system-show-all-tabs-hotkey` remaps Window > Editors. The menu item opens the Editors
switcher with either separate windows or native tabs.
### System Tab Navigation
```sh

View file

@ -48,7 +48,7 @@ startup-message-capture = true
tabs = true
system-native-tabs = false # macOS only
system-pinned-hotkey = "cmd+ctrl+z" # macOS only
system-switcher-hotkey = "cmd+ctrl+n" # macOS only, requires system-native-tabs = true
system-switcher-hotkey = "cmd+ctrl+n" # macOS only
system-new-window-hotkey = "cmd+n" # macOS only
system-hide-hotkey = "cmd+h" # macOS only
system-hide-others-hotkey = "cmd+alt+h" # macOS only

View file

@ -941,8 +941,9 @@ until more than one tab exists to keep a clean single-window look.
Use Window > New Window (default: `cmd+n`) or the Dock menu to open another Neovide window. If
native tabs are enabled, new windows become tabs in the host window.
If you have native tabs enabled, the Window menu shows an Editors entry and the Editors hotkey
becomes available. You can also remap the in-app tab cycling shortcuts.
The Window menu includes an Editors entry that opens Neovide's editor switcher for both separate
windows or native tabs. If you use native tabs, you can also remap the in-app tab cycling
shortcuts.
#### macOS Global Activation Shortcuts
@ -951,10 +952,9 @@ Neovide registers system-wide shortcuts on macOS:
- **Pinned** <kbd></kbd> + <kbd></kbd> + <kbd>Z</kbd> toggles the most recently used Neovide
window. If that window is already active, the shortcut hides it; otherwise it brings the window
to the front.
- **Editors** <kbd></kbd> + <kbd></kbd> + <kbd>N</kbd> opens the Editors (tab overview) view so
you can pick another Neovide window. This shortcut is only available when
`system-native-tabs = true` and if only one window exists, it behaves the same as the pinned
shortcut.
- **Editors** <kbd></kbd> + <kbd></kbd> + <kbd>N</kbd> opens the Editors switcher so you can
search and pick another Neovide window or tab. The switcher is available with either separate
windows or native tabs.
Customize them by setting the environment variables:
@ -992,6 +992,8 @@ system-show-all-tabs-hotkey = "cmd+shift+e"
Set any of them to `false` (or an empty value) to remove the menu shortcut while keeping the menu
item.
`system-show-all-tabs-hotkey` remaps Window > Editors.
When `system-native-tabs` is enabled, you can also customize the in-app tab navigation shortcuts:
```toml