#!/usr/bin/env python # License: GPLv3 Copyright: 2020, Kovid Goyal from collections.abc import Generator, Iterable, Iterator, Sequence from functools import partial from itertools import repeat from typing import Any, Callable, NamedTuple from kitty.borders import BorderColor from kitty.constants import serialize_user_var_name from kitty.fast_data_types import Region, set_active_window, viewport_for_window from kitty.options.types import Options from kitty.types import Edges, WindowGeometry, WindowMapper from kitty.typing_compat import TypedDict, WindowType from kitty.window_list import WindowGroup, WindowList class BorderLine(NamedTuple): edges: Edges = Edges() color: BorderColor = BorderColor.inactive class LayoutOpts: def __init__(self, data: dict[str, str]): pass def serialized(self) -> dict[str, Any]: return {} class LayoutData(NamedTuple): content_pos: int = 0 cells_per_window: int = 0 space_before: int = 0 space_after: int = 0 content_size: int = 0 DecorationPairs = Sequence[tuple[int, int]] LayoutDimension = Generator[LayoutData, None, None] ListOfWindows = list[WindowType] class NeighborsMap(TypedDict): left: list[int] top: list[int] right: list[int] bottom: list[int] class LayoutGlobalData: draw_minimal_borders: bool = True draw_active_borders: bool = True alignment_x: int = 0 alignment_y: int = 0 central: Region = Region((0, 0, 199, 199, 200, 200)) cell_width: int = 20 cell_height: int = 20 lgd = LayoutGlobalData() def idx_for_id(win_id: int, windows: Iterable[WindowType]) -> int | None: for i, w in enumerate(windows): if w.id == win_id: return i return None def set_layout_options(opts: Options) -> None: lgd.draw_minimal_borders = opts.draw_minimal_borders and sum(opts.window_margin_width) == 0 lgd.draw_active_borders = opts.active_border_color is not None lgd.alignment_x = -1 if opts.placement_strategy.endswith('left') else 1 if opts.placement_strategy.endswith('right') else 0 lgd.alignment_y = -1 if opts.placement_strategy.startswith('top') else 1 if opts.placement_strategy.startswith('bottom') else 0 def convert_bias_map(bias: dict[int, float], number_of_windows: int, number_of_cells: int) -> Sequence[float]: cells_per_window, extra = divmod(number_of_cells, number_of_windows) cell_map = list(repeat(cells_per_window, number_of_windows)) cell_map[-1] += extra base_bias = [x / number_of_cells for x in cell_map] return distribute_indexed_bias(base_bias, bias) def calculate_cells_map( bias: None | Sequence[float] | dict[int, float], number_of_windows: int, number_of_cells: int ) -> list[int]: if isinstance(bias, dict): bias = convert_bias_map(bias, number_of_windows, number_of_cells) cells_per_window = number_of_cells // number_of_windows if bias is not None and number_of_windows > 1 and number_of_windows == len(bias) and cells_per_window > 5: cells_map = [int(b * number_of_cells) for b in bias] while min(cells_map) < 5: maxi, mini = map(cells_map.index, (max(cells_map), min(cells_map))) if maxi == mini: break cells_map[mini] += 1 cells_map[maxi] -= 1 else: cells_map = list(repeat(cells_per_window, number_of_windows)) extra = number_of_cells - sum(cells_map) if extra > 0: cells_map[-1] += extra return cells_map def layout_dimension( start_at: int, length: int, cell_length: int, decoration_pairs: DecorationPairs, alignment: int = 0, bias: None | Sequence[float] | dict[int, float] = None ) -> LayoutDimension: number_of_windows = len(decoration_pairs) number_of_cells = length // cell_length dec_vals: Iterable[int] = map(sum, decoration_pairs) space_needed_for_decorations = sum(dec_vals) extra = length - number_of_cells * cell_length while extra < space_needed_for_decorations: number_of_cells -= 1 extra = length - number_of_cells * cell_length cells_map = calculate_cells_map(bias, number_of_windows, number_of_cells) assert sum(cells_map) == number_of_cells extra = length - number_of_cells * cell_length - space_needed_for_decorations pos = start_at # start if alignment > 0: # end pos += extra elif alignment == 0: # center pos += extra // 2 last_i = len(cells_map) - 1 for i, cells_per_window in enumerate(cells_map): before_dec, after_dec = decoration_pairs[i] pos += before_dec if i == 0: before_space = pos - start_at else: before_space = before_dec content_size = cells_per_window * cell_length if i == last_i: after_space = (start_at + length) - (pos + content_size) else: after_space = after_dec yield LayoutData(pos, cells_per_window, before_space, after_space, content_size) pos += content_size + after_space class Rect(NamedTuple): left: int top: int right: int bottom: int def blank_rects_for_window(wg: WindowGeometry) -> Generator[Rect, None, None]: left_width, right_width = wg.spaces.left, wg.spaces.right top_height, bottom_height = wg.spaces.top, wg.spaces.bottom if left_width > 0: yield Rect(wg.left - left_width, wg.top - top_height, wg.left, wg.bottom + bottom_height) if top_height > 0: yield Rect(wg.left, wg.top - top_height, wg.right + right_width, wg.top) if right_width > 0: yield Rect(wg.right, wg.top, wg.right + right_width, wg.bottom + bottom_height) if bottom_height > 0: yield Rect(wg.left, wg.bottom, wg.right, wg.bottom + bottom_height) def window_geometry(xstart: int, xnum: int, ystart: int, ynum: int, left: int, top: int, right: int, bottom: int) -> WindowGeometry: return WindowGeometry( left=xstart, top=ystart, xnum=max(0, xnum), ynum=max(0, ynum), right=xstart + lgd.cell_width * xnum, bottom=ystart + lgd.cell_height * ynum, spaces=Edges(left, top, right, bottom) ) def window_geometry_from_layouts(x: LayoutData, y: LayoutData) -> WindowGeometry: return window_geometry(x.content_pos, x.cells_per_window, y.content_pos, y.cells_per_window, x.space_before, y.space_before, x.space_after, y.space_after) def layout_single_window( xdecoration_pairs: DecorationPairs, ydecoration_pairs: DecorationPairs, xalignment: int = 0, yalignment: int = 0, ) -> WindowGeometry: x = next(layout_dimension(lgd.central.left, lgd.central.width, lgd.cell_width, xdecoration_pairs, alignment=xalignment)) y = next(layout_dimension(lgd.central.top, lgd.central.height, lgd.cell_height, ydecoration_pairs, alignment=yalignment)) return window_geometry_from_layouts(x, y) def safe_increment_bias(old_val: float, increment: float = 0) -> float: return max(0.1, min(old_val + increment, 0.9)) def normalize_biases(biases: list[float]) -> list[float]: s = sum(biases) if s == 1.0: return biases return [x/s for x in biases] def distribute_indexed_bias(base_bias: Sequence[float], index_bias_map: dict[int, float]) -> Sequence[float]: if not index_bias_map: return base_bias ans = list(base_bias) limit = len(ans) for row, increment in index_bias_map.items(): if row >= limit or not increment: continue other_increment = -increment / (limit - 1) ans = [safe_increment_bias(b, increment if i == row else other_increment) for i, b in enumerate(ans)] return normalize_biases(ans) def create_window_id_map_for_unserialize(all_windows: WindowList, serialize_user_var_name: str = serialize_user_var_name) -> dict[int, int]: window_id_map = {} for w in all_windows: k = w.user_vars.pop(serialize_user_var_name, None) if k is not None: try: window_id_map[int(k)] = w.id except Exception: pass return window_id_map class Layout: name: str = '' needs_window_borders = True must_draw_borders = False # can be overridden to customize behavior from kittens layout_opts = LayoutOpts({}) only_active_window_visible = False def __init__(self, os_window_id: int, tab_id: int, layout_opts: str = '') -> None: self.set_owner(os_window_id, tab_id) # A set of rectangles corresponding to the blank spaces at the edges of # this layout, i.e. spaces that are not covered by any window self.blank_rects: list[Rect] = [] self.layout_opts = self.parse_layout_opts(layout_opts) assert self.name is not None self.full_name = f'{self.name}:{layout_opts}' if layout_opts else self.name self.remove_all_biases() def set_owner(self, os_window_id: int, tab_id: int) -> None: # Useful when moving a layout from one tab to another typically a detached tab being re-attached self.os_window_id = os_window_id self.tab_id = tab_id self.set_active_window_in_os_window = partial(set_active_window, os_window_id, tab_id) def bias_increment_for_cell(self, all_windows: WindowList, is_horizontal: bool) -> float: self._set_dimensions() return self.calculate_bias_increment_for_a_single_cell(all_windows, is_horizontal) def calculate_bias_increment_for_a_single_cell(self, all_windows: WindowList, is_horizontal: bool) -> float: if is_horizontal: return (lgd.cell_width + 1) / lgd.central.width return (lgd.cell_height + 1) / lgd.central.height def apply_bias(self, window_id: int, increment: float, all_windows: WindowList, is_horizontal: bool = True) -> bool: return False def remove_all_biases(self) -> bool: return False def modify_size_of_window(self, all_windows: WindowList, window_id: int, increment: float, is_horizontal: bool = True) -> bool: idx = all_windows.group_idx_for_window(window_id) if idx is None or not increment: return False return self.apply_bias(idx, increment, all_windows, is_horizontal) def parse_layout_opts(self, layout_opts: str | None = None) -> LayoutOpts: data: dict[str, str] = {} if layout_opts: for x in layout_opts.split(';'): k, v = x.partition('=')[::2] if k and v: data[k] = v return type(self.layout_opts)(data) def nth_window(self, all_windows: WindowList, num: int) -> WindowType | None: return all_windows.active_window_in_nth_group(num, clamp=True) def activate_nth_window(self, all_windows: WindowList, num: int) -> None: all_windows.set_active_group_idx(num) def next_window(self, all_windows: WindowList, delta: int = 1) -> None: all_windows.activate_next_window_group(delta) def neighbors(self, all_windows: WindowList) -> NeighborsMap: w = all_windows.active_window assert w is not None return self.neighbors_for_window(w, all_windows) def move_window(self, all_windows: WindowList, delta: int = 1) -> bool: if all_windows.num_groups < 2 or not delta: return False return all_windows.move_window_group(by=delta) def move_window_to_group(self, all_windows: WindowList, group: int) -> bool: return all_windows.move_window_group(to_group=group) def add_window( self, all_windows: WindowList, window: WindowType, location: str | None = None, overlay_for: int | None = None, put_overlay_behind: bool = False, bias: float | None = None, next_to: WindowType | None = None, ) -> WindowType | None: if overlay_for is not None: underlay = all_windows.id_map.get(overlay_for) if underlay is not None: window.margin, window.padding = underlay.margin.copy(), underlay.padding.copy() all_windows.add_window(window, group_of=overlay_for, head_of_group=put_overlay_behind) return underlay if location == 'neighbor': location = 'after' self.add_non_overlay_window(all_windows, window, location, bias, next_to) return None def add_non_overlay_window( self, all_windows: WindowList, window: WindowType, location: str | None, bias: float | None = None, next_to: WindowType | None = None ) -> None: before = False next_to = next_to or all_windows.active_window if location is not None: if location in ('after', 'vsplit', 'hsplit'): pass elif location == 'before': before = True elif location == 'first': before = True next_to = None elif location == 'last': next_to = None all_windows.add_window(window, next_to=next_to, before=before) if bias is not None: idx = all_windows.group_idx_for_window(window) if idx is not None: self._set_dimensions() self._bias_slot(all_windows, idx, bias) def _bias_slot(self, all_windows: WindowList, idx: int, bias: float) -> bool: fractional_bias = max(10, min(abs(bias), 90)) / 100 h, v = self.calculate_bias_increment_for_a_single_cell(all_windows, True), self.calculate_bias_increment_for_a_single_cell(all_windows, False) nh, nv = lgd.central.width / lgd.cell_width, lgd.central.height / lgd.cell_height f = max(-90, min(bias, 90)) / 100. return self.bias_slot(all_windows, idx, fractional_bias, h * nh *f, v * nv * f) def bias_slot(self, all_windows: WindowList, idx: int, fractional_bias: float, cell_increment_bias_h: float, cell_increment_bias_v: float) -> bool: return False def update_visibility(self, all_windows: WindowList) -> None: active_window = all_windows.active_window for window, is_group_leader in all_windows.iter_windows_with_visibility(): is_visible = window is active_window or (is_group_leader and not self.only_active_window_visible) window.set_visible_in_layout(is_visible) def _set_dimensions(self) -> None: lgd.central, tab_bar, vw, vh, lgd.cell_width, lgd.cell_height = viewport_for_window(self.os_window_id) def __call__(self, all_windows: WindowList) -> None: self._set_dimensions() self.update_visibility(all_windows) self.blank_rects = [] self.do_layout(all_windows) def layout_single_window_group(self, wg: WindowGroup, add_blank_rects: bool = True) -> None: bw = 1 if self.must_draw_borders else 0 xdecoration_pairs = (( wg.decoration('left', border_mult=bw, is_single_window=True), wg.decoration('right', border_mult=bw, is_single_window=True), ),) ydecoration_pairs = (( wg.decoration('top', border_mult=bw, is_single_window=True), wg.decoration('bottom', border_mult=bw, is_single_window=True), ),) geom = layout_single_window(xdecoration_pairs, ydecoration_pairs, xalignment=lgd.alignment_x, yalignment=lgd.alignment_y) wg.set_geometry(geom) if add_blank_rects and wg: self.blank_rects.extend(blank_rects_for_window(geom)) def xlayout( self, groups: Iterator[WindowGroup], bias: None | Sequence[float] | dict[int, float] = None, start: int | None = None, size: int | None = None, offset: int = 0, border_mult: int = 1 ) -> LayoutDimension: decoration_pairs = tuple( (g.decoration('left', border_mult=border_mult), g.decoration('right', border_mult=border_mult)) for i, g in enumerate(groups) if i >= offset ) if start is None: start = lgd.central.left if size is None: size = lgd.central.width return layout_dimension(start, size, lgd.cell_width, decoration_pairs, bias=bias, alignment=lgd.alignment_x) def ylayout( self, groups: Iterator[WindowGroup], bias: None | Sequence[float] | dict[int, float] = None, start: int | None = None, size: int | None = None, offset: int = 0, border_mult: int = 1 ) -> LayoutDimension: decoration_pairs = tuple( (g.decoration('top', border_mult=border_mult), g.decoration('bottom', border_mult=border_mult)) for i, g in enumerate(groups) if i >= offset ) if start is None: start = lgd.central.top if size is None: size = lgd.central.height return layout_dimension(start, size, lgd.cell_height, decoration_pairs, bias=bias, alignment=lgd.alignment_y) def set_window_group_geometry(self, wg: WindowGroup, xl: LayoutData, yl: LayoutData) -> WindowGeometry: geom = window_geometry_from_layouts(xl, yl) wg.set_geometry(geom) self.blank_rects.extend(blank_rects_for_window(geom)) return geom def do_layout(self, windows: WindowList) -> None: raise NotImplementedError() def neighbors_for_window(self, window: WindowType, windows: WindowList) -> NeighborsMap: return {'left': [], 'right': [], 'top': [], 'bottom': []} def compute_needs_borders_map(self, all_windows: WindowList) -> dict[int, bool]: return all_windows.compute_needs_borders_map(lgd.draw_active_borders) def get_minimal_borders(self, windows: WindowList) -> Generator[BorderLine, None, None]: self._set_dimensions() yield from self.minimal_borders(windows) def minimal_borders(self, windows: WindowList) -> Generator[BorderLine, None, None]: return yield BorderLine() # type: ignore def layout_action(self, action_name: str, args: Sequence[str], all_windows: WindowList) -> bool | None: pass def layout_state(self) -> dict[str, Any]: return {} def set_layout_state(self, layout_state: dict[str, Any], map_group_id: WindowMapper) -> bool: return True def serialize(self, all_windows: WindowList) -> dict[str, Any]: ans = self.layout_state() ans['opts'] = self.layout_opts.serialized() ans['class'] = self.__class__.__name__ ans['all_windows'] = all_windows.serialize_layout_state() return ans def unserialize( self, s: dict[str, Any], all_windows: WindowList, window_id_mapper: Callable[[WindowList], dict[int, int]] = create_window_id_map_for_unserialize, ) -> bool: 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) if m is None: return False return self.set_layout_state(s, m.get)