diff --git a/docs/conf.py b/docs/conf.py index fa41dda92..ddcdc61dd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,6 +12,7 @@ import os import re import subprocess import sys +import textwrap import time from functools import lru_cache, partial from typing import Any, Callable, Dict, Iterable, Iterator, List, Tuple @@ -30,6 +31,7 @@ if kitty_src not in sys.path: from kitty.conf.types import Definition, expand_opt_references # noqa from kitty.constants import str_version, website_url # noqa from kitty.fast_data_types import Shlex, TEXT_SIZE_CODE # noqa +from kittens.panel.main import default_quake_cmdline # noqa # config {{{ # -- Project information ----------------------------------------------------- @@ -120,6 +122,9 @@ string_replacements = { '_kitty_install_cmd': 'curl -L https://sw.kovidgoyal.net/kitty/installer.sh | sh /dev/stdin', '_build_go_version': go_version('../go.mod'), '_text_size_code': str(TEXT_SIZE_CODE), + '_default_quake_cmdline': textwrap.fill( + default_quake_cmdline, break_on_hyphens=False, break_long_words=False, initial_indent=' ' * 4, subsequent_indent=' ' * 8, width=77, + ).replace('\n', ' \\\n'), } @@ -352,7 +357,7 @@ def write_remote_control_protocol_docs() -> None: # {{{ def replace_string(app: Any, docname: str, source: List[str]) -> None: # {{{ src = source[0] - for k, v in app.config.string_replacements.items(): + for k, v in string_replacements.items(): src = src.replace(k, v) source[0] = src # }}} @@ -769,7 +774,6 @@ def setup(app: Any) -> None: write_remote_control_protocol_docs() write_color_names_table() write_conf_docs(app, kn) - app.add_config_value('string_replacements', {}, True) app.connect('source-read', replace_string) app.add_config_value('analytics_id', '', 'env') app.connect('html-page-context', add_html_context) diff --git a/docs/kittens/panel.rst b/docs/kittens/panel.rst index a1f2850de..3743e9868 100644 --- a/docs/kittens/panel.rst +++ b/docs/kittens/panel.rst @@ -75,12 +75,11 @@ Make a Quake like quick access terminal Support for quake mode, works only on macOS and Wayland, except for GNOME. This kitten can be used to make a quick access terminal, that appears and -disappears at a key press. To do so use the following command:: +disappears at a key press. To do so use the following command: - kitty +kitten panel --edge=top --layer=overlay --lines=15 \ - --focus-policy=exclusive --exclusive-zone=0 --override-exclusive-zone \ - -o background_opacity=0.8 --toggle-visibility --single-instance \ - --instance-group=quake --detach +.. code-block:: sh + +_default_quake_cmdline Run this command in a terminal, and a quick access kitty panel will show up at the top of your screen. Run it again, and the panel will be hidden. diff --git a/glfw/cocoa_init.m b/glfw/cocoa_init.m index 4295d0887..fb4479528 100644 --- a/glfw/cocoa_init.m +++ b/glfw/cocoa_init.m @@ -358,8 +358,7 @@ static GLFWapplicationwillfinishlaunchingfun finish_launching_callback = NULL; else createMenuBar(); } - if (finish_launching_callback) - finish_launching_callback(); + if (finish_launching_callback) finish_launching_callback(false); } - (BOOL)application:(NSApplication *)sender openFile:(NSString *)filename { @@ -410,6 +409,7 @@ static GLFWapplicationwillfinishlaunchingfun finish_launching_callback = NULL; - (void)applicationDidFinishLaunching:(NSNotification *)notification { + if (finish_launching_callback) finish_launching_callback(true); (void)notification; [NSApp stop:nil]; diff --git a/glfw/cocoa_platform.h b/glfw/cocoa_platform.h index df33821c7..727d23d5d 100644 --- a/glfw/cocoa_platform.h +++ b/glfw/cocoa_platform.h @@ -67,7 +67,7 @@ typedef void* id; typedef int (* GLFWcocoatextinputfilterfun)(int,int,unsigned int, unsigned long); typedef bool (* GLFWapplicationshouldhandlereopenfun)(int); typedef bool (* GLFWhandleurlopen)(const char*); -typedef void (* GLFWapplicationwillfinishlaunchingfun)(void); +typedef void (* GLFWapplicationwillfinishlaunchingfun)(bool); typedef bool (* GLFWcocoatogglefullscreenfun)(GLFWwindow*); typedef void (* GLFWcocoarenderframefun)(GLFWwindow*); diff --git a/glfw/glfw.py b/glfw/glfw.py index c5b007391..ab00fcf01 100755 --- a/glfw/glfw.py +++ b/glfw/glfw.py @@ -361,7 +361,7 @@ def generate_wrappers(glfw_header: str) -> None: typedef int (* GLFWcocoatextinputfilterfun)(int,int,unsigned int,unsigned long); typedef bool (* GLFWapplicationshouldhandlereopenfun)(int); typedef bool (* GLFWhandleurlopen)(const char*); -typedef void (* GLFWapplicationwillfinishlaunchingfun)(void); +typedef void (* GLFWapplicationwillfinishlaunchingfun)(bool); typedef bool (* GLFWcocoatogglefullscreenfun)(GLFWwindow*); typedef void (* GLFWcocoarenderframefun)(GLFWwindow*); typedef void (*GLFWwaylandframecallbackfunc)(unsigned long long id); diff --git a/kittens/panel/main.py b/kittens/panel/main.py index ad4bac018..063e99f81 100644 --- a/kittens/panel/main.py +++ b/kittens/panel/main.py @@ -33,6 +33,13 @@ from kitty.types import LayerShellConfig from kitty.typing import BossType, EdgeLiteral from kitty.utils import log_error +quake = ( + 'kitty +kitten panel --edge=top --layer=overlay --lines=25 --focus-policy=exclusive' + ' -o background_opacity=0.8 --toggle-visibility --single-instance --instance-group=quake' +) +default_quake_cmdline = f'{quake} --exclusive-zone=0 --override-exclusive-zone --detach' +default_macos_quake_cmdline = f'{quake}' + OPTIONS = r''' --lines default=1 @@ -363,6 +370,8 @@ def main(sys_args: list[str]) -> None: sys.argv.extend(('--override', override)) sys.argv.append('--override=linux_display_server=auto') sys.argv.append('--override=macos_quit_when_last_window_closed=yes') + sys.argv.append('--override=macos_hide_from_tasks=yes') + sys.argv.append('--override=macos_window_resizable=no') if args.single_instance: sys.argv.append('--single-instance') if args.instance_group: @@ -378,7 +387,7 @@ def main(sys_args: list[str]) -> None: if not is_macos: run_app.first_window_callback = setup_x11_window run_app.initial_window_size_func = initial_window_size_func - real_main() + real_main(called_from_panel=True) if __name__ == '__main__': diff --git a/kitty/boss.py b/kitty/boss.py index 02c415d6f..bc770ee91 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -108,6 +108,7 @@ from .fast_data_types import ( thread_write, toggle_fullscreen, toggle_maximized, + toggle_os_window_visibility, toggle_secure_input, wrapped_kitten_names, ) @@ -900,6 +901,10 @@ class Boss: log_error('Unknown message received over single instance socket, ignoring') return None + def quick_access_terminal_invoked(self) -> None: + for os_window_id in self.os_window_map: + toggle_os_window_visibility(os_window_id) + def handle_remote_cmd(self, cmd: memoryview, window: Window | None = None) -> None: response = self._handle_remote_command(cmd, window) if response is not None and not isinstance(response, AsyncResponse) and window is not None: diff --git a/kitty/cocoa_window.h b/kitty/cocoa_window.h index b9df9187a..ce76b9d2e 100644 --- a/kitty/cocoa_window.h +++ b/kitty/cocoa_window.h @@ -43,7 +43,7 @@ typedef enum { void cocoa_focus_window(void *w); long cocoa_window_number(void *w); -void cocoa_create_global_menu(void); +void cocoa_application_lifecycle_event(bool); void cocoa_recreate_global_menu(void); void cocoa_system_beep(const char*); void cocoa_set_activation_policy(bool); diff --git a/kitty/cocoa_window.m b/kitty/cocoa_window.m index 0e08ee7b3..58a552b82 100644 --- a/kitty/cocoa_window.m +++ b/kitty/cocoa_window.m @@ -50,6 +50,7 @@ extern CGSConnectionID _CGSDefaultConnection(void); CFArrayRef CGSCopySpacesForWindows(CGSConnectionID Connection, CGSSpaceSelector Type, CFArrayRef Windows); static NSMenuItem* title_menu = NULL; +static bool application_has_finished_launching = false; static NSString* @@ -679,6 +680,12 @@ cocoa_send_notification(PyObject *self UNUSED, PyObject *args, PyObject *kw) { return YES; } +- (void)quickAccessTerminal:(NSPasteboard *)pboard userData:(NSString *)userData error:(NSString **)error { + // we ignore event during application launch as it will cause the window to be shown and hidden + static bool is_first_event = true; + if (is_first_event) is_first_event = false; + else { call_boss(quick_access_terminal_invoked, NULL) } +} @end // global menu {{{ @@ -712,7 +719,7 @@ add_user_global_menu_entry(struct MenuItem *e, NSMenu *bar, size_t action_index) } } -void +static void cocoa_create_global_menu(void) { NSString* app_name = find_app_name(); NSMenu* bar = [[NSMenu alloc] init]; @@ -844,6 +851,13 @@ cocoa_create_global_menu(void) { #undef MENU_ITEM } +void +cocoa_application_lifecycle_event(bool application_launch_finished) { + if (application_launch_finished) { // applicationDidFinishLaunching + application_has_finished_launching = true; + } else cocoa_create_global_menu(); // applicationWillFinishLaunching +} + void cocoa_update_menu_bar_title(PyObject *pytitle) { if (!pytitle) return; diff --git a/kitty/constants.py b/kitty/constants.py index 0962df303..bffd29bbd 100644 --- a/kitty/constants.py +++ b/kitty/constants.py @@ -31,10 +31,12 @@ is_running_from_develop: bool = False RC_ENCRYPTION_PROTOCOL_VERSION = '1' website_base_url = 'https://sw.kovidgoyal.net/kitty/' default_pager_for_help = ('less', '-iRXF') -launched_by_launch_services = getattr(sys, 'kitty_run_data', {}).get('launched_by_launch_services', False) +kitty_run_data: dict[str, Any] = getattr(sys, 'kitty_run_data', {}) +launched_by_launch_services = kitty_run_data.get('launched_by_launch_services', False) +is_quick_access_terminal_app = kitty_run_data.get('is_quick_access_terminal_app', False) if getattr(sys, 'frozen', False): - extensions_dir: str = getattr(sys, 'kitty_run_data')['extensions_dir'] + extensions_dir: str = kitty_run_data['extensions_dir'] def get_frozen_base() -> str: global is_running_from_develop @@ -66,7 +68,7 @@ else: @run_once def kitty_exe() -> str: - rpath = getattr(sys, 'kitty_run_data').get('bundle_exe_dir') + rpath = kitty_run_data.get('bundle_exe_dir') if not rpath: items = os.environ.get('PATH', '').split(os.pathsep) + [os.path.join(kitty_base_dir, 'kitty', 'launcher')] seen: set[str] = set() @@ -87,7 +89,7 @@ def kitten_exe() -> str: def _get_config_dir() -> str: - cdir = getattr(sys, 'kitty_run_data', {}).get('config_dir', '') + cdir = kitty_run_data.get('config_dir', '') if cdir: return str(cdir) import atexit @@ -276,7 +278,7 @@ def clear_handled_signals(*a: Any) -> None: def local_docs() -> str: d = os.path.dirname base = d(d(kitty_exe())) - from_source = getattr(sys, 'kitty_run_data').get('from_source') + from_source = kitty_run_data.get('from_source') if is_macos and from_source and '/kitty.app/Contents/' in kitty_exe(): base = d(d(d(base))) subdir = os.path.join('doc', 'kitty', 'html') diff --git a/kitty/glfw-wrapper.h b/kitty/glfw-wrapper.h index fca9f20c0..d094b1e94 100644 --- a/kitty/glfw-wrapper.h +++ b/kitty/glfw-wrapper.h @@ -1710,7 +1710,7 @@ typedef struct GLFWgamepadstate typedef int (* GLFWcocoatextinputfilterfun)(int,int,unsigned int,unsigned long); typedef bool (* GLFWapplicationshouldhandlereopenfun)(int); typedef bool (* GLFWhandleurlopen)(const char*); -typedef void (* GLFWapplicationwillfinishlaunchingfun)(void); +typedef void (* GLFWapplicationwillfinishlaunchingfun)(bool); typedef bool (* GLFWcocoatogglefullscreenfun)(GLFWwindow*); typedef void (* GLFWcocoarenderframefun)(GLFWwindow*); typedef void (*GLFWwaylandframecallbackfunc)(unsigned long long id); diff --git a/kitty/glfw.c b/kitty/glfw.c index 775ef5842..cbab2dc5b 100644 --- a/kitty/glfw.c +++ b/kitty/glfw.c @@ -1299,7 +1299,7 @@ create_os_window(PyObject UNUSED *self, PyObject *args, PyObject *kw) { cocoa_set_activation_policy(OPT(macos_hide_from_tasks) || lsc != NULL); glfwWindowHint(GLFW_COCOA_GRAPHICS_SWITCHING, true); glfwSetApplicationShouldHandleReopen(on_application_reopen); - glfwSetApplicationWillFinishLaunching(cocoa_create_global_menu); + glfwSetApplicationWillFinishLaunching(cocoa_application_lifecycle_event); #endif } if (OPT(hide_window_decorations) & 1) glfwWindowHint(GLFW_DECORATED, false); diff --git a/kitty/launcher/main.c b/kitty/launcher/main.c index ca47ab6e9..06a1077c7 100644 --- a/kitty/launcher/main.c +++ b/kitty/launcher/main.c @@ -44,7 +44,7 @@ typedef struct { const char *exe, *exe_dir, *lc_ctype, *lib_dir, *config_dir; char **argv; int argc; - bool launched_by_launch_services; + bool launched_by_launch_services, is_quick_access_terminal; } RunData; static bool @@ -70,6 +70,9 @@ set_kitty_run_data(RunData *run_data, bool from_source, wchar_t *extensions_dir) PyObject *lbls = run_data->launched_by_launch_services ? Py_True : Py_False; Py_INCREF(lbls); S(launched_by_launch_services, lbls); + lbls = run_data->is_quick_access_terminal ? Py_True : Py_False; + Py_INCREF(lbls); + S(is_quick_access_terminal_app, lbls); char buf[PATH_MAX + 1]; if (run_data->config_dir == NULL) { @@ -438,19 +441,33 @@ delegate_to_kitten_if_possible(int argc, char *argv[], char* exe_dir) { return false; } +static bool +endswith(const char *str, const char *suffix) { + size_t strLen = strlen(str); + size_t suffixLen = strlen(suffix); + if (suffixLen > strLen) return false; + return strcmp(str + strLen - suffixLen, suffix) == 0; +} + int main(int argc_, char *argv_[], char* envp[]) { if (argc_ < 1 || !argv_) { fprintf(stderr, "Invalid argc/argv\n"); return 1; } if (!ensure_working_stdio()) return 1; char exe[PATH_MAX+1] = {0}; + if (!read_exe_path(exe, sizeof(exe))) return 1; char exe_dir_buf[PATH_MAX+1] = {0}; + strncpy(exe_dir_buf, exe, sizeof(exe_dir_buf)); + char *exe_dir = dirname(exe_dir_buf); + RAII_ALLOC(const char, lc_ctype, NULL); bool launched_by_launch_services = false; const char *config_dir = NULL; + bool is_quick_access_terminal = false; argv_array argva = {.argv = argv_, .count = argc_}; #ifdef __APPLE__ lc_ctype = getenv("LC_CTYPE"); if (lc_ctype) lc_ctype = strdup(lc_ctype); char abuf[PATH_MAX+1]; + is_quick_access_terminal = endswith(exe, "/kitty-quick-access"); if (getenv("KITTY_LAUNCHED_BY_LAUNCH_SERVICES")) { launched_by_launch_services = true; unsetenv("KITTY_LAUNCHED_BY_LAUNCH_SERVICES"); @@ -462,11 +479,10 @@ int main(int argc_, char *argv_[], char* envp[]) { if (!get_argv_from(cbuf, argva.argv[0], &argva)) exit(1); } } +#else + (void)endswith; #endif (void)read_full_file; - if (!read_exe_path(exe, sizeof(exe))) return 1; - strncpy(exe_dir_buf, exe, sizeof(exe_dir_buf)); - char *exe_dir = dirname(exe_dir_buf); if (!delegate_to_kitten_if_possible(argva.count, argva.argv, exe_dir)) handle_fast_commandline(argva.count, argva.argv, NULL); int ret=0; char lib[PATH_MAX+1] = {0}; @@ -477,7 +493,7 @@ int main(int argc_, char *argv_[], char* envp[]) { } RunData run_data = { .exe = exe, .exe_dir = exe_dir, .lib_dir = lib, .argc = argva.count, .argv = argva.argv, .lc_ctype = lc_ctype, - .launched_by_launch_services=launched_by_launch_services, .config_dir = config_dir, + .launched_by_launch_services=launched_by_launch_services, .config_dir = config_dir, .is_quick_access_terminal=is_quick_access_terminal, }; ret = run_embedded(&run_data); free_argv_array(&argva); diff --git a/kitty/main.py b/kitty/main.py index 0557e69c8..1a28c5b9a 100644 --- a/kitty/main.py +++ b/kitty/main.py @@ -22,6 +22,7 @@ from .constants import ( clear_handled_signals, glfw_path, is_macos, + is_quick_access_terminal_app, is_wayland, kitten_exe, kitty_exe, @@ -62,6 +63,7 @@ from .utils import ( log_error, parse_os_window_state, safe_mtime, + shlex_split, startup_notification_handler, ) @@ -451,13 +453,10 @@ def set_locale() -> None: set_LANG_in_default_env(old_lang) -def _main() -> None: +def kitty_main() -> None: running_in_kitty(True) args = sys.argv[1:] - if is_macos and launched_by_launch_services: - os.chdir(os.path.expanduser('~')) - set_use_os_log(True) try: cwd_ok = os.path.isdir(os.getcwd()) except Exception: @@ -495,6 +494,8 @@ def _main() -> None: return bad_lines: list[BadLine] = [] opts = create_opts(cli_opts, accumulate_bad_lines=bad_lines) + if is_quick_access_terminal_app: + opts.macos_hide_from_tasks = True setup_environment(opts, cli_opts) # set_locale on macOS uses cocoa APIs when LANG is not set, so we have to @@ -524,9 +525,20 @@ def _main() -> None: cleanup_ssh_control_masters() -def main() -> None: + +def main(called_from_panel: bool = False) -> None: + global redirected_for_quick_access try: - _main() + if is_macos and launched_by_launch_services and not called_from_panel: + with suppress(OSError): + os.chdir(os.path.expanduser('~')) + set_use_os_log(True) + if is_quick_access_terminal_app: + from kittens.panel.main import default_macos_quake_cmdline + from kittens.panel.main import main as panel_main + panel_main(list(shlex_split(default_macos_quake_cmdline))[2:]) + return + kitty_main() except Exception: import traceback tb = traceback.format_exc() diff --git a/setup.py b/setup.py index dafcdcce7..c4be4d79a 100755 --- a/setup.py +++ b/setup.py @@ -1486,14 +1486,14 @@ MimeType=image/*;application/x-sh;application/x-shellscript;inode/directory;text os.symlink(os.path.relpath(launcher, os.path.dirname(in_src_launcher)), in_src_launcher) -def macos_info_plist() -> bytes: +def macos_info_plist(for_quake: bool = False, quake_desc: str = f'Quick access to {appname}') -> bytes: import plistlib VERSION = '.'.join(map(str, version)) def access(what: str, verb: str = 'would like to access') -> str: return f'A program running inside kitty {verb} {what}' - docs = [ + docs = [] if for_quake else [ { 'CFBundleTypeName': 'Terminal scripts', 'CFBundleTypeExtensions': ['command', 'sh', 'zsh', 'bash', 'fish', 'tool'], @@ -1531,7 +1531,7 @@ def macos_info_plist() -> bytes: }, ] - url_schemes = [ + url_schemes = [] if for_quake else [ { 'CFBundleURLName': 'File URL', 'CFBundleURLSchemes': ['file'], @@ -1585,6 +1585,12 @@ def macos_info_plist() -> bytes: ] services = [ + { + 'NSMenuItem': {'default': quake_desc}, + 'NSMessage': 'quickAccessTerminal', + 'NSRequiredContext': {'NSServiceCategory': 'None'}, + }, + ] if for_quake else [ { 'NSMenuItem': {'default': f'New {appname} Tab Here'}, 'NSMessage': 'openTab', @@ -1607,10 +1613,10 @@ def macos_info_plist() -> bytes: pl = dict( # Naming - CFBundleName=appname, - CFBundleDisplayName=appname, + CFBundleName=f'{appname}-quick-access' if for_quake else appname, + CFBundleDisplayName=f'{appname}-quick-access' if for_quake else appname, # Identification - CFBundleIdentifier=f'net.kovidgoyal.{appname}', + CFBundleIdentifier=f'net.kovidgoyal.{appname}' + ('-quick-access' if for_quake else ''), # Bundle Version Info CFBundleVersion=VERSION, CFBundleShortVersionString=VERSION, @@ -1624,7 +1630,7 @@ def macos_info_plist() -> bytes: CFBundleSignature='????', LSApplicationCategoryType='public.app-category.utilities', # App Execution - CFBundleExecutable=appname, + CFBundleExecutable='kitty-quick-access' if for_quake else appname, LSEnvironment={'KITTY_LAUNCHED_BY_LAUNCH_SERVICES': '1'}, LSRequiresNativeExecution=True, NSSupportsSuddenTermination=False, @@ -1670,6 +1676,9 @@ def macos_info_plist() -> bytes: # Speech NSSpeechRecognitionUsageDescription=access('speech recognition.'), ) + if for_quake: + # exclude from dock and menubar + pl['LSBackgroundOnly'] = True return plistlib.dumps(pl) @@ -1694,6 +1703,22 @@ def create_macos_app_icon(where: str = 'Resources') -> None: ]]) +def create_quick_access_bundle(kapp: str) -> None: + qapp = os.path.join(kapp, 'Contents', 'kitty-quick-access.app') + base_exe_dir = os.path.join(kapp, 'Contents/MacOS') + if os.path.exists(qapp): + shutil.rmtree(qapp) + bin_dir = os.path.join(qapp, 'Contents/MacOS') + os.makedirs(bin_dir) + with open(os.path.join(qapp, 'Contents/Info.plist'), 'wb') as f: + f.write(macos_info_plist(for_quake=True, quake_desc=f'Quick access to {appname} built from source')) + for exe in os.listdir(base_exe_dir): + os.symlink(f'../../../MacOS/{exe}', os.path.join(bin_dir, exe)) + shutil.copy2(os.path.join(base_exe_dir, 'kitty'), os.path.join(bin_dir, 'kitty-quick-access')) + for x in ('Frameworks', 'Resources'): + os.symlink(f'../../{x}', os.path.join(qapp, 'Contents', x)) + + def create_minimal_macos_bundle(args: Options, launcher_dir: str, relocate: bool = False) -> None: kapp = os.path.join(launcher_dir, 'kitty.app') if os.path.exists(kapp): @@ -1715,6 +1740,7 @@ def create_minimal_macos_bundle(args: Options, launcher_dir: str, relocate: bool os.remove(kitty_exe) os.symlink(os.path.join(os.path.relpath(bin_dir, launcher_dir), appname), kitty_exe) create_macos_app_icon(resources_dir) + create_quick_access_bundle(kapp) def create_macos_bundle_gunk(dest: str, for_freeze: bool, args: Options) -> str: @@ -1741,6 +1767,7 @@ def create_macos_bundle_gunk(dest: str, for_freeze: bool, args: Options) -> str: raise SystemExit('kitten not built cannot create macOS bundle') os.symlink(os.path.relpath(kitten_exe, os.path.dirname(in_src_launcher)), os.path.join(os.path.dirname(in_src_launcher), os.path.basename(kitten_exe))) + create_quick_access_bundle(dest) return str(kitty_exe)