From b49c7a8d0bb6c6d8104cc376e59e0c95774c2e30 Mon Sep 17 00:00:00 2001 From: Axy Date: Mon, 2 Mar 2026 18:21:50 +0100 Subject: [PATCH] Layout, curses, and fixes --- __main__.py | 26 +++-- amazeing/config/config_parser.py | 56 +++++---- amazeing/config/parser_combinator.py | 4 +- amazeing/maze_class/maze_walls.py | 1 - amazeing/maze_display/TTYdisplay.py | 60 +++++++--- amazeing/maze_display/backend.py | 44 +++---- amazeing/maze_display/layout.py | 164 +++++++++++++++++++++++++++ amazeing/maze_make_pacman.py | 2 +- 8 files changed, 288 insertions(+), 69 deletions(-) create mode 100644 amazeing/maze_display/layout.py diff --git a/__main__.py b/__main__.py index 6d94cb7..238d639 100644 --- a/__main__.py +++ b/__main__.py @@ -14,6 +14,11 @@ from amazeing.maze_class.maze_walls import Cardinal, CellCoord from amazeing.maze_display.TTYdisplay import TTYTile from amazeing.maze_display.backend import IVec2 +# from amazeing.maze_display.layout import example + +# example() +# exit(0) + # random.seed(42) # print(Config.parse(stdin.read()).__dict__) @@ -29,28 +34,26 @@ pattern.fill(maze) walls_const = set(maze.walls_full()) -backend = TTYBackend(IVec2(*dims), IVec2(1, 1), IVec2(2, 2)) +backend = TTYBackend(IVec2(*dims), IVec2(2, 1), IVec2(2, 1)) curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_BLACK) black = curses.color_pair(1) empty = (" ", black) style_empty = backend.add_style( TTYTile( [ - [empty, empty, empty], - [empty, empty, empty], - [empty, empty, empty], + [empty, empty, empty, empty], + [empty, empty, empty, empty], ] ) ) -curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_WHITE) +curses.init_pair(2, curses.COLOR_WHITE, curses.COLOR_WHITE) white = curses.color_pair(2) -full = ("#", white) +full = (" ", white) style_full = backend.add_style( TTYTile( [ - [full, full, full], - [full, full, full], - [full, full, full], + [full, full, full, full], + [full, full, full, full], ] ) ) @@ -76,9 +79,10 @@ def display_maze(maze: Maze) -> None: maze_make_perfect(maze, callback=display_maze) -backend.event(-1) -# maze_make_pacman(maze, walls_const, callback=display_maze) +maze_make_pacman(maze, walls_const, callback=display_maze) while False: maze_make_perfect(maze, callback=display_maze) maze_make_pacman(maze, walls_const, callback=display_maze) maze._rebuild() +while backend.event(-1) is None: + backend.present() diff --git a/amazeing/config/config_parser.py b/amazeing/config/config_parser.py index f20027d..ab3b9bd 100644 --- a/amazeing/config/config_parser.py +++ b/amazeing/config/config_parser.py @@ -1,7 +1,6 @@ from abc import ABC, abstractmethod -from collections.abc import Callable, Iterable -from typing import Any, Type, cast -from dataclasses import dataclass +from collections.abc import Callable +from typing import Any, Type from .parser_combinator import ( ParseResult, Parser, @@ -68,6 +67,7 @@ class ConfigField[T](ABC): @abstractmethod def parse(self, s: str) -> ParseResult[T]: ... + def default(self) -> T: if self.__default is None: raise ConfigException( @@ -91,19 +91,23 @@ class ConfigField[T](ABC): class IntField(ConfigField[int]): - parse = lambda self, s: parse_int(s) + def parse(self, s: str) -> ParseResult[int]: + return parse_int(s) class BoolField(ConfigField[bool]): - parse = lambda self, s: parse_bool(s) + def parse(self, s: str) -> ParseResult[bool]: + return parse_bool(s) class CoordField(ConfigField[tuple[int, int]]): - parse = lambda self, s: parse_coord(s) + def parse(self, s: str) -> ParseResult[tuple[int, int]]: + return parse_coord(s) class PathField(ConfigField[str]): - parse = lambda self, s: parse_path(s) + def parse(self, s: str) -> ParseResult[str]: + return parse_path(s) def OptionalField[T](cls: Type[ConfigField[T]]) -> Type[ConfigField[T | None]]: @@ -129,19 +133,17 @@ def DefaultedField[T]( return Inner -def line_parser( - fields: dict[str, ConfigField[Any]], -) -> Parser[tuple[str, Any] | None]: +def line_parser[T]( + fields: dict[str, ConfigField[T]], +) -> Parser[tuple[str, T] | None]: return alt( parser_map(lambda _: None, parse_comment), *( preceeded( seq(tag(name), parse_space, tag("="), parse_space), - # name=name is used to actually capture the value, because - # lambdas are by-reference otherwise, including for trivial - # value types, much smart very clever :) parser_map( - lambda res, name=name: (name, res), cut(field.parse) + (lambda name: lambda res: (name, res))(name), + cut(field.parse), ), ) for name, field in fields.items() @@ -150,7 +152,7 @@ def line_parser( def fields_parser( - fields_raw: dict[str, type[ConfigField[Any]]], + fields_raw: dict[str, type[ConfigField]], ) -> Parser[dict[str, Any]]: fields = {key: cls(key) for key, cls in fields_raw.items()} parse_line = terminated(line_parser(fields), cut(tag("\n"))) @@ -163,11 +165,14 @@ def fields_parser( acc[elem[0]].append(elem[1]) return acc - return parser_map( + fields_map: Callable[[dict[str, list[Any]]], dict[str, Any]] = ( lambda res: { name: fields[name].merge(values) for name, values in res.items() - }, + } + ) + return parser_map( + fields_map, fold( parse_line, fold_fn, @@ -186,6 +191,9 @@ class Config: output_file: str | None perfect: bool seed: int | None + screensaver: bool + visual: bool + interactive: bool def __init__(self) -> None: pass @@ -199,16 +207,22 @@ class Config: "HEIGHT": IntField, "ENTRY": OptionalField(CoordField), "EXIT": OptionalField(CoordField), - "OUTPUT_FILE": PathField, - "PERFECT": BoolField, + "OUTPUT_FILE": OptionalField(PathField), + "PERFECT": DefaultedField(BoolField, True), "SEED": OptionalField(IntField), + "SCREENSAVER": DefaultedField(BoolField, False), + "VISUAL": DefaultedField(BoolField, False), + "INTERACTIVE": DefaultedField(BoolField, False), } ) )(s) if fields is None: raise ConfigException("Failed to parse config") res = Config() - for key, value in fields[0].items(): - res.__dict__[key.lower()] = value + for key, val in fields[0].items(): + res.__dict__[key.lower()] = val + + if res.screensaver: + res.visual = True return res diff --git a/amazeing/config/parser_combinator.py b/amazeing/config/parser_combinator.py index 4c9bbea..a025da8 100644 --- a/amazeing/config/parser_combinator.py +++ b/amazeing/config/parser_combinator.py @@ -63,7 +63,9 @@ def cut[T](p: Parser[T]) -> Parser[T]: def tag(tag: str) -> Parser[str]: return lambda s: ( - (s[: len(tag)], s[len(tag) :]) if s.startswith(tag) else None + (s[: len(tag)], s[len(tag) :]) # noqa E203 + if s.startswith(tag) + else None ) diff --git a/amazeing/maze_class/maze_walls.py b/amazeing/maze_class/maze_walls.py index 178b619..5e279c2 100644 --- a/amazeing/maze_class/maze_walls.py +++ b/amazeing/maze_class/maze_walls.py @@ -1,5 +1,4 @@ from enum import Enum, auto -from sys import stderr from typing import Iterable, Optional, cast from ..maze_display import IVec2 diff --git a/amazeing/maze_display/TTYdisplay.py b/amazeing/maze_display/TTYdisplay.py index 8a44ea8..0ec9461 100644 --- a/amazeing/maze_display/TTYdisplay.py +++ b/amazeing/maze_display/TTYdisplay.py @@ -1,4 +1,13 @@ -from sys import stderr +from amazeing.maze_display.layout import ( + BInt, + Box, + FBox, + HBox, + VBox, + layout_fair, + vpad_box, + hpad_box, +) from .backend import Backend, IVec2, BackendEvent, KeyboardInput import curses @@ -10,8 +19,12 @@ class TTYTile: def blit( self, src: IVec2, dst: IVec2, size: IVec2, window: curses.window ) -> None: - for y, line in enumerate(self.__pixels[src.y : src.y + size.y]): - for x, (char, attrs) in enumerate(line[src.x : src.x + size.x]): + for y, line in enumerate( + self.__pixels[src.y : src.y + size.y] # noqa E203 + ): + for x, (char, attrs) in enumerate( + line[src.x : src.x + size.x] # noqa E203 + ): window.addch(dst.y + y, dst.x + x, char, attrs) @@ -44,6 +57,10 @@ class TTYTileMap: ) +class ScrollablePad: + pass + + class TTYBackend(Backend[int]): """ Takes the ABC Backend and displays the maze in the terminal. @@ -56,18 +73,35 @@ class TTYBackend(Backend[int]): self.__tilemap: TTYTileMap = TTYTileMap(wall_dim, cell_dim) self.__style = 0 - dims = self.__tilemap.dst_coord(maze_dims * 2 + 2) + dims = self.__tilemap.dst_coord(maze_dims * 2 + 1) self.__screen: curses.window = curses.initscr() curses.start_color() curses.noecho() curses.cbreak() + curses.curs_set(0) self.__screen.keypad(True) - self.__pad: curses.window = curses.newpad(dims.y, dims.x) + self.__pad: curses.window = curses.newpad(dims.y + 1, dims.x + 1) self.__dims = maze_dims + maze_box = FBox( + IVec2(BInt(dims.x), BInt(dims.y)), + lambda at, into: self.__pad.refresh( + 0, 0, at.y, at.x, at.y + into.y - 1, at.x + into.x - 1 + ), + ) + self.__layout: Box = VBox.noassoc( + layout_fair, + [ + vpad_box(), + HBox.noassoc(layout_fair, [hpad_box(), maze_box, hpad_box()]), + vpad_box(), + ], + ) + def __del__(self): + curses.curs_set(1) curses.nocbreak() self.__screen.keypad(False) curses.echo() @@ -87,18 +121,16 @@ class TTYBackend(Backend[int]): def present(self) -> None: self.__screen.refresh() - self.__pad.refresh( - 0, - 0, - 0, - 0, - min(self.__pad.getmaxyx()[0] - 1, self.__screen.getmaxyx()[0] - 1), - min(self.__pad.getmaxyx()[1] - 1, self.__screen.getmaxyx()[1] - 1), - ) + y, x = self.__screen.getmaxyx() + self.__layout.laid_out(IVec2(0, 0), IVec2(x, y)) def event(self, timeout_ms: int = -1) -> BackendEvent | None: self.__screen.timeout(timeout_ms) try: - return KeyboardInput(self.__screen.getkey()) + key = self.__screen.getkey() + if key == "KEY_RESIZE": + self.__screen.erase() + return None + return KeyboardInput(key) except curses.error: return None diff --git a/amazeing/maze_display/backend.py b/amazeing/maze_display/backend.py index 45d346e..0d053a1 100644 --- a/amazeing/maze_display/backend.py +++ b/amazeing/maze_display/backend.py @@ -1,47 +1,51 @@ from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass +from typing import Type, cast -class IVec2: - def __init__(self, x: int, y: int) -> None: - self.x: int = x - self.y: int = y +class IVec2[T = int]: + def __init__(self, x: T, y: T) -> None: + self.x: T = x + self.y: T = y @staticmethod - def splat(n: int) -> "IVec2": + def splat(n: T) -> "IVec2[T]": return IVec2(n, n) @staticmethod def with_op( - op: Callable[[int, int], int], - ) -> Callable[["IVec2", "int | IVec2"], "IVec2"]: + op: Callable[[T, T], T], + ) -> Callable[["IVec2[T]", "T | IVec2[T]"], "IVec2[T]"]: return lambda self, other: IVec2( op( self.x, ( other - if isinstance(other, IVec2) - else (other := IVec2.splat(other)) + if isinstance(other, type(self)) + else (other := type(self).splat(cast(T, other))) ).x, ), - op(self.y, other.y), + op(self.y, cast(IVec2[T], other).y), ) - def __mul__(self, other: "int | IVec2") -> "IVec2": - return self.with_op(int.__mul__)(self, other) + def innertype(self) -> Type: + return type(self.x) - def __add__(self, other: "int | IVec2") -> "IVec2": - return self.with_op(int.__add__)(self, other) + def __mul__(self, other: "T | IVec2[T]") -> "IVec2[T]": + return self.with_op(self.innertype().__mul__)(self, other) - def __sub__(self, other: "int | IVec2") -> "IVec2": - return self.with_op(int.__sub__)(self, other) + def __add__(self, other: "T | IVec2[T]") -> "IVec2[T]": + return self.with_op(self.innertype().__add__)(self, other) - def __floordiv__(self, other: "int | IVec2") -> "IVec2": - return self.with_op(int.__floordiv__)(self, other) + def __sub__(self, other: "T | IVec2[T]") -> "IVec2[T]": + return self.with_op(self.innertype().__sub__)(self, other) - def __mod__(self, other: "int | IVec2") -> "IVec2": - return self.with_op(int.__mod__)(self, other) + def __floordiv__(self, other: "T| IVec2[T]") -> "IVec2[T]": + return self.with_op(self.innertype().__floordiv__)(self, other) + + def __mod__(self, other: "T | IVec2[T]") -> "IVec2[T]": + return self.with_op(self.innertype().__mod__)(self, other) @dataclass diff --git a/amazeing/maze_display/layout.py b/amazeing/maze_display/layout.py new file mode 100644 index 0000000..acbb80f --- /dev/null +++ b/amazeing/maze_display/layout.py @@ -0,0 +1,164 @@ +from abc import ABC, abstractmethod +from collections.abc import Callable +from .backend import IVec2 + + +class BInt: + def __init__(self, val: int, has_flex: bool = False) -> None: + self.val: int = val + self.has_flex: bool = has_flex + + +type BVec2 = IVec2[BInt] + +type Layout[T] = Callable[[list[tuple[BInt, T]], int], list[int]] + + +def layout_priority(sizes: list[BInt], available: int) -> list[int]: + res = [] + for size in sizes: + size_scaled = min(size.val, available) + res.append(size_scaled) + available -= size_scaled + return res + + +def rdiv(a: int, b: int) -> int: + return a // b + (a % b != 0) if a != 0 else 0 + + +def layout_fair[T](sizes: list[tuple[BInt, T]], available: int) -> list[int]: + res = [0 for _ in sizes] + count = len(sizes) + for idx, size in sorted(enumerate(sizes), key=lambda e: e[1][0].val): + size_scaled = min(size[0].val, rdiv(available, count)) + res[idx] += size_scaled + available -= size_scaled + count -= 1 + count = sum(1 for e in sizes if e[0].has_flex) + for idx, size in enumerate(sizes): + if not size[0].has_flex: + continue + size_scaled = rdiv(available, count) + res[idx] += size_scaled + available -= size_scaled + count -= 1 + return res + + +class Box(ABC): + @abstractmethod + def dims(self) -> BVec2: ... + @abstractmethod + def laid_out(self, at: IVec2, into: IVec2) -> None: ... + + +class VBox[T](Box): + def __init__( + self, layout: Layout, boxes: list[tuple[Box, T]] = [] + ) -> None: + self.boxes: list[tuple[Box, T]] = boxes + self.layout: Layout = layout + + @staticmethod + def noassoc(layout: Layout, boxes: list[Box]) -> "VBox[None]": + return VBox(layout, [(box, None) for box in boxes]) + + def dims(self) -> BVec2: + dims = [box.dims() for box, _ in self.boxes] + return IVec2( + BInt( + max(map(lambda e: e.x.val, dims)), + any(map(lambda e: e.x.has_flex, dims)), + ), + BInt( + sum(map(lambda e: e.y.val, dims)), + any(map(lambda e: e.y.has_flex, dims)), + ), + ) + + def laid_out(self, at: IVec2, into: IVec2) -> None: + get_width: Callable[[BInt], int] = lambda w: ( + into.x if w.has_flex and w.val < into.x else min(w.val, into.x) + ) + + dims = [(box.dims(), assoc) for box, assoc in self.boxes] + heights = self.layout([(dim.y, assoc) for dim, assoc in dims], into.y) + widths = [(get_width(dim.x), assoc) for dim, assoc in dims] + + for height, (width, _), (box, _) in zip(heights, widths, self.boxes): + box.laid_out(at, IVec2(width, height)) + at.y += height + + +class HBox[T](Box): + def __init__( + self, layout: Layout, boxes: list[tuple[Box, T]] = [] + ) -> None: + self.boxes: list[tuple[Box, T]] = boxes + self.layout: Layout = layout + + @staticmethod + def noassoc(layout: Layout, boxes: list[Box]) -> "HBox[None]": + return HBox(layout, [(box, None) for box in boxes]) + + def dims(self) -> BVec2: + dims = [box.dims() for box, _ in self.boxes] + return IVec2( + BInt( + sum(map(lambda e: e.x.val, dims)), + any(map(lambda e: e.x.has_flex, dims)), + ), + BInt( + max(map(lambda e: e.y.val, dims)), + any(map(lambda e: e.y.has_flex, dims)), + ), + ) + + def laid_out(self, at: IVec2, into: IVec2) -> None: + get_height: Callable[[BInt], int] = lambda w: ( + into.y if w.has_flex and w.val < into.y else min(w.val, into.y) + ) + + dims = [(box.dims(), assoc) for box, assoc in self.boxes] + widths = self.layout([(dim.x, assoc) for dim, assoc in dims], into.x) + heights = [(get_height(dim.y), assoc) for dim, assoc in dims] + + for (height, _), width, (box, _) in zip(heights, widths, self.boxes): + box.laid_out(at, IVec2(width, height)) + at.x += width + + +class FBox(Box): + def __init__( + self, dims: BVec2, cb: Callable[[IVec2, IVec2], None] + ) -> None: + self.__dims: BVec2 = dims + self.__cb: Callable[[IVec2, IVec2], None] = cb + + def dims(self) -> BVec2: + return self.__dims + + def laid_out(self, at: IVec2, into: IVec2) -> None: + self.__cb(at, into) + + +def vpad_box[T](min_pad: int = 0) -> FBox: + return FBox(IVec2(BInt(0), BInt(min_pad, True)), lambda _at, _into: None) + + +def hpad_box(min_pad: int = 0) -> FBox: + return FBox(IVec2(BInt(min_pad, True), BInt(0)), lambda _at, _into: None) + + +def print_cb(at: IVec2, into: IVec2) -> None: + print(f"at {at.x, at.y}, into {into.x, into.y}") + + +def example() -> None: + a = FBox(IVec2(BInt(8, False), BInt(4, True)), print_cb) + b = FBox(IVec2(BInt(4, False), BInt(8, False)), print_cb) + c = VBox.noassoc(layout_fair, [a, b]) + c.laid_out(IVec2(0, 0), IVec2(3, 4)) + c.laid_out(IVec2(0, 0), IVec2(8, 30)) + c.laid_out(IVec2(0, 0), IVec2(12, 30)) diff --git a/amazeing/maze_make_pacman.py b/amazeing/maze_make_pacman.py index 622494a..5014347 100644 --- a/amazeing/maze_make_pacman.py +++ b/amazeing/maze_make_pacman.py @@ -1,4 +1,4 @@ -from typing import Any, Callable +from typing import Callable from amazeing import Maze, WallCoord import random -- 2.53.0