diff --git a/kitty/layout/base.py b/kitty/layout/base.py index cf97b6b70..0fd50d066 100644 --- a/kitty/layout/base.py +++ b/kitty/layout/base.py @@ -575,11 +575,17 @@ class Layout: s: dict[str, Any], all_windows: WindowList, window_id_mapper: Callable[[WindowList], dict[int, int]] = create_window_id_map_for_unserialize, + apply_to_window_list: bool = True, ) -> bool: + """ + apply_to_window_list=False computes the group id mapping without changing the + window list. Needed when restoring the state of more than one layout, since + only the layout being made current should order the windows. + """ if s.get('class') != self.__class__.__name__: return False window_id_map = create_window_id_map_for_unserialize(all_windows) - m = all_windows.unserialize_layout_state(s['all_windows'], window_id_map) + m = all_windows.unserialize_layout_state(s['all_windows'], window_id_map, apply=apply_to_window_list) if m is None: return False return self.set_layout_state(s, m.get) diff --git a/kitty/layout/splits.py b/kitty/layout/splits.py index d2ce166d8..66683f976 100644 --- a/kitty/layout/splits.py +++ b/kitty/layout/splits.py @@ -964,7 +964,10 @@ class Splits(Layout): new_root = Pair() new_root.unserialize(layout_state['pairs'], map_group_id) before = frozenset(self.pairs_root.all_window_ids()) - if before == frozenset(new_root.all_window_ids()): + # An empty tree means this layout has not laid out any windows yet, which is + # the case when restoring the state of a layout that was not the active one. + # do_layout() places any groups the restored tree is missing. + if not before or before == frozenset(new_root.all_window_ids()): self.pairs_root = new_root self.layout_opts = SplitsLayoutOpts(layout_state['opts']) if 'maximized' in layout_state: diff --git a/kitty/tabs.py b/kitty/tabs.py index d3e484954..da940f255 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -54,7 +54,7 @@ from .fast_data_types import ( sync_os_window_title, ) from .layout.base import DragOverlayMode, Layout -from .layout.interface import create_layout_object_for, evict_cached_layouts +from .layout.interface import all_layouts, create_layout_object_for, evict_cached_layouts from .progress import ProgressState from .tab_bar import TabBar, TabBarData, apply_title_template from .types import ac @@ -216,6 +216,9 @@ class Tab: # {{{ self.windows: WindowList = WindowList(self) self._last_used_layout: str | None = None self._current_layout_name: str | None = None + # Layouts this tab has used, by name. Their state is preserved when + # switching away, so it can be saved to a session as well. + self._used_layouts: dict[str, Layout] = {} self.cwd = self.args.directory if no_initial_window: self._set_current_layout(self.enabled_layouts[0]) @@ -302,6 +305,7 @@ class Tab: # {{{ self._last_used_layout = self._current_layout_name self.current_layout = self.create_layout_object(layout_name) self._current_layout_name = layout_name + self._used_layouts[layout_name] = self.current_layout self.mark_tab_bar_dirty() def startup(self, session_tab: SessionTab) -> None: @@ -370,7 +374,22 @@ class Tab: # {{{ if active_window_id and not did_focus_matching_spec: self.windows.set_active_window_group_for(active_window_id) if session_tab.layout_state: - self.current_layout.unserialize(session_tab.layout_state, self.windows) + self.unserialize_layout_state_from_session(session_tab.layout_state) + + def unserialize_layout_state_from_session(self, layout_state: dict[str, Any]) -> None: + self.current_layout.unserialize(layout_state, self.windows) + # Restore the state of layouts that were not active when the session was + # saved, so that switching to them gives back their saved arrangement + # rather than a freshly built one. + for name, state in (layout_state.get('other_layouts') or {}).items(): + if name.partition(':')[0] not in all_layouts: + continue + layout = self.create_layout_object(name) + if layout.unserialize(state, self.windows, apply_to_window_list=False): + self._used_layouts[name] = layout + last_used = layout_state.get('last_used_layout') + if last_used and last_used != self._current_layout_name: + self._last_used_layout = last_used def serialize_state(self) -> dict[str, Any]: return { @@ -426,12 +445,27 @@ class Tab: # {{{ f'new_tab {self.name}'.rstrip(), f'layout {layout}', f'enabled_layouts {",".join(enabled_layouts)}', - f'set_layout_state {json.dumps(self.current_layout.serialize(self.windows))}', + f'set_layout_state {json.dumps(self.serialize_layout_state_for_session())}', f'cd {most_common_cwd}', '', ] + launch_cmds return [] + def serialize_layout_state_for_session(self) -> dict[str, Any]: + """ + The state of the current layout, with the state of any other layouts this tab + has used nested under other_layouts. Nesting rather than adding new session + commands keeps the file readable by older versions of kitty, which ignore + unknown keys in this dict but abort on unknown commands. + """ + ans = self.current_layout.serialize(self.windows) + others = {name: layout.serialize(self.windows) for name, layout in self._used_layouts.items() if name != self._current_layout_name} + if others: + ans['other_layouts'] = others + if self._last_used_layout: + ans['last_used_layout'] = self._last_used_layout + return ans + def data_for_tab_bar(self, is_active: bool) -> TabBarData: t = self title = t.name or t.title or appname diff --git a/kitty/window_list.py b/kitty/window_list.py index 9ddda437b..24005910a 100644 --- a/kitty/window_list.py +++ b/kitty/window_list.py @@ -203,7 +203,12 @@ class WindowList: 'window_groups': [g.serialize_layout_state() for g in self.groups], } - def unserialize_layout_state(self, state: dict[str, Any], window_id_map: dict[int, int]) -> dict[int, int] | None: + def unserialize_layout_state(self, state: dict[str, Any], window_id_map: dict[int, int], apply: bool = True) -> dict[int, int] | None: + """ + Returns a map of serialized group id to current group id. With apply=False the + map is computed but the window list is left unchanged, so that the state of + several layouts can be restored while only one of them orders the windows. + """ if set(window_id_map.values()) != set(self.id_map): # some window in this collection does not correspond to a # serialized window @@ -233,6 +238,8 @@ class WindowList: # we ignore them. if len(ans) != len(self.groups): return None + if not apply: + return ans gmap = {g.id: g for g in self.groups} groups = [] for wg in state['window_groups']: diff --git a/kitty_tests/layout.py b/kitty_tests/layout.py index 443f4161e..b28cb606e 100644 --- a/kitty_tests/layout.py +++ b/kitty_tests/layout.py @@ -16,6 +16,7 @@ from .base import BaseTest class Window: def __init__(self, win_id, overlay_for=None, overlay_window_id=None): self.id = win_id + self.serialized_id = 0 self.overlay_for = overlay_for self.overlay_window_id = overlay_window_id self.is_visible_in_layout = True @@ -273,6 +274,28 @@ class TestLayout(BaseTest): self.ae(q.neighbors_for_window(windows[2], all_windows), {'left': [1], 'right': [4], 'top': [2]}) self.ae(q.neighbors_for_window(windows[3], all_windows), {'left': [3], 'top': [2]}) + def test_splits_unserialize_into_unused_layout(self): + # Restoring the state of a layout that was not the active one when the session + # was saved: its pairs tree is empty, and the window list must not be + # reordered, since only the layout being made current does that. + q = create_layout(Splits) + all_windows = create_windows(q, num=0) + for wid, loc in ((1, None), (2, 'hsplit'), (3, 'vsplit')): + win = Window(wid) + win.serialized_id = wid + q.add_window(all_windows, win, location=loc) + q(all_windows) + expected = {'horizontal': False, 'one': 1, 'two': {'one': 2, 'two': 3}} + self.ae(q.pairs_root.serialize(), expected) + state = q.serialize(all_windows) + groups_before = [g.id for g in all_windows.groups] + + fresh = create_layout(Splits) + self.ae(fresh.pairs_root.serialize(), {}) + self.assertTrue(fresh.unserialize(state, all_windows, apply_to_window_list=False)) + self.ae(fresh.pairs_root.serialize(), expected) + self.ae([g.id for g in all_windows.groups], groups_before) + def test_splits_maximize(self): q = create_layout(Splits) all_windows = create_windows(q, num=0)