mirror of
https://github.com/prdlk/dotfiles.git
synced 2026-11-02 19:32:43 +00:00
chore(config): adjust workspace bindings and add persistent workspaces for dual monitors
This commit is contained in:
1 parent
cc29237c0c
commit
c0f611e1bd
5 files changed
+271
-25
No files matched your search
@@ -143,10 +143,11 @@ o.bind("SUPER + A", "Toggle scratch workspace", "omarchy-toggle-scratch scratchp
|
|||||||
o.bind("SUPER + CTRL + ALT + H", "Toggle Tern scratch workspace", "omarchy-toggle-scratch scratchpad")
|
o.bind("SUPER + CTRL + ALT + H", "Toggle Tern scratch workspace", "omarchy-toggle-scratch scratchpad")
|
||||||
o.bind("SUPER + CTRL + ALT + L", "Toggle Claude scratch workspace", "omarchy-toggle-scratch claude")
|
o.bind("SUPER + CTRL + ALT + L", "Toggle Claude scratch workspace", "omarchy-toggle-scratch claude")
|
||||||
|
|
||||||
-- Workspaces 1 to 5 on the home row of the left half: ALT to focus,
|
-- Workspaces 1 to 4 (Studio Display) on Q W E R and 5 to 8 (laptop panel) on
|
||||||
-- ALT + SHIFT to send the window there. No Omarchy default uses ALT with a
|
-- Y U I O: ALT to focus, ALT + SHIFT to send the window there. The pinning
|
||||||
-- letter, so nothing is unbound.
|
-- lives in hypr/hyprland.lua, so focusing a workspace also moves to its
|
||||||
for index, key in ipairs({ "Q", "W", "E", "R", "T" }) do
|
-- monitor. No Omarchy default uses ALT with a letter, so nothing is unbound.
|
||||||
|
for index, key in ipairs({ "Q", "W", "E", "R", "Y", "U", "I", "O" }) do
|
||||||
local workspace = tostring(index)
|
local workspace = tostring(index)
|
||||||
|
|
||||||
o.bind("ALT + " .. key, "Switch to workspace " .. workspace, hl.dsp.focus({ workspace = workspace }))
|
o.bind("ALT + " .. key, "Switch to workspace " .. workspace, hl.dsp.focus({ workspace = workspace }))
|
||||||
@@ -156,9 +157,6 @@ end
|
|||||||
hl.unbind("SUPER + O") -- was: pop window out
|
hl.unbind("SUPER + O") -- was: pop window out
|
||||||
hl.unbind("SUPER + SHIFT + O") -- was: Obsidian (moved to SUPER + CTRL + ALT + O)
|
hl.unbind("SUPER + SHIFT + O") -- was: Obsidian (moved to SUPER + CTRL + ALT + O)
|
||||||
|
|
||||||
o.bind("ALT + O", "Focus next monitor", hl.dsp.focus({ monitor = "+1" }))
|
|
||||||
o.bind("ALT + SHIFT + O", "Move window to next monitor", hl.dsp.window.move({ monitor = "+1" }))
|
|
||||||
|
|
||||||
o.bind("ALT + SHIFT + J", "Move window to next monitor", hl.dsp.window.move({ monitor = "+1" }))
|
o.bind("ALT + SHIFT + J", "Move window to next monitor", hl.dsp.window.move({ monitor = "+1" }))
|
||||||
o.bind("ALT + SHIFT + K", "Move window to previous monitor", hl.dsp.window.move({ monitor = "-1" }))
|
o.bind("ALT + SHIFT + K", "Move window to previous monitor", hl.dsp.window.move({ monitor = "-1" }))
|
||||||
|
|
||||||
|
|||||||
@@ -28,11 +28,19 @@ require("default.hypr.toggles")
|
|||||||
-- Add any other personal Hyprland configuration below.
|
-- Add any other personal Hyprland configuration below.
|
||||||
-- o.window("qemu", { workspace = "5" })
|
-- o.window("qemu", { workspace = "5" })
|
||||||
|
|
||||||
-- Pin workspaces to monitors. Matched on EDID description for the external
|
-- Pin workspaces to monitors: 1-4 on the Studio Display, 5-8 on the laptop
|
||||||
-- display so it survives landing on a different DP connector after re-docking.
|
-- panel; 1 and 5 are what each shows on connect. Persistent so empty ones
|
||||||
hl.workspace_rule({ workspace = "3", monitor = "desc:Apple Computer Inc StudioDisplay 0x984262A7" })
|
-- stay on their monitor (the bar's workspace dots list them per monitor).
|
||||||
hl.workspace_rule({ workspace = "4", monitor = "eDP-1" })
|
-- Matched on EDID description for the external display so it survives
|
||||||
hl.workspace_rule({ workspace = "5", monitor = "eDP-1" })
|
-- landing on a different DP connector after re-docking.
|
||||||
|
for id = 1, 8 do
|
||||||
|
hl.workspace_rule({
|
||||||
|
workspace = tostring(id),
|
||||||
|
monitor = id <= 4 and "desc:Apple Computer Inc StudioDisplay 0x984262A7" or "eDP-1",
|
||||||
|
default = id == 1 or id == 5,
|
||||||
|
persistent = true,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
-- Always open these apps on a fixed workspace and follow them there. Classes
|
-- Always open these apps on a fixed workspace and follow them there. Classes
|
||||||
-- come from `hyprctl clients` -- the desktop files' StartupWMClass hints
|
-- come from `hyprctl clients` -- the desktop files' StartupWMClass hints
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
"""Daily kitty sessions, archived per week.
|
||||||
|
|
||||||
|
~/.local/share/kitty-sessions/<monday YYYY-MM-DD>/<day YYYY-MM-DD>.kitty-session
|
||||||
|
~/.local/share/kitty-sessions/<monday YYYY-MM-DD>/<day YYYY-MM-DD>.scrollback/<window key>.ansi
|
||||||
|
|
||||||
|
One file, three entry points (kitty runs it under a different __name__ each):
|
||||||
|
- geninclude (__main__): creates today's session file and points
|
||||||
|
startup_session at it whenever no running kitty owns today's session (an
|
||||||
|
flock held for the owner's lifetime). Every launch after kitty was closed
|
||||||
|
reopens the day; a second concurrent instance starts plain instead of
|
||||||
|
duplicating today's tabs.
|
||||||
|
- watcher (__kitty_watcher__): SAVE_DELAY seconds after a window opens,
|
||||||
|
closes, moves, gains focus or finishes a command (and every SAVE_EVERY
|
||||||
|
seconds, for output from long-running programs), rewrites the session this
|
||||||
|
instance started with: tabs/splits/cwds, each shell window's scrollback, and
|
||||||
|
the program running in it. On restore the shell `cat`s the scrollback, then
|
||||||
|
re-runs the program. Days opened later via the picker are left as read-only
|
||||||
|
snapshots.
|
||||||
|
- kitten (map ... kitten day_session.py): picker over every archived day,
|
||||||
|
newest first; the choice is opened with goto_session.
|
||||||
|
"""
|
||||||
|
import fcntl
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import tempfile
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from kittens.tui.handler import result_handler
|
||||||
|
|
||||||
|
ROOT = os.path.expanduser('~/.local/share/kitty-sessions')
|
||||||
|
SAVE_DELAY = 1 # seconds; batches bursts of events, and the most change lost when kitty dies
|
||||||
|
SAVE_EVERY = 30 # seconds; catches output that arrives without any window event
|
||||||
|
SB_VAR = 'day_scrollback' # user var naming a window's scrollback dump; kitty saves/restores it via launch --var
|
||||||
|
UNSERIALIZE = 'kitty-unserialize-data=' # kitty.constants.unserialize_launch_flag
|
||||||
|
# Never replay a command that was mid-run when the session was saved if any word
|
||||||
|
# of it is one of these: re-running them on the next launch can destroy data.
|
||||||
|
NO_REPLAY = {
|
||||||
|
'rm', 'rmdir', 'mv', 'dd', 'shred', 'truncate', 'wipefs', 'fdisk', 'parted',
|
||||||
|
'sudo', 'doas', 'su', 'run0', 'pkexec', 'kill', 'pkill', 'killall',
|
||||||
|
'reboot', 'shutdown', 'poweroff',
|
||||||
|
}
|
||||||
|
SGR = re.compile(r'\x1b\[[0-9;:]*m')
|
||||||
|
PROMPT_MARK = '\x1b]133;A' # shell integration's start-of-prompt mark
|
||||||
|
_schedule_save = None # set by on_load when this instance owns a day session
|
||||||
|
|
||||||
|
|
||||||
|
def today_path() -> str:
|
||||||
|
d = date.today()
|
||||||
|
return os.path.join(ROOT, str(d - timedelta(days=d.weekday())), f'{d}.kitty-session')
|
||||||
|
|
||||||
|
|
||||||
|
def own_day(path: str) -> bool:
|
||||||
|
# Lock a side file, not the session itself: atomic_save replaces that inode.
|
||||||
|
# The fd stays open (and non-inheritable) so the lock dies with this kitty.
|
||||||
|
lock = os.path.join(os.environ.get('XDG_RUNTIME_DIR') or tempfile.gettempdir(),
|
||||||
|
f'kitty-day-{os.path.basename(path)}.lock')
|
||||||
|
fd = os.open(lock, os.O_RDONLY | os.O_CREAT, 0o600)
|
||||||
|
try:
|
||||||
|
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except BlockingIOError:
|
||||||
|
os.close(fd)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def replay_safe(cmd: str) -> bool:
|
||||||
|
try:
|
||||||
|
words = shlex.split(cmd)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return not any((b := os.path.basename(w)) in NO_REPLAY or b.startswith('mkfs') for w in words)
|
||||||
|
|
||||||
|
|
||||||
|
def scrollback(window) -> str:
|
||||||
|
from kitty.window import as_text
|
||||||
|
|
||||||
|
s = window.screen
|
||||||
|
# While a full-screen program (nvim) is up, the shell's output lives in the other buffer.
|
||||||
|
text = as_text(s, as_ansi=True, add_history=True, alternate_screen=s.is_using_alternate_linebuf())
|
||||||
|
if window.at_prompt and (i := text.rfind(PROMPT_MARK)) >= 0:
|
||||||
|
text = text[:i] # the idle prompt; the restored shell draws a fresh one
|
||||||
|
lines = text.split('\n')
|
||||||
|
while lines and not SGR.sub('', lines[-1]).strip(): # the unused rows below the cursor
|
||||||
|
lines.pop()
|
||||||
|
return '\n'.join(lines) + '\x1b[m\n' if lines else ''
|
||||||
|
|
||||||
|
|
||||||
|
def on_load(boss, data) -> None:
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from kitty.config import atomic_save
|
||||||
|
from kitty.fast_data_types import add_timer, get_options
|
||||||
|
from kitty.session import parse_save_as_options_spec_args, session_arg_to_name
|
||||||
|
|
||||||
|
global _schedule_save
|
||||||
|
path = get_options().startup_session or ''
|
||||||
|
if not path.startswith(ROOT + os.sep):
|
||||||
|
return
|
||||||
|
name = session_arg_to_name(path)
|
||||||
|
sb_dir = path.rpartition('.')[0] + '.scrollback'
|
||||||
|
ser_opts = parse_save_as_options_spec_args(
|
||||||
|
['--use-foreground-process', f'--match=session:^{re.escape(name)}$'])[0]
|
||||||
|
last = ''
|
||||||
|
dumps: dict[str, str] = {}
|
||||||
|
pending = False
|
||||||
|
|
||||||
|
def launch_parts(line: str) -> tuple[list[str], dict] | None:
|
||||||
|
args = shlex.split(line) if line.startswith('launch ') else []
|
||||||
|
if len(args) < 2 or not args[1].startswith(UNSERIALIZE):
|
||||||
|
return None
|
||||||
|
return args, json.loads(args[1][len(UNSERIALIZE):])
|
||||||
|
|
||||||
|
# Per-window shell startup command (scrollback cat + program), by SB_VAR key.
|
||||||
|
# Seeded from the file so restored windows keep theirs until their shell is up.
|
||||||
|
startup: dict[str, str] = {}
|
||||||
|
with open(path) as f:
|
||||||
|
for args, data in filter(None, map(launch_parts, f.read().splitlines())):
|
||||||
|
for a in args:
|
||||||
|
if a.startswith(f'--var={SB_VAR}=') and data.get('cmd_at_shell_startup'):
|
||||||
|
startup[a.removeprefix(f'--var={SB_VAR}=')] = data['cmd_at_shell_startup']
|
||||||
|
|
||||||
|
def startup_cmd(line: str) -> str:
|
||||||
|
# Rewrite kitty's per-window restore data: cat the scrollback first, then
|
||||||
|
# re-run the foreground program unless it is on the NO_REPLAY list.
|
||||||
|
parts = launch_parts(line)
|
||||||
|
if parts is None:
|
||||||
|
return line
|
||||||
|
args, data = parts
|
||||||
|
w = boss.window_id_map.get(data['id'])
|
||||||
|
key = w.user_vars.get(SB_VAR, '') if w else ''
|
||||||
|
fg = data.pop('cmd_at_shell_startup', None)
|
||||||
|
# Until the shell draws a prompt, kitty's "foreground process" is shell
|
||||||
|
# init (mise/direnv hooks) or the replayed program: keep the previous value.
|
||||||
|
if w and w.screen.last_reported_cwd:
|
||||||
|
steps = []
|
||||||
|
dump = os.path.join(sb_dir, f'{key}.ansi')
|
||||||
|
if os.path.exists(dump):
|
||||||
|
steps.append(f'cat {shlex.quote(dump)}')
|
||||||
|
if fg:
|
||||||
|
fg = fg if isinstance(fg, str) else shlex.join(fg)
|
||||||
|
if replay_safe(fg):
|
||||||
|
steps.append(fg)
|
||||||
|
startup[key] = '; '.join(steps)
|
||||||
|
if startup.get(key):
|
||||||
|
data['cmd_at_shell_startup'] = startup[key]
|
||||||
|
args[1] = UNSERIALIZE + json.dumps(data)
|
||||||
|
return shlex.join(args)
|
||||||
|
|
||||||
|
def save(timer_id: int) -> None:
|
||||||
|
nonlocal last, pending
|
||||||
|
pending = False
|
||||||
|
windows = [w for w in boss.all_windows if w.created_in_session_name == name]
|
||||||
|
# Nothing left in the session means kitty is closing: keep the last good save.
|
||||||
|
if not windows:
|
||||||
|
return
|
||||||
|
os.makedirs(sb_dir, exist_ok=True)
|
||||||
|
keep = set()
|
||||||
|
for w in windows:
|
||||||
|
key = w.user_vars.get(SB_VAR)
|
||||||
|
if not key:
|
||||||
|
key = uuid4().hex
|
||||||
|
w.set_user_var(SB_VAR, key)
|
||||||
|
keep.add(f'{key}.ansi')
|
||||||
|
# Only once the shell has drawn a prompt (and reported its cwd): a
|
||||||
|
# restored window cats its old dump before that, so writing earlier
|
||||||
|
# could replace the history before it was replayed.
|
||||||
|
if w.screen.last_reported_cwd:
|
||||||
|
text = scrollback(w)
|
||||||
|
if dumps.get(key) != text:
|
||||||
|
atomic_save(text.encode(), os.path.join(sb_dir, f'{key}.ansi'))
|
||||||
|
dumps[key] = text
|
||||||
|
for f in set(os.listdir(sb_dir)) - keep: # windows closed since the last save
|
||||||
|
os.remove(os.path.join(sb_dir, f))
|
||||||
|
text = '\n'.join(map(startup_cmd, boss.serialize_state_as_session(path, ser_opts)))
|
||||||
|
if text != last:
|
||||||
|
atomic_save(text.encode(), path)
|
||||||
|
last = text
|
||||||
|
|
||||||
|
def schedule() -> None:
|
||||||
|
nonlocal pending
|
||||||
|
if not pending:
|
||||||
|
pending = True
|
||||||
|
add_timer(save, SAVE_DELAY, False)
|
||||||
|
|
||||||
|
_schedule_save = schedule
|
||||||
|
add_timer(lambda timer_id: schedule(), SAVE_EVERY, True)
|
||||||
|
|
||||||
|
|
||||||
|
def _changed(boss, window, data) -> None:
|
||||||
|
if _schedule_save is not None:
|
||||||
|
_schedule_save()
|
||||||
|
|
||||||
|
|
||||||
|
# on_resize covers windows/tabs being created, closed or re-split; on_cmd_startstop
|
||||||
|
# catches `cd` and finished commands. Title changes are skipped: spinners would
|
||||||
|
# re-save every second.
|
||||||
|
on_resize = on_close = on_focus_change = on_cmd_startstop = _changed
|
||||||
|
|
||||||
|
|
||||||
|
def main(args: list[str]) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@result_handler(no_ui=True)
|
||||||
|
def handle_result(args: list[str], answer: str, target_window_id: int, boss) -> None:
|
||||||
|
from kitty.session import goto_session
|
||||||
|
|
||||||
|
days = sorted(glob.glob(os.path.join(ROOT, '*', '*.kitty-session')), key=os.path.basename, reverse=True)
|
||||||
|
if not days:
|
||||||
|
boss.show_error('No day sessions', f'Nothing archived under {ROOT} yet.')
|
||||||
|
return
|
||||||
|
|
||||||
|
def label(path: str) -> str:
|
||||||
|
d = date.fromisoformat(os.path.basename(path).partition('.')[0])
|
||||||
|
return f'{d} {d:%a} week of {os.path.basename(os.path.dirname(path))}'
|
||||||
|
|
||||||
|
boss.choose_entry('Open a day', ((p, label(p)) for p in days), lambda p: p and goto_session(boss, (p,)))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
path = today_path()
|
||||||
|
if own_day(path):
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
try:
|
||||||
|
open(path, 'x').close() # empty session = one tab with the default shell
|
||||||
|
except FileExistsError:
|
||||||
|
pass
|
||||||
|
print(f'startup_session {path}')
|
||||||
@@ -34,8 +34,8 @@ action_alias launch_mn launch --cwd=current --type=overlay-main
|
|||||||
action_alias launch_os launch --cwd=current --type=os-window
|
action_alias launch_os launch --cwd=current --type=os-window
|
||||||
action_alias launch_ov launch --cwd=current --type=overlay
|
action_alias launch_ov launch --cwd=current --type=overlay
|
||||||
action_alias launch_tb launch --cwd=current --type=tab
|
action_alias launch_tb launch --cwd=current --type=tab
|
||||||
action_alias launch_hs launch --cwd=current --location=hsplit --bias=30
|
action_alias launch_hs launch --cwd=current --location=hsplit --bias=25
|
||||||
action_alias launch_vs launch --cwd=current --location=vsplit --bias=40
|
action_alias launch_vs launch --cwd=current --location=vsplit --bias=30
|
||||||
action_alias launch_vs50 launch --cwd=current --location=vsplit --bias=50
|
action_alias launch_vs50 launch --cwd=current --location=vsplit --bias=50
|
||||||
|
|
||||||
# panes = kitty windows
|
# panes = kitty windows
|
||||||
@@ -78,6 +78,18 @@ map alt+u goto_tab 7
|
|||||||
map alt+i goto_tab 8
|
map alt+i goto_tab 8
|
||||||
map alt+o goto_tab 9
|
map alt+o goto_tab 9
|
||||||
|
|
||||||
|
# Daily sessions, archived per week (day_session.py):
|
||||||
|
# ~/.local/share/kitty-sessions/<monday>/<day>.kitty-session. Launching kitty
|
||||||
|
# reopens today's session unless another running kitty already owns it: tabs,
|
||||||
|
# splits, cwds, each shell's scrollback and the program running in it. Tabs
|
||||||
|
# made with *_with_cwd / --cwd=current join it and are saved ~1s after each
|
||||||
|
# change. ctrl+shift+d reopens any earlier day.
|
||||||
|
geninclude day_session.py
|
||||||
|
watcher day_session.py
|
||||||
|
map ctrl+shift+d kitten day_session.py
|
||||||
|
# show only the active day's tabs (plus tabs outside any session)
|
||||||
|
tab_bar_filter session:~ or session:^$
|
||||||
|
|
||||||
# split nav (vim-aware)
|
# split nav (vim-aware)
|
||||||
map ctrl+h kitten pass_keys.py neighboring_window left ctrl+h
|
map ctrl+h kitten pass_keys.py neighboring_window left ctrl+h
|
||||||
map ctrl+j kitten pass_keys.py neighboring_window bottom ctrl+j
|
map ctrl+j kitten pass_keys.py neighboring_window bottom ctrl+j
|
||||||
@@ -90,8 +102,8 @@ map super+up resize_window taller
|
|||||||
map super+down resize_window shorter 3
|
map super+down resize_window shorter 3
|
||||||
|
|
||||||
tab_bar_style powerline
|
tab_bar_style powerline
|
||||||
tab_powerline_style angled
|
tab_powerline_style slanted
|
||||||
tab_bar_align left
|
tab_bar_align center
|
||||||
tab_bar_edge top
|
tab_bar_edge top
|
||||||
|
|
||||||
# Titles come from ~/.config/kitty/tab_bar.py (`{custom}`): the repo the tab's
|
# Titles come from ~/.config/kitty/tab_bar.py (`{custom}`): the repo the tab's
|
||||||
|
|||||||
@@ -59,15 +59,12 @@
|
|||||||
"on-click": "activate",
|
"on-click": "activate",
|
||||||
"cursor": true,
|
"cursor": true,
|
||||||
"format": "{icon}",
|
"format": "{icon}",
|
||||||
"persistent-workspaces": {
|
// Matches the workspace rules in hypr/hyprland.lua: 1-4 on the Studio
|
||||||
"1": [],
|
// Display, 5-8 on the laptop panel. Waybar keys outputs by connector, so
|
||||||
"2": [],
|
// update DP-4 if the Studio Display re-docks on another port.
|
||||||
"3": [],
|
"persistent-workspaces": {
|
||||||
"4": [],
|
"DP-4": [1, 2, 3, 4],
|
||||||
"5": [],
|
"eDP-1": [5, 6, 7, 8]
|
||||||
"7": [],
|
|
||||||
"8": [],
|
|
||||||
"9": []
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"custom/separator": {
|
"custom/separator": {
|
||||||
|
|||||||
Reference in new issue
Block a user