diff --git a/docs/changelog.rst b/docs/changelog.rst index 784681741..7587f9909 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -46,6 +46,8 @@ Detailed list of changes 0.30.2 [future] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- A new option :opt:`menu_map` that allows adding entries to the global menubar on macOS (:disc:`6680`) + - A new mouse action ``mouse_selection word_and_line_from_point`` to select the current word under the mouse cursor and extend to end of line (:pull:`6663`) - macOS: When running the default shell with the login program fix :file:`~/.hushlogin` not being respected when opening windows not in the home directory (:iss:`6689`) diff --git a/kitty/boss.py b/kitty/boss.py index a32d5187f..133ed6be8 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -1536,6 +1536,10 @@ class Boss: return True return False + def user_menu_action(self, defn: str) -> None: + ' Callback from user actions in the macOS global menu bar or other menus ' + self.combine(defn) + @ac('misc', ''' Combine multiple actions and map to a single keypress diff --git a/kitty/child-monitor.c b/kitty/child-monitor.c index 6dcb5b87d..e99180223 100644 --- a/kitty/child-monitor.c +++ b/kitty/child-monitor.c @@ -1148,6 +1148,7 @@ process_cocoa_pending_actions(void) { if (cocoa_pending_actions_data.wd) { if (cocoa_pending_actions[NEW_OS_WINDOW_WITH_WD]) { call_boss(new_os_window_with_wd, "sO", cocoa_pending_actions_data.wd, Py_True); } if (cocoa_pending_actions[NEW_TAB_WITH_WD]) { call_boss(new_tab_with_wd, "sO", cocoa_pending_actions_data.wd, Py_True); } + if (cocoa_pending_actions[USER_MENU_ACTION]) { call_boss(user_menu_action, "s", cocoa_pending_actions_data.wd); } free(cocoa_pending_actions_data.wd); cocoa_pending_actions_data.wd = NULL; } diff --git a/kitty/cocoa_window.m b/kitty/cocoa_window.m index 8ea47d145..0045d5118 100644 --- a/kitty/cocoa_window.m +++ b/kitty/cocoa_window.m @@ -212,6 +212,16 @@ find_app_name(void) { @end // }}} +@interface UserMenuItem : NSMenuItem +@property (nonatomic) size_t action_index; +@end + +@implementation UserMenuItem { +} +@end + + + @interface GlobalMenuTarget : NSObject + (GlobalMenuTarget *) shared_instance; @end @@ -220,6 +230,13 @@ find_app_name(void) { @implementation GlobalMenuTarget +- (void)user_menu_action:(id)sender { + UserMenuItem *m = sender; + if (m.action_index < OPT(global_menu).count && OPT(global_menu.entries)) { + set_cocoa_pending_action(USER_MENU_ACTION, OPT(global_menu).entries[m.action_index].definition); + } +} + PENDING(edit_config_file, PREFERENCES_WINDOW) PENDING(new_os_window, NEW_OS_WINDOW) PENDING(detach_tab, DETACH_TAB) @@ -559,6 +576,35 @@ cocoa_send_notification(PyObject *self UNUSED, PyObject *args) { // global menu {{{ +static void +add_user_global_menu_entry(struct MenuItem *e, NSMenu *bar, size_t action_index) { + NSMenu *parent = bar; + UserMenuItem *final_item = nil; + GlobalMenuTarget *global_menu_target = [GlobalMenuTarget shared_instance]; + for (size_t i = 0; i < e->location_count; i++) { + NSMenuItem *item = [parent itemWithTitle:@(e->location[i])]; + if (!item) { + final_item = [[UserMenuItem alloc] initWithTitle:@(e->location[i]) action:@selector(user_menu_action:) keyEquivalent:@""]; + final_item.target = global_menu_target; + [parent addItem:final_item]; + item = final_item; + [final_item release]; + } + if (i + 1 < e->location_count) { + if (![item hasSubmenu]) { + NSMenu* sub_menu = [[NSMenu alloc] initWithTitle:item.title]; + [item setSubmenu:sub_menu]; + [sub_menu release]; + } + parent = [item submenu]; + if (!parent) return; + } + } + if (final_item != nil) { + final_item.action_index = action_index; + } +} + void cocoa_create_global_menu(void) { NSString* app_name = find_app_name(); @@ -665,8 +711,17 @@ cocoa_create_global_menu(void) { [NSApp setHelpMenu:helpMenu]; [helpMenu release]; + if (OPT(global_menu.entries)) { + for (size_t i = 0; i < OPT(global_menu.count); i++) { + struct MenuItem *e = OPT(global_menu.entries) + i; + if (e->definition && e->location && e->location_count > 1) { + add_user_global_menu_entry(e, bar, i); + } + } + } [bar release]; + class_addMethod( object_getClass([NSApp delegate]), @selector(applicationDockMenu:), @@ -675,6 +730,7 @@ cocoa_create_global_menu(void) { [NSApp setServicesProvider:[[[ServiceProvider alloc] init] autorelease]]; + #undef MENU_ITEM } diff --git a/kitty/options/definition.py b/kitty/options/definition.py index 2cb8667dc..f29110110 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -3096,6 +3096,20 @@ STDOUT. When enabled, this also sets the :code:`KITTY_STDIO_FORWARDED=3` environment variable so child processes know about the forwarding. ''') +opt('+menu_map', '', + option_type='menu_map', add_to_default=False, ctype='!menu_map', + long_text=''' +Specify entries for various menus in kitty. Currently only the global menubar on macOS +is supported. For example:: + + menu_map global "Actions::Launch something special" launch --hold --type=os-window sh -c "echo hello world" + +This will create a menu entry named "Launch something special" in an "Actions" menu in the macOS global menubar. +Sub-menus can be created by adding more levels separated by ::. +''' + ) + + egr() # }}} diff --git a/kitty/options/parse.py b/kitty/options/parse.py index 8a9be9f18..60a0bea56 100644 --- a/kitty/options/parse.py +++ b/kitty/options/parse.py @@ -12,13 +12,13 @@ from kitty.options.utils import ( config_or_absolute_path, copy_on_select, cursor_text_color, deprecated_adjust_line_height, deprecated_hide_window_decorations_aliases, deprecated_macos_show_window_title_in_menubar_alias, deprecated_send_text, disable_ligatures, edge_width, env, font_features, hide_window_decorations, - macos_option_as_alt, macos_titlebar_color, modify_font, narrow_symbols, optional_edge_width, - parse_map, parse_mouse_map, paste_actions, remote_control_password, resize_debounce_time, - scrollback_lines, scrollback_pager_history_size, shell_integration, store_multiple, symbol_map, - tab_activity_symbol, tab_bar_edge, tab_bar_margin_height, tab_bar_min_tabs, tab_fade, - tab_font_style, tab_separator, tab_title_template, titlebar_color, to_cursor_shape, to_font_size, - to_layout_names, to_modifiers, url_prefixes, url_style, visual_window_select_characters, - window_border_width, window_size + macos_option_as_alt, macos_titlebar_color, menu_map, modify_font, narrow_symbols, + optional_edge_width, parse_map, parse_mouse_map, paste_actions, remote_control_password, + resize_debounce_time, scrollback_lines, scrollback_pager_history_size, shell_integration, + store_multiple, symbol_map, tab_activity_symbol, tab_bar_edge, tab_bar_margin_height, + tab_bar_min_tabs, tab_fade, tab_font_style, tab_separator, tab_title_template, titlebar_color, + to_cursor_shape, to_font_size, to_layout_names, to_modifiers, url_prefixes, url_style, + visual_window_select_characters, window_border_width, window_size ) @@ -1105,6 +1105,10 @@ class Parser: def mark3_foreground(self, val: str, ans: typing.Dict[str, typing.Any]) -> None: ans['mark3_foreground'] = to_color(val) + def menu_map(self, val: str, ans: typing.Dict[str, typing.Any]) -> None: + for k, v in menu_map(val, ans["menu_map"]): + ans["menu_map"][k] = v + def modify_font(self, val: str, ans: typing.Dict[str, typing.Any]) -> None: for k, v in modify_font(val): ans["modify_font"][k] = v @@ -1407,6 +1411,7 @@ def create_result_dict() -> typing.Dict[str, typing.Any]: 'exe_search_path': {}, 'font_features': {}, 'kitten_alias': {}, + 'menu_map': {}, 'modify_font': {}, 'narrow_symbols': {}, 'remote_control_password': {}, diff --git a/kitty/options/to-c-generated.h b/kitty/options/to-c-generated.h index 867e25179..bf2ca731d 100644 --- a/kitty/options/to-c-generated.h +++ b/kitty/options/to-c-generated.h @@ -941,6 +941,19 @@ convert_from_opts_allow_hyperlinks(PyObject *py_opts, Options *opts) { Py_DECREF(ret); } +static void +convert_from_python_menu_map(PyObject *val, Options *opts) { + menu_map(val, opts); +} + +static void +convert_from_opts_menu_map(PyObject *py_opts, Options *opts) { + PyObject *ret = PyObject_GetAttrString(py_opts, "menu_map"); + if (ret == NULL) return; + convert_from_python_menu_map(ret, opts); + Py_DECREF(ret); +} + static void convert_from_python_wayland_titlebar_color(PyObject *val, Options *opts) { opts->wayland_titlebar_color = PyLong_AsUnsignedLong(val); @@ -1230,6 +1243,8 @@ convert_opts_from_python_opts(PyObject *py_opts, Options *opts) { if (PyErr_Occurred()) return false; convert_from_opts_allow_hyperlinks(py_opts, opts); if (PyErr_Occurred()) return false; + convert_from_opts_menu_map(py_opts, opts); + if (PyErr_Occurred()) return false; convert_from_opts_wayland_titlebar_color(py_opts, opts); if (PyErr_Occurred()) return false; convert_from_opts_macos_titlebar_color(py_opts, opts); diff --git a/kitty/options/to-c.h b/kitty/options/to-c.h index 7634c9eb8..0247840f2 100644 --- a/kitty/options/to-c.h +++ b/kitty/options/to-c.h @@ -179,6 +179,49 @@ url_prefixes(PyObject *up, Options *opts) { } } +static inline void +free_menu_map(Options *opts) { + if (opts->global_menu.entries) { + for (size_t i=0; i < opts->global_menu.count; i++) { + struct MenuItem *e = opts->global_menu.entries + i; + if (e->definition) { free((void*)e->definition); } + if (e->location) { + for (size_t l=0; l < e->location_count; l++) { free((void*)e->location[l]); } + free(e->location); + } + } + free(opts->global_menu.entries); opts->global_menu.entries = NULL; + } +} + +static void +menu_map(PyObject *entry_dict, Options *opts) { + if (!PyDict_Check(entry_dict)) { PyErr_SetString(PyExc_TypeError, "menu_map entries must be a dict"); return; } + free_menu_map(opts); + size_t maxnum = PyDict_Size(entry_dict); + opts->global_menu.count = 0; + opts->global_menu.entries = calloc(maxnum, sizeof(opts->global_menu.entries[0])); + if (!opts->global_menu.entries) { PyErr_NoMemory(); return; } + + PyObject *key, *value; + Py_ssize_t pos = 0; + + while (PyDict_Next(entry_dict, &pos, &key, &value)) { + if (PyTuple_Check(key) && PyTuple_GET_SIZE(key) > 1 && PyUnicode_Check(value) && PyUnicode_CompareWithASCIIString(PyTuple_GET_ITEM(key, 0), "global") == 0) { + struct MenuItem *e = opts->global_menu.entries + opts->global_menu.count++; + e->location_count = PyTuple_GET_SIZE(key) - 1; + e->location = calloc(e->location_count, sizeof(e->location[0])); + if (!e->location) { PyErr_NoMemory(); return; } + e->definition = strdup(PyUnicode_AsUTF8(value)); + if (!e->definition) { PyErr_NoMemory(); return; } + for (size_t i = 0; i < e->location_count; i++) { + e->location[i] = strdup(PyUnicode_AsUTF8(PyTuple_GET_ITEM(key, i+1))); + if (!e->location[i]) { PyErr_NoMemory(); return; } + } + } + } +} + static void text_composition_strategy(PyObject *val, Options *opts) { if (!PyUnicode_Check(val)) { PyErr_SetString(PyExc_TypeError, "text_rendering_strategy must be a string"); return; } diff --git a/kitty/options/types.py b/kitty/options/types.py index 4c476cf74..01155fb01 100644 --- a/kitty/options/types.py +++ b/kitty/options/types.py @@ -399,6 +399,7 @@ option_names = ( # {{{ 'mark2_foreground', 'mark3_background', 'mark3_foreground', + 'menu_map', 'modify_font', 'mouse_hide_wait', 'mouse_map', @@ -629,6 +630,7 @@ class Options: exe_search_path: typing.Dict[str, str] = {} font_features: typing.Dict[str, typing.Tuple[kitty.fonts.FontFeature, ...]] = {} kitten_alias: typing.Dict[str, str] = {} + menu_map: typing.Dict[typing.Tuple[str, ...], str] = {} modify_font: typing.Dict[str, kitty.fonts.FontModification] = {} narrow_symbols: typing.Dict[typing.Tuple[int, int], int] = {} remote_control_password: typing.Dict[str, typing.Sequence[str]] = {} @@ -750,6 +752,7 @@ defaults.env = {} defaults.exe_search_path = {} defaults.font_features = {} defaults.kitten_alias = {} +defaults.menu_map = {} defaults.modify_font = {} defaults.narrow_symbols = {} defaults.remote_control_password = {} diff --git a/kitty/options/utils.py b/kitty/options/utils.py index 99f9b962c..4042c140a 100644 --- a/kitty/options/utils.py +++ b/kitty/options/utils.py @@ -871,6 +871,26 @@ def store_multiple(val: str, current_val: Container[str]) -> Iterable[Tuple[str, yield val, val +def menu_map(val: str, current_val: Container[str]) -> Iterable[Tuple[Tuple[str, ...], str]]: + parts = val.split(maxsplit=1) + if len(parts) != 2: + raise ValueError(f'Ignoring invalid menu action: {val}') + if parts[0] != 'global': + raise ValueError(f'Unknown menu type: {parts[0]}. Known types: global') + start = 0 + if parts[1].startswith('"'): + start = 1 + idx = parts[1].find('"', 1) + if idx == -1: + raise ValueError(f'The menu entry name in {val} must end with a double quote') + else: + idx = parts[1].find(' ') + if idx == -1: + raise ValueError(f'The menu entry {val} must have an action') + location = ('global',) + tuple(parts[1][start:idx].split('::')) + yield location, parts[1][idx+1:].lstrip() + + allowed_shell_integration_values = frozenset({'enabled', 'disabled', 'no-rc', 'no-cursor', 'no-title', 'no-prompt-mark', 'no-complete', 'no-cwd', 'no-sudo'}) diff --git a/kitty/state.h b/kitty/state.h index e8c3de4b4..4c0a9482a 100644 --- a/kitty/state.h +++ b/kitty/state.h @@ -23,6 +23,12 @@ typedef struct { typedef enum AdjustmentUnit { POINT = 0, PERCENT = 1, PIXEL = 2 } AdjustmentUnit; +struct MenuItem { + const char* *location; + size_t location_count; + const char *definition; +}; + typedef struct { monotonic_t visual_bell_duration, cursor_blink_interval, cursor_stop_blinking_after, mouse_hide_wait, click_interval; double wheel_scroll_multiplier, touch_scroll_multiplier; @@ -92,6 +98,7 @@ typedef struct { int background_blur; long macos_titlebar_color; unsigned long wayland_titlebar_color; + struct { struct MenuItem *entries; size_t count; } global_menu; } Options; typedef struct WindowLogoRenderData { @@ -343,6 +350,7 @@ typedef enum { HIDE_OTHERS, MINIMIZE, QUIT, + USER_MENU_ACTION, NUM_COCOA_PENDING_ACTIONS } CocoaPendingAction;