Get the quake terminal working on macOS

This commit is contained in:
Kovid Goyal 2025-04-25 20:27:26 +05:30
parent 610d6b8654
commit 189d78283c
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
15 changed files with 127 additions and 39 deletions

View file

@ -12,6 +12,7 @@
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 @@
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 @@ def go_version(go_mod_path: str) -> str: # {{{
'_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 format_cmd(p: Callable[..., None], name: str, cmd: RemoteCommand) -> 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)

View file

@ -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.

View file

@ -358,8 +358,7 @@ - (void)applicationWillFinishLaunching:(NSNotification *)notification
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 @@ - (void)application:(NSApplication *)sender openURLs:(NSArray<NSURL *> *)urls
- (void)applicationDidFinishLaunching:(NSNotification *)notification
{
if (finish_launching_callback) finish_launching_callback(true);
(void)notification;
[NSApp stop:nil];

View file

@ -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*);

View file

@ -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);

View file

@ -33,6 +33,13 @@
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__':

View file

@ -108,6 +108,7 @@
thread_write,
toggle_fullscreen,
toggle_maximized,
toggle_os_window_visibility,
toggle_secure_input,
wrapped_kitten_names,
)
@ -900,6 +901,10 @@ def peer_message_received(self, msg_bytes: bytes, peer_id: int, is_remote_contro
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:

View file

@ -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);

View file

@ -50,6 +50,7 @@
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 @@ - (BOOL)openFileURLs:(NSPasteboard*)pasteboard
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 @@ - (BOOL)openFileURLs:(NSPasteboard*)pasteboard
}
}
void
static void
cocoa_create_global_menu(void) {
NSString* app_name = find_app_name();
NSMenu* bar = [[NSMenu alloc] init];
@ -844,6 +851,13 @@ - (BOOL)openFileURLs:(NSPasteboard*)pasteboard
#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;

View file

@ -31,10 +31,12 @@ class Version(NamedTuple):
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 @@ def get_frozen_base() -> str:
@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')

2
kitty/glfw-wrapper.h generated
View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -22,6 +22,7 @@
clear_handled_signals,
glfw_path,
is_macos,
is_quick_access_terminal_app,
is_wayland,
kitten_exe,
kitty_exe,
@ -62,6 +63,7 @@
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()

View file

@ -1486,14 +1486,14 @@ def create_linux_bundle_gunk(ddir: str, args: Options) -> None:
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 access(what: str, verb: str = 'would like to access') -> str:
},
]
url_schemes = [
url_schemes = [] if for_quake else [
{
'CFBundleURLName': 'File URL',
'CFBundleURLSchemes': ['file'],
@ -1585,6 +1585,12 @@ def access(what: str, verb: str = 'would like to access') -> str:
]
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 access(what: str, verb: str = 'would like to access') -> str:
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 access(what: str, verb: str = 'would like to access') -> str:
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 access(what: str, verb: str = 'would like to access') -> str:
# 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)