mirror of
https://github.com/kovidgoyal/kitty.git
synced 2026-10-20 12:19:36 +00:00
Merge branch 'splits-proportional' of https://github.com/xiaobai050/kitty
# Conflicts: # kitty_tests/layout.py
This commit is contained in:
commit
791614ce93
4 changed files with 223 additions and 5 deletions
|
|
@ -202,6 +202,10 @@ Detailed list of changes
|
|||
0.49.0 [future]
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
- Splits layout: Add an optional proportional sizing policy that preserves
|
||||
adjusted window weights when adding, closing or repositioning windows.
|
||||
See :doc:`layouts` for configuration.
|
||||
|
||||
- Support for :doc:`/custom-shaders` for adding various graphical effects (:iss:`10344`)
|
||||
|
||||
- Various throughput performance improvements for a 15-35% real world improvement depending on workload
|
||||
|
|
|
|||
|
|
@ -207,12 +207,26 @@ width and ``maximize vertical`` to fill the full height. Calling it again
|
|||
restores the original split sizes. The ``equalize`` action redistributes space
|
||||
so that all windows along each split axis receive an equal share.
|
||||
|
||||
This layout takes two options. ``equalize_on_window_close`` automatically equalizes
|
||||
This layout takes three options. ``equalize_on_window_close`` automatically equalizes
|
||||
split sizes whenever a window is closed, keeping remaining windows balanced
|
||||
without needing an explicit keybinding::
|
||||
|
||||
enabled_layouts splits:equalize_on_window_close=true
|
||||
|
||||
``proportional`` preserves the relative sizes of siblings along the same split
|
||||
axis when adding, removing or repositioning windows. A new window receives the
|
||||
same weight as the window being split, and the siblings are scaled to fit.
|
||||
For example, splitting either of two equally sized side-by-side windows makes
|
||||
three equally sized windows. Manually adjusted proportions are preserved when
|
||||
windows are closed. Splits along the other axis keep their relative sizes::
|
||||
|
||||
enabled_layouts splits:proportional=true
|
||||
|
||||
This option is disabled by default. An explicit :option:`launch --bias` overrides
|
||||
proportional sizing for that new window. If ``equalize_on_window_close`` is also
|
||||
enabled, closing a window equalizes the layout instead of preserving its weights.
|
||||
The option is saved as part of a :doc:`session <sessions>`.
|
||||
|
||||
``split_axis`` controls whether new windows
|
||||
are placed into vertical or horizontal splits when a :option:`--location
|
||||
<launch --location>` is not specified. A value of ``horizontal`` (same as
|
||||
|
|
|
|||
|
|
@ -127,6 +127,51 @@ class Pair:
|
|||
return q
|
||||
return None
|
||||
|
||||
def window_weights(self) -> dict[int, float]:
|
||||
weights: dict[int, float] = {}
|
||||
|
||||
def walk(child: Pair | int | None, weight: float) -> None:
|
||||
if isinstance(child, Pair):
|
||||
if child.is_redundant:
|
||||
walk(child.one or child.two, weight)
|
||||
else:
|
||||
walk(child.one, weight * child.bias)
|
||||
walk(child.two, weight * (1 - child.bias))
|
||||
elif child is not None:
|
||||
weights[child] = weight
|
||||
|
||||
walk(self, 1.0)
|
||||
return weights
|
||||
|
||||
def set_window_weights(self, weights: dict[int, float]) -> None:
|
||||
def tune(child: Pair | int | None) -> float:
|
||||
if isinstance(child, Pair):
|
||||
one, two = tune(child.one), tune(child.two)
|
||||
if one > 0 and two > 0:
|
||||
child.bias = one / (one + two)
|
||||
return one + two
|
||||
return weights.get(child, 0.0) if child is not None else 0.0
|
||||
|
||||
tune(self)
|
||||
|
||||
def preserve_weights_on_removal(self, removed: Collection[int]) -> bool:
|
||||
# Each perpendicular subtree is one unit along this split axis. If
|
||||
# some of its windows survive, retain its full share on this axis.
|
||||
def tune(child: Pair | int | None, weight: float) -> float:
|
||||
if isinstance(child, Pair):
|
||||
if child.horizontal != self.horizontal:
|
||||
return weight if child.preserve_weights_on_removal(removed) else 0.0
|
||||
if child.is_redundant:
|
||||
return tune(child.one or child.two, weight)
|
||||
one = tune(child.one, weight * child.bias)
|
||||
two = tune(child.two, weight * (1 - child.bias))
|
||||
if one > 0 and two > 0:
|
||||
child.bias = one / (one + two)
|
||||
return one + two
|
||||
return weight if child is not None and child not in removed else 0.0
|
||||
|
||||
return tune(self, 1.0) > 0
|
||||
|
||||
def remove_windows(self, window_ids: Collection[int]) -> None:
|
||||
if isinstance(self.one, int) and self.one in window_ids:
|
||||
self.one = None
|
||||
|
|
@ -545,6 +590,7 @@ class Pair:
|
|||
class SplitsLayoutOpts(LayoutOpts):
|
||||
default_axis_is_horizontal: bool | None = True
|
||||
equalize_on_close: bool = False
|
||||
proportional: bool = False
|
||||
|
||||
def __init__(self, data: dict[str, str]):
|
||||
q = data.get('split_axis', 'horizontal')
|
||||
|
|
@ -553,11 +599,13 @@ class SplitsLayoutOpts(LayoutOpts):
|
|||
else:
|
||||
self.default_axis_is_horizontal = q == 'horizontal'
|
||||
self.equalize_on_close = to_bool(data.get('equalize_on_window_close', 'n'))
|
||||
self.proportional = to_bool(data.get('proportional', 'n'))
|
||||
|
||||
def serialized(self) -> dict[str, str]:
|
||||
return {
|
||||
'split_axis': 'auto' if self.default_axis_is_horizontal is None else ('horizontal' if self.default_axis_is_horizontal else 'vertical'),
|
||||
'equalize_on_window_close': 'y' if self.equalize_on_close else 'n',
|
||||
'proportional': 'y' if self.proportional else 'n',
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -588,6 +636,8 @@ class Splits(Layout):
|
|||
|
||||
def remove_windows(self, *windows_to_remove: int) -> None:
|
||||
root = self.pairs_root
|
||||
if self.layout_opts.proportional:
|
||||
root.preserve_weights_on_removal(frozenset(windows_to_remove))
|
||||
for pair in root.self_and_descendants():
|
||||
pair.remove_windows(windows_to_remove)
|
||||
root.collapse_redundant_pairs()
|
||||
|
|
@ -596,6 +646,25 @@ class Splits(Layout):
|
|||
if isinstance(q, Pair):
|
||||
self.pairs_root = q
|
||||
|
||||
def proportional_container(self, group_id: int, horizontal: bool) -> Pair | None:
|
||||
root = self.pairs_root
|
||||
pair = root.pair_for_window(group_id)
|
||||
if pair is None or pair.horizontal != horizontal:
|
||||
return None
|
||||
while (parent := pair.parent(root)) is not None and parent.horizontal == horizontal:
|
||||
pair = parent
|
||||
return pair
|
||||
|
||||
def split_and_add(self, pair: Pair, group_id: int, new_id: int, horizontal: bool, after: bool, bias: float | None = None) -> None:
|
||||
container = self.proportional_container(group_id, horizontal) if self.layout_opts.proportional and bias is None else None
|
||||
weights = container.window_weights() if container is not None else {}
|
||||
parent = pair.split_and_add(group_id, new_id, horizontal, after)
|
||||
if bias is not None:
|
||||
parent.bias = bias if parent.one == new_id else (1 - bias)
|
||||
elif container is not None and (weight := weights.get(group_id, 0.0)) > 0:
|
||||
weights[new_id] = weight
|
||||
container.set_window_weights(weights)
|
||||
|
||||
def do_layout(self, windows: WindowList) -> None:
|
||||
groups = tuple(windows.iter_all_layoutable_groups())
|
||||
root = self.pairs_root
|
||||
|
|
@ -642,9 +711,7 @@ class Splits(Layout):
|
|||
wheight = aw.geometry.bottom - aw.geometry.top
|
||||
horizontal = wwidth >= wheight
|
||||
target_group = all_windows.add_window(window, next_to=aw, before=not after)
|
||||
parent_pair = pair.split_and_add(group_id, target_group.id, horizontal, after)
|
||||
if bias is not None:
|
||||
parent_pair.bias = bias if parent_pair.one == target_group.id else (1 - bias)
|
||||
self.split_and_add(pair, group_id, target_group.id, horizontal, after, bias)
|
||||
return
|
||||
all_windows.add_window(window)
|
||||
g = all_windows.group_for_window(window)
|
||||
|
|
@ -747,7 +814,7 @@ class Splits(Layout):
|
|||
# Re-insert next to dest
|
||||
pair = self.pairs_root.pair_for_window(dest_wg.id)
|
||||
if pair is not None:
|
||||
pair.split_and_add(dest_wg.id, src_wg.id, horizontal, after)
|
||||
self.split_and_add(pair, dest_wg.id, src_wg.id, horizontal, after)
|
||||
else:
|
||||
self.pairs_root.balanced_add(src_wg.id)
|
||||
|
||||
|
|
|
|||
|
|
@ -969,3 +969,136 @@ class TestSplitBorderResize(BaseTest):
|
|||
data = layout.drag_resize_target_windows(ws[1], 0, 0, LEFT_EDGE | TOP_EDGE, windows)
|
||||
self.assertIsNone(data.horizontal_id)
|
||||
self.assertIsNone(data.vertical_id)
|
||||
|
||||
|
||||
class TestProportionalSplits(BaseTest):
|
||||
def make_layout(self, shape=None, num=3, **options):
|
||||
q = create_layout(Splits)
|
||||
q.layout_opts = SplitsLayoutOpts({'proportional': 'yes', **options})
|
||||
windows = create_windows(q, num=0)
|
||||
for i in range(1, num + 1 if shape else 2):
|
||||
q.add_window(windows, Window(i), location='vsplit')
|
||||
if shape:
|
||||
q.pairs_root.unserialize(shape, lambda x: x)
|
||||
return q, windows
|
||||
|
||||
def check_weights(self, q, expected):
|
||||
# Observe the serialized layout, independently of the sizing helpers.
|
||||
actual = {}
|
||||
|
||||
def walk(node, size):
|
||||
if isinstance(node, int):
|
||||
actual[node] = size
|
||||
elif 'two' not in node:
|
||||
walk(node['one'], size)
|
||||
else:
|
||||
bias = node.get('bias', 0.5)
|
||||
walk(node['one'], size * bias)
|
||||
walk(node['two'], size * (1 - bias))
|
||||
|
||||
walk(q.pairs_root.serialize(), 1.0)
|
||||
self.ae(actual.keys(), expected.keys())
|
||||
for wid, size in expected.items():
|
||||
self.assertAlmostEqual(actual[wid], size)
|
||||
|
||||
def test_proportional_add_equal_siblings(self):
|
||||
for location in ('vsplit', 'hsplit', None):
|
||||
for target in (1, 2):
|
||||
with self.subTest(location=location, target=target):
|
||||
q, windows = self.make_layout()
|
||||
q.add_window(windows, Window(2), location=location)
|
||||
windows.set_active_window_group_for(windows.id_map[target])
|
||||
q.add_window(windows, Window(3), location=location)
|
||||
self.check_weights(q, {1: 1 / 3, 2: 1 / 3, 3: 1 / 3})
|
||||
q.add_window(windows, Window(4), location=location)
|
||||
self.check_weights(q, dict.fromkeys((1, 2, 3, 4), 0.25))
|
||||
|
||||
def test_proportional_default_unchanged(self):
|
||||
q, windows = self.make_layout(proportional='no')
|
||||
q.add_window(windows, Window(2), location='vsplit')
|
||||
q.add_window(windows, Window(3), location='vsplit')
|
||||
self.check_weights(q, {1: 0.5, 2: 0.25, 3: 0.25})
|
||||
|
||||
def test_proportional_add_preserves_adjusted_weights(self):
|
||||
q, windows = self.make_layout({'bias': 0.2, 'one': 1, 'two': {'bias': 0.375, 'one': 2, 'two': 3}})
|
||||
q.add_window(windows, Window(4), location='vsplit', next_to=windows.id_map[2])
|
||||
self.check_weights(q, {1: 2 / 13, 2: 3 / 13, 3: 5 / 13, 4: 3 / 13})
|
||||
|
||||
def test_proportional_explicit_bias_wins(self):
|
||||
q, windows = self.make_layout()
|
||||
q.add_window(windows, Window(2), location='vsplit')
|
||||
q.add_window(windows, Window(3), location='vsplit', bias=80)
|
||||
self.check_weights(q, {1: 0.5, 2: 0.1, 3: 0.4})
|
||||
|
||||
def test_proportional_close_preserves_survivors(self):
|
||||
for removed in (1, 2, 3):
|
||||
q, windows = self.make_layout({'bias': 0.2, 'one': 1, 'two': {'bias': 0.375, 'one': 2, 'two': 3}})
|
||||
sizes = {1: 0.2, 2: 0.3, 3: 0.5}
|
||||
sizes.pop(removed)
|
||||
total = sum(sizes.values())
|
||||
windows.remove_window(windows.id_map[removed])
|
||||
q.remove_windows(removed)
|
||||
self.check_weights(q, {k: v / total for k, v in sizes.items()})
|
||||
|
||||
def test_proportional_close_multiple(self):
|
||||
q, windows = self.make_layout()
|
||||
for i in range(2, 6):
|
||||
q.add_window(windows, Window(i), location='vsplit')
|
||||
q.remove_windows(1, 3, 5)
|
||||
self.check_weights(q, {2: 0.5, 4: 0.5})
|
||||
|
||||
def test_proportional_batch_close_mixed_axes(self):
|
||||
for horizontal in (True, False):
|
||||
shape = {
|
||||
'horizontal': horizontal,
|
||||
'one': 1,
|
||||
'two': {'horizontal': horizontal, 'one': {'horizontal': not horizontal, 'one': 2, 'two': 3}, 'two': 4},
|
||||
}
|
||||
q, windows = self.make_layout(shape, num=4)
|
||||
q.remove_windows(1, 2)
|
||||
# The surviving center pane keeps its column's width, even though
|
||||
# the other pane in that column was removed at the same time.
|
||||
self.check_weights(q, {3: 0.5, 4: 0.5})
|
||||
|
||||
def test_proportional_mixed_axes(self):
|
||||
q, windows = self.make_layout({'bias': 0.4, 'one': 1, 'two': {'horizontal': False, 'bias': 0.25, 'one': 2, 'two': 3}})
|
||||
q.add_window(windows, Window(4), location='hsplit', next_to=windows.id_map[2])
|
||||
self.check_weights(q, {1: 0.4, 2: 0.12, 3: 0.36, 4: 0.12})
|
||||
q.remove_windows(2)
|
||||
self.check_weights(q, {1: 0.4, 3: 0.45, 4: 0.15})
|
||||
q.remove_windows(4)
|
||||
self.check_weights(q, {1: 0.4, 3: 0.6})
|
||||
|
||||
def test_proportional_perpendicular_subtree_unchanged(self):
|
||||
q, windows = self.make_layout({'bias': 0.4, 'one': 1, 'two': {'horizontal': False, 'bias': 0.25, 'one': 2, 'two': 3}})
|
||||
q.add_window(windows, Window(4), location='vsplit', next_to=windows.id_map[1])
|
||||
self.check_weights(q, {1: 2 / 7, 4: 2 / 7, 2: 3 / 28, 3: 9 / 28})
|
||||
|
||||
def test_proportional_reposition(self):
|
||||
q, windows = self.make_layout({'bias': 0.2, 'one': 1, 'two': {'bias': 0.375, 'one': 2, 'two': 3}})
|
||||
q.insert_window_next_to(windows, windows.id_map[3], windows.id_map[1], True, False)
|
||||
self.check_weights(q, {1: 2 / 7, 2: 3 / 7, 3: 2 / 7})
|
||||
self.ae(list(q.pairs_root.all_window_ids()), [3, 1, 2])
|
||||
before = q.layout_state()
|
||||
q.insert_window_next_to(windows, windows.id_map[1], windows.id_map[1], True, False)
|
||||
self.ae(q.layout_state(), before)
|
||||
|
||||
def test_proportional_overlay_does_not_change_weights(self):
|
||||
q, windows = self.make_layout()
|
||||
q.add_window(windows, Window(2), location='vsplit')
|
||||
before = q.layout_state()
|
||||
q.add_window(windows, Window(10), overlay_for=2)
|
||||
self.ae(q.layout_state(), before)
|
||||
q.add_window(windows, Window(3), location='vsplit')
|
||||
self.check_weights(q, {1: 1 / 3, 2: 1 / 3, 3: 1 / 3})
|
||||
self.assertIs(windows.group_for_window(windows.id_map[10]), windows.group_for_window(windows.id_map[2]))
|
||||
|
||||
def test_proportional_option_roundtrip_and_equalize(self):
|
||||
q, windows = self.make_layout({'bias': 0.2, 'one': 1, 'two': {'bias': 0.375, 'one': 2, 'two': 3}}, equalize_on_window_close='yes')
|
||||
options = SplitsLayoutOpts(q.layout_opts.serialized())
|
||||
self.assertTrue(options.proportional)
|
||||
self.assertTrue(options.equalize_on_close)
|
||||
windows.remove_window(windows.id_map[1])
|
||||
q.remove_windows(1)
|
||||
self.assertTrue(q.on_window_removed(windows))
|
||||
self.check_weights(q, {2: 0.5, 3: 0.5})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue