Files
kitty-mirror/kitty/main.py
Kovid Goyal 20b39ee163 Add a default mapping for searching the scrollback
Opens the scrollback pager in search mode. Particularly useful for
newbies on macOS that are used to using cmd+f to trigger search mode.

If there is a current selection, it is automatically searched for.
2025-12-01 08:10:07 +05:30

622 lines
25 KiB
Python

#!/usr/bin/env python
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
import json
import locale
import os
import shutil
import sys
from collections.abc import Generator, Sequence
from contextlib import contextmanager, suppress
from gettext import gettext as _
from .borders import load_borders_program
from .boss import Boss
from .child import set_default_env, set_LANG_in_default_env
from .cli import create_opts, parse_args
from .cli_stub import CLIOptions
from .colors import theme_colors
from .conf.utils import BadLine
from .config import cached_values_for
from .constants import (
appname,
beam_cursor_data_file,
clear_handled_signals,
glfw_path,
is_macos,
is_quick_access_terminal_app,
is_wayland,
kitten_exe,
kitty_exe,
launched_by_launch_services,
logo_png_file,
running_in_kitty,
supports_window_occlusion,
website_url,
)
from .fast_data_types import (
GLFW_MOD_ALT,
GLFW_MOD_SHIFT,
SingleKey,
create_os_window,
free_font_data,
glfw_get_monitor_names,
glfw_get_monitor_workarea,
glfw_init,
glfw_terminate,
grab_keyboard,
is_layer_shell_supported,
load_png_data,
mask_kitty_signals_process_wide,
run_at_exit_cleanup_functions,
set_custom_cursor,
set_default_window_icon,
set_options,
set_use_os_log,
)
from .fonts.render import dump_font_debug, set_font_family
from .options.types import Options
from .options.utils import DELETE_ENV_VAR
from .os_window_size import edge_spacing, initial_window_size_func
from .session import create_sessions, get_os_window_sizing_data
from .shaders import CompileError, load_shader_programs
from .types import LayerShellConfig
from .utils import (
cleanup_ssh_control_masters,
expandvars,
get_custom_window_icon,
log_error,
parse_os_window_state,
read_shell_environment,
safe_mtime,
startup_notification_handler,
)
def set_custom_ibeam_cursor() -> None:
with open(beam_cursor_data_file, 'rb') as f:
data = f.read()
rgba_data, width, height = load_png_data(data)
c2x = os.path.splitext(beam_cursor_data_file)
with open(f'{c2x[0]}@2x{c2x[1]}', 'rb') as f:
data = f.read()
rgba_data2, width2, height2 = load_png_data(data)
images = (rgba_data, width, height), (rgba_data2, width2, height2)
try:
set_custom_cursor("beam", images, 4, 8)
except Exception as e:
log_error(f'Failed to set custom beam cursor with error: {e}')
def load_all_shaders() -> None:
try:
load_shader_programs()
load_borders_program()
except CompileError as err:
raise SystemExit(err)
def init_glfw_module(glfw_module: str = 'wayland', debug_keyboard: bool = False, debug_rendering: bool = False, wayland_enable_ime: bool = True) -> None:
ok, swo = glfw_init(glfw_path(glfw_module), edge_spacing, debug_keyboard, debug_rendering, wayland_enable_ime)
if not ok:
raise SystemExit('GLFW initialization failed')
supports_window_occlusion(swo)
def init_glfw(opts: Options, debug_keyboard: bool = False, debug_rendering: bool = False) -> str:
glfw_module = 'cocoa' if is_macos else ('wayland' if is_wayland(opts) else 'x11')
init_glfw_module(glfw_module, debug_keyboard, debug_rendering, wayland_enable_ime=opts.wayland_enable_ime)
return glfw_module
def get_macos_shortcut_for(
func_map: dict[tuple[str, ...], list[SingleKey]], defn: str = 'new_os_window', lookup_name: str = ''
) -> SingleKey | None:
# for maximum robustness we should use opts.alias_map to resolve
# aliases however this requires parsing everything on startup which could
# be potentially slow. Lets just hope the user doesn't alias these
# functions.
ans = None
candidates = []
qkey = tuple(defn.split())
candidates = func_map[qkey]
if candidates:
from .fast_data_types import cocoa_set_global_shortcut
alt_mods = GLFW_MOD_ALT, GLFW_MOD_ALT | GLFW_MOD_SHIFT
# Reverse list so that later defined keyboard shortcuts take priority over earlier defined ones
for candidate in reversed(candidates):
if candidate.mods in alt_mods:
# Option based shortcuts dont work in the global menubar,
# presumably because Apple reserves them for IME, see
# https://github.com/kovidgoyal/kitty/issues/3515
continue
if cocoa_set_global_shortcut(lookup_name or qkey[0], candidate[0], candidate[2]):
ans = candidate
break
return ans
def set_macos_app_custom_icon() -> None:
custom_icon_mtime, custom_icon_path = get_custom_window_icon()
if custom_icon_mtime is not None and custom_icon_path is not None:
from .fast_data_types import cocoa_set_app_icon, cocoa_set_dock_icon
krd = getattr(sys, 'kitty_run_data')
bundle_path = os.path.dirname(os.path.dirname(krd.get('bundle_exe_dir')))
icon_sentinel = os.path.join(bundle_path, 'Icon\r')
sentinel_mtime = safe_mtime(icon_sentinel)
if sentinel_mtime is None or sentinel_mtime < custom_icon_mtime:
try:
cocoa_set_app_icon(custom_icon_path, bundle_path)
except (FileNotFoundError, OSError) as e:
log_error(str(e))
log_error('Failed to set custom app icon, ignoring')
# macOS Dock does not reload icons until it is restarted, so we set
# the application icon here. This will revert when kitty quits, but
# can't be helped since there appears to be no way to get the dock
# to reload short of killing it.
cocoa_set_dock_icon(custom_icon_path)
def get_icon128_path(base_path: str) -> str:
# max icon size on X11 64bits is 128x128
path, ext = os.path.splitext(base_path)
return f'{path}-128{ext}'
def set_window_icon() -> None:
custom_icon_path = get_custom_window_icon()[1]
is_x11 = not is_macos and not is_wayland()
try:
if custom_icon_path is not None:
custom_icon128_path = get_icon128_path(custom_icon_path)
if is_x11 and safe_mtime(custom_icon128_path) is not None:
set_default_window_icon(custom_icon128_path)
else:
set_default_window_icon(custom_icon_path)
else:
if is_x11:
set_default_window_icon(get_icon128_path(logo_png_file))
except ValueError as err:
log_error(err)
def set_cocoa_global_shortcuts(opts: Options) -> dict[str, SingleKey]:
global_shortcuts: dict[str, SingleKey] = {}
if is_macos:
from collections import defaultdict
func_map = defaultdict(list)
for single_key, v in opts.keyboard_modes[''].keymap.items():
kd = v[-1] # the last definition is the active one
if kd.is_suitable_for_global_shortcut:
parts = tuple(kd.definition.split())
func_map[parts].append(single_key)
for ac in ('new_os_window', 'close_os_window', 'close_tab', 'edit_config_file', 'previous_tab',
'next_tab', 'new_tab', 'new_window', 'close_window', 'toggle_macos_secure_keyboard_entry',
'toggle_fullscreen', 'macos_cycle_through_os_windows', 'macos_cycle_through_os_windows_backwards',
'hide_macos_app', 'hide_macos_other_apps', 'minimize_macos_window', 'quit', 'search_scrollback'):
val = get_macos_shortcut_for(func_map, ac)
if val is not None:
global_shortcuts[ac] = val
val = get_macos_shortcut_for(func_map, 'clear_terminal reset active', lookup_name='reset_terminal')
if val is not None:
global_shortcuts['reset_terminal'] = val
val = get_macos_shortcut_for(func_map, 'clear_terminal to_cursor active', lookup_name='clear_terminal_and_scrollback')
if val is not None:
global_shortcuts['clear_terminal_and_scrollback'] = val
val = get_macos_shortcut_for(func_map, 'clear_terminal scrollback active', lookup_name='clear_scrollback')
if val is not None:
global_shortcuts['clear_scrollback'] = val
val = get_macos_shortcut_for(func_map, 'clear_terminal to_cursor_scroll active', lookup_name='clear_screen')
if val is not None:
global_shortcuts['clear_screen'] = val
val = get_macos_shortcut_for(func_map, 'clear_terminal last_command active', lookup_name='clear_last_command')
if val is not None:
global_shortcuts['clear_last_command'] = val
val = get_macos_shortcut_for(func_map, 'load_config_file', lookup_name='reload_config')
if val is not None:
global_shortcuts['reload_config'] = val
val = get_macos_shortcut_for(func_map, f'open_url {website_url()}', lookup_name='open_kitty_website')
if val is not None:
global_shortcuts['open_kitty_website'] = val
return global_shortcuts
_is_panel_kitten = False
def is_panel_kitten() -> bool:
return _is_panel_kitten
def list_monitors(json_output: bool = False) -> None:
monitor_names = glfw_get_monitor_names()
has_descriptions = False
for (name, desc) in monitor_names:
if desc:
has_descriptions = True
break
if json_output:
if has_descriptions:
monitors_list_of_dict = [{'name': name, 'description': desc} for name, desc in monitor_names]
else:
monitors_list_of_dict = [{'name': name} for name, _ in monitor_names]
json.dump(monitors_list_of_dict, sys.stdout, indent=2, sort_keys=True)
print()
return
isatty = sys.stdout.isatty()
for (name, desc) in monitor_names:
if isatty:
name = f'\x1b[32m{name}\x1b[39m' # ]]
print(name)
if desc:
print(f'\t{desc}')
if has_descriptions:
print()
def _run_app(opts: Options, args: CLIOptions, bad_lines: Sequence[BadLine] = (), talk_fd: int = -1) -> None:
global _is_panel_kitten
_is_panel_kitten = run_app.cached_values_name == 'panel'
if _is_panel_kitten and run_app.layer_shell_config and run_app.layer_shell_config.output_name in ('list', 'listjson'):
list_monitors(run_app.layer_shell_config.output_name == 'listjson')
return
if is_macos:
global_shortcuts = set_cocoa_global_shortcuts(opts)
if opts.macos_custom_beam_cursor:
set_custom_ibeam_cursor()
set_macos_app_custom_icon()
else:
global_shortcuts = {}
set_window_icon()
if _is_panel_kitten and not is_layer_shell_supported():
raise SystemExit('Cannot create panels as the window manager/compositor does not support the necessary protocols')
pos_x, pos_y = None, None
if args.grab_keyboard:
grab_keyboard(True)
with cached_values_for(run_app.cached_values_name) as cached_values:
if not _is_panel_kitten and not is_wayland():
if opts.remember_window_position:
cached_workarea = tuple(tuple(x) for x in cached_values.get('monitor-workarea', ()))
if cached_workarea and glfw_get_monitor_workarea() == tuple(cached_workarea):
pos_x, pos_y = cached_values.get('window-pos', (None, None))
if args.position:
pos_x, pos_y = map(int, args.position.lower().partition('x')[::2])
startup_session_error: tuple[Exception, str] | None = None
try:
startup_sessions = tuple(create_sessions(opts, args, default_session=opts.startup_session))
except Exception as e:
startup_session_error = (e, (getattr(args, 'session', '') or opts.startup_session or ''))
if getattr(args, 'session', ''):
args.session = ''
startup_sessions = tuple(create_sessions(opts, args))
wincls = (startup_sessions[0].os_window_class if startup_sessions else '') or args.cls or appname
winname = (startup_sessions[0].os_window_name if startup_sessions else '') or args.name or wincls or appname
window_state = (args.start_as if args.start_as and args.start_as != 'normal' else None) or (
getattr(startup_sessions[0], 'os_window_state', None) if startup_sessions else None
)
wstate = parse_os_window_state(window_state) if window_state is not None else None
with startup_notification_handler(extra_callback=run_app.first_window_callback) as pre_show_callback:
window_id = create_os_window(
run_app.initial_window_size_func(get_os_window_sizing_data(opts, startup_sessions[0] if startup_sessions else None), cached_values),
pre_show_callback,
args.title or appname, winname,
wincls, wstate, load_all_shaders, disallow_override_title=bool(args.title), layer_shell_config=run_app.layer_shell_config, x=pos_x, y=pos_y)
boss = Boss(opts, args, cached_values, global_shortcuts, talk_fd)
boss.start(window_id, startup_sessions)
if args.debug_font_fallback:
dump_font_debug()
if bad_lines or boss.misc_config_errors:
boss.show_bad_config_lines(bad_lines, boss.misc_config_errors)
boss.misc_config_errors = []
if startup_session_error:
boss.show_error(_('The startup session was invalid'), _(
'Loading the start session file {0} failed, with error:\n{1}').format(startup_session_error[1], startup_session_error[0]))
try:
boss.child_monitor.main_loop()
finally:
boss.destroy()
class AppRunner:
def __init__(self) -> None:
self.cached_values_name = 'main'
self.first_window_callback = lambda window_handle: None
self.layer_shell_config: LayerShellConfig | None = None
self.initial_window_size_func = initial_window_size_func
def __call__(self, opts: Options, args: CLIOptions, bad_lines: Sequence[BadLine] = (), talk_fd: int = -1) -> None:
if theme_colors.refresh():
theme_colors.patch_opts(opts, args.debug_rendering)
set_options(opts, is_wayland(), args.debug_rendering, args.debug_font_fallback)
try:
set_font_family(opts, add_builtin_nerd_font=True)
_run_app(opts, args, bad_lines, talk_fd)
finally:
set_options(None)
free_font_data() # must free font data before glfw/freetype/fontconfig/opengl etc are finalized
if is_macos:
from kitty.fast_data_types import (
cocoa_set_notification_activated_callback,
)
cocoa_set_notification_activated_callback(None)
run_app = AppRunner()
def ensure_macos_locale() -> None:
# Ensure the LANG env var is set. See
# https://github.com/kovidgoyal/kitty/issues/90
from .fast_data_types import cocoa_get_lang, locale_is_valid
if 'LANG' not in os.environ:
lang_code, country_code, identifier = cocoa_get_lang()
lang = 'en_US'
if identifier and locale_is_valid(identifier):
lang = identifier
elif lang_code and country_code and locale_is_valid(f'{lang_code}_{country_code}'):
lang = f'{lang_code}_{country_code}'
elif lang_code:
if lang_code != 'en':
with suppress(OSError):
found = sorted(x for x in os.listdir('/usr/share/locale') if x.startswith(f'{lang_code}_'))
if found:
lang = found[0].partition('.')[0]
os.environ['LANG'] = f'{lang}.UTF-8'
set_LANG_in_default_env(os.environ['LANG'])
@contextmanager
def setup_profiling() -> Generator[None, None, None]:
try:
from .fast_data_types import start_profiler, stop_profiler
do_profile = True
except ImportError:
do_profile = False
if do_profile:
start_profiler('/tmp/kitty-profile.log')
yield
if do_profile:
import subprocess
stop_profiler()
exe = kitty_exe()
cg = '/tmp/kitty-profile.callgrind'
print('Post processing profile data for', exe, '...')
with open(cg, 'wb') as f:
subprocess.call(['pprof', '--callgrind', exe, '/tmp/kitty-profile.log'], stdout=f)
try:
subprocess.Popen(['kcachegrind', cg], preexec_fn=clear_handled_signals)
except FileNotFoundError:
subprocess.call(['pprof', '--text', exe, '/tmp/kitty-profile.log'])
print('To view the graphical call data, use: kcachegrind', cg)
def expand_listen_on(listen_on: str, from_config_file: bool, env: dict[str, str]) -> str:
if from_config_file and listen_on == 'none':
return ''
listen_on = expandvars(listen_on, env)
if '{kitty_pid}' not in listen_on and from_config_file and listen_on.startswith('unix:'):
listen_on += '-{kitty_pid}'
listen_on = listen_on.replace('{kitty_pid}', str(os.getpid()))
if listen_on.startswith('unix:'):
path = listen_on[len('unix:'):]
if not path.startswith('@'):
if path.startswith('~'):
listen_on = f'unix:{os.path.expanduser(path)}'
elif not os.path.isabs(path):
import tempfile
listen_on = f'unix:{os.path.join(tempfile.gettempdir(), path)}'
elif listen_on.startswith('tcp:') or listen_on.startswith('tcp6:'):
if from_config_file: # use a random port
listen_on = ':'.join(listen_on.split(':', 2)[:2]) + ':0'
return listen_on
def safe_samefile(a: str, b: str) -> bool:
with suppress(OSError):
return os.path.samefile(a, b)
return os.path.abspath(os.path.realpath(a)) == os.path.abspath(os.path.realpath(b))
def prepend_if_not_present(path: str, paths_serialized: str) -> str:
# prepend a path only if path/kitty is not already present, even as a symlink
pq = os.path.join(path, 'kitty')
for candidate in paths_serialized.split(os.pathsep):
q = os.path.join(candidate, 'kitty')
if safe_samefile(q, pq):
return paths_serialized
return path + os.pathsep + paths_serialized
def ensure_kitty_in_path() -> None:
# Ensure the correct kitty is in PATH
krd = getattr(sys, 'kitty_run_data')
rpath = krd.get('bundle_exe_dir')
if not rpath:
return
if rpath:
modify_path = is_macos or getattr(sys, 'frozen', False) or krd.get('from_source')
existing = shutil.which('kitty')
if modify_path or not existing:
env_path = os.environ.get('PATH', '')
correct_kitty = os.path.join(rpath, 'kitty')
if not existing or not safe_samefile(existing, correct_kitty):
os.environ['PATH'] = prepend_if_not_present(rpath, env_path)
def ensure_kitten_in_path() -> None:
correct_kitten = kitten_exe()
existing = shutil.which('kitten')
if existing and safe_samefile(existing, correct_kitten):
return
env_path = os.environ.get('PATH', '')
os.environ['PATH'] = prepend_if_not_present(os.path.dirname(correct_kitten), env_path)
def setup_manpath(env: dict[str, str]) -> None:
# Ensure kitty manpages are available in frozen builds
if not getattr(sys, 'frozen', False):
return
from .constants import local_docs
mp = os.environ.get('MANPATH', env.get('MANPATH', ''))
d = os.path.dirname
kitty_man = os.path.join(d(d(d(local_docs()))), 'man')
if not mp:
env['MANPATH'] = f'{kitty_man}:'
elif mp.startswith(':'):
env['MANPATH'] = f':{kitty_man}:{mp}'
else:
env['MANPATH'] = f'{kitty_man}:{mp}'
def setup_environment(opts: Options, cli_opts: CLIOptions) -> None:
from_config_file = False
if not cli_opts.listen_on:
cli_opts.listen_on = opts.listen_on
from_config_file = True
if vars := opts.env.pop('read_from_shell', ''):
import fnmatch
import re
senv = read_shell_environment(opts)
patterns = tuple(re.compile(fnmatch.translate(x.strip())) for x in vars.split() if x.strip())
if patterns:
for k, v in senv.items():
for pat in patterns:
if pat.match(k) is not None:
opts.env[k] = v
break
if cli_opts.listen_on:
cli_opts.listen_on = expand_listen_on(cli_opts.listen_on, from_config_file, opts.env)
env = opts.env.copy()
ensure_kitty_in_path()
ensure_kitten_in_path()
kitty_path = shutil.which('kitty')
if kitty_path:
child_path = env.get('PATH')
# if child_path is None it will be inherited from os.environ,
# the other values mean the user doesn't want a PATH
if child_path not in ('', DELETE_ENV_VAR) and child_path is not None:
env['PATH'] = prepend_if_not_present(os.path.dirname(kitty_path), env['PATH'])
setup_manpath(env)
set_default_env(env)
def set_locale() -> None:
if is_macos:
ensure_macos_locale()
try:
locale.setlocale(locale.LC_ALL, '')
except Exception:
log_error('Failed to set locale with LANG:', os.environ.get('LANG'))
old_lang = os.environ.pop('LANG', None)
if old_lang is not None:
try:
locale.setlocale(locale.LC_ALL, '')
except Exception:
log_error('Failed to set locale with no LANG')
os.environ['LANG'] = old_lang
set_LANG_in_default_env(old_lang)
def kitty_main(called_from_panel: bool = False) -> None:
running_in_kitty(True)
args = sys.argv[1:]
try:
cwd_ok = os.path.isdir(os.getcwd())
except Exception:
cwd_ok = False
if not cwd_ok:
os.chdir(os.path.expanduser('~'))
cli_flags = None
if getattr(sys, 'cmdline_args_for_open', False):
usage: str | None = 'file_or_url ...'
appname: str | None = 'kitty +open'
msg: str | None = (
'Run kitty and open the specified files or URLs in it, using launch-actions.conf. For details'
' see https://sw.kovidgoyal.net/kitty/open_actions/#scripting-the-opening-of-files-with-kitty-on-macos'
'\n\nAll the normal kitty options can be used.')
else:
if not called_from_panel:
cli_flags = getattr(sys, 'kitty_run_data', {}).get('cli_flags', None)
usage = msg = appname = None
cli_opts, rest = parse_args(args=args, result_class=CLIOptions, usage=usage, message=msg, appname=appname, preparsed_from_c=cli_flags)
if getattr(sys, 'cmdline_args_for_open', False):
setattr(sys, 'cmdline_args_for_open', rest)
cli_opts.args = []
else:
cli_opts.args = rest
talk_fd = -1
if cli_opts.single_instance:
si_data = os.environ.pop('KITTY_SI_DATA', '')
if si_data:
talk_fd = int(si_data)
if cli_opts.detach:
if cli_opts.session == '-':
from .session import PreReadSession
cli_opts.session = PreReadSession(sys.stdin.read(), os.environ, '-', os.path.join(os.getcwd(), '-'))
if cli_opts.replay_commands:
from kitty.client import main as client_main
client_main(cli_opts.replay_commands)
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
# call it after the fork
try:
set_locale()
except Exception:
log_error('Failed to set locale, ignoring')
with suppress(AttributeError): # python compiled without threading
sys.setswitchinterval(1000.0) # we have only a single python thread
if cli_opts.watcher:
from .window import global_watchers
global_watchers.set_extra(cli_opts.watcher)
log_error('The --watcher command line option has been deprecated in favor of using the watcher option in kitty.conf')
# mask the signals now as on some platforms the display backend starts
# threads. These threads must not handle the masked signals, to ensure
# kitty can handle them. See https://github.com/kovidgoyal/kitty/issues/4636
mask_kitty_signals_process_wide()
init_glfw(opts, cli_opts.debug_keyboard, cli_opts.debug_rendering)
try:
with setup_profiling():
# Avoid needing to launch threads to reap zombies
run_app(opts, cli_opts, bad_lines, talk_fd)
finally:
glfw_terminate()
cleanup_ssh_control_masters()
def main(called_from_panel: bool = False) -> None:
global redirected_for_quick_access
try:
if is_macos and launched_by_launch_services and not called_from_panel:
with suppress(OSError):
os.chdir(os.path.expanduser('~'))
if is_quick_access_terminal_app:
# we were started by launch services, use the kitten to read
# the config and re-run
os.execl(kitten_exe(), kitten_exe(), 'quick-access-terminal')
set_use_os_log(True)
kitty_main(called_from_panel)
except Exception:
import traceback
tb = traceback.format_exc()
log_error(tb)
raise SystemExit(1)
finally:
# we cant rely on this running during module unloading of fast_data_types as Python fails
# to unload the module, due to reference cycles, I am guessing.
run_at_exit_cleanup_functions()