diff --git a/CHANGELOG.md b/CHANGELOG.md index dd71c0e386..7b4c6448a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,14 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## Unreleased +## [0.87.0] - 2024-11-24 ### Added - Added Styles.has_any_rules https://github.com/Textualize/textual/pull/5264 +- Added `position` CSS rule. https://github.com/Textualize/textual/pull/5278 +- Added `Widget.set_scroll` https://github.com/Textualize/textual/pull/5278 +- Added `Select.selection` https://github.com/Textualize/textual/pull/5278 ### Fixed @@ -2573,6 +2576,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040 - New handler system for messages that doesn't require inheritance - Improved traceback handling +[0.87.0]: https://github.com/Textualize/textual/compare/v0.86.4...v0.87.0 [0.86.3]: https://github.com/Textualize/textual/compare/v0.86.2...v0.86.3 [0.86.2]: https://github.com/Textualize/textual/compare/v0.86.1...v0.86.2 [0.86.1]: https://github.com/Textualize/textual/compare/v0.86.0...v0.86.1 diff --git a/docs/css_types/position.md b/docs/css_types/position.md new file mode 100644 index 0000000000..307716be75 --- /dev/null +++ b/docs/css_types/position.md @@ -0,0 +1,31 @@ +# <position> + +The `` CSS type defines how the the `offset` rule is applied.. + + +## Syntax + +A [``](./position.md) may be any of the following values: + +| Value | Alignment type | +| ---------- | ------------------------------------------------------------ | +| `relative` | Offset is applied to widgets default position. | +| `absolute` | Offset is applied to the origin (top left) of its container. | + +## Examples + +### CSS + +```css +Label { + position: absolute; + offset: 10 5; +} +``` + +### Python + +```py +widget.styles.position = "absolute" +widget.styles.offset = (10, 5) +``` diff --git a/docs/examples/styles/position.py b/docs/examples/styles/position.py new file mode 100644 index 0000000000..c002ce3fb0 --- /dev/null +++ b/docs/examples/styles/position.py @@ -0,0 +1,15 @@ +from textual.app import App, ComposeResult +from textual.widgets import Label + + +class PositionApp(App): + CSS_PATH = "position.tcss" + + def compose(self) -> ComposeResult: + yield Label("Absolute", id="label1") + yield Label("Relative", id="label2") + + +if __name__ == "__main__": + app = PositionApp() + app.run() diff --git a/docs/examples/styles/position.tcss b/docs/examples/styles/position.tcss new file mode 100644 index 0000000000..80b16f294d --- /dev/null +++ b/docs/examples/styles/position.tcss @@ -0,0 +1,19 @@ +Screen { + align: center middle; +} + +Label { + padding: 1; + background: $panel; + border: thick $border; +} + +Label#label1 { + position: absolute; + offset: 2 1; +} + +Label#label2 { + position: relative; + offset: 2 1; +} diff --git a/docs/styles/position.md b/docs/styles/position.md new file mode 100644 index 0000000000..087b8df7d0 --- /dev/null +++ b/docs/styles/position.md @@ -0,0 +1,61 @@ + +# Position + +The `position` style modifies what [`offset`](./offset.md) is applied to. +The default for `position` is `"relative"`, which means the offset is applied to the normal position of the widget. +In other words, if `offset` is (1, 1), then the widget will be moved 1 cell and 1 line down from its usual position. + +The alternative value of `position` is `"absolute"`. +With absolute positioning, the offset is relative to the origin (i.e. the top left of the container). +So a widget with offset (1, 1) and absolute positioning will be 1 cell and 1 line down from the top left corner. + +!!! note + + Absolute positioning takes precedence over the parent's alignment rule. + +## Syntax + +--8<-- "docs/snippets/syntax_block_start.md" +position: <position>; +--8<-- "docs/snippets/syntax_block_end.md" + + +## Examples + + +Two labels, the first is absolute positioned and is displayed relative to the top left of the screen. +The second label is relative and is displayed offset from the center. + +=== "Output" + + ```{.textual path="docs/examples/styles/position.py"} + ``` + +=== "position.py" + + ```py + --8<-- "docs/examples/styles/position.py" + ``` + +=== "position.tcss" + + ```css + --8<-- "docs/examples/styles/position.tcss" + ``` + + + + +## CSS + +```css +position: relative; +position: absolute; +``` + +## Python + +```py +widget.styles.position = "relative" +widget.styles.position = "absolute" +``` diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml index 23d258eb18..ad3907fca5 100644 --- a/mkdocs-nav.yml +++ b/mkdocs-nav.yml @@ -37,6 +37,7 @@ nav: - "css_types/name.md" - "css_types/number.md" - "css_types/overflow.md" + - "css_types/position.md" - "css_types/percentage.md" - "css_types/scalar.md" - "css_types/text_align.md" @@ -122,6 +123,7 @@ nav: - "styles/outline.md" - "styles/overflow.md" - "styles/padding.md" + - "styles/position.md" - Scrollbar colors: - "styles/scrollbar_colors/index.md" - "styles/scrollbar_colors/scrollbar_background.md" diff --git a/pyproject.toml b/pyproject.toml index 3221ddff8f..b21f182d1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.86.3" +version = "0.87.0" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/" @@ -22,6 +22,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Typing :: Typed", ] include = [ diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index 7178315b1b..1659a99715 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -109,6 +109,8 @@ def arrange( layout_placements, placement_offset ) + WidgetPlacement.apply_absolute(layout_placements) + placements.extend(layout_placements) return DockArrangeResult(placements, set(display_widgets), scroll_spacing) @@ -186,6 +188,7 @@ def _arrange_dock_widgets( dock_widget, top_z, True, + False, ) ) @@ -238,7 +241,7 @@ def _arrange_split_widgets( append_placement( _WidgetPlacement( - split_region, null_offset, null_spacing, split_widget, 1, True + split_region, null_offset, null_spacing, split_widget, 1, True, False ) ) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 6f6fe29f62..f35e22f1c4 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -649,6 +649,7 @@ def add_widget( z, fixed, overlay, + absolute, ) in reversed(placements): layer_index = get_layer_index(sub_widget.layer, 0) # Combine regions with children to calculate the "virtual size" diff --git a/src/textual/css/_help_text.py b/src/textual/css/_help_text.py index 118f5ef12b..2e925e0de6 100644 --- a/src/textual/css/_help_text.py +++ b/src/textual/css/_help_text.py @@ -14,6 +14,7 @@ VALID_BORDER, VALID_KEYLINE, VALID_LAYOUT, + VALID_POSITION, VALID_STYLE_FLAGS, VALID_TEXT_ALIGN, ) @@ -770,6 +771,23 @@ def offset_single_axis_help_text(property_name: str) -> HelpText: ) +def position_help_text(property_name: str) -> HelpText: + """Help text to show when the user supplies the wrong value for position. + + Args: + property_name: The name of the property. + + Returns: + Renderable for displaying the help text for this property. + """ + return HelpText( + summary=f"Invalid value for [i]{property_name}[/]", + bullets=[ + Bullet(f"Valid values are {friendly_list(VALID_POSITION)}"), + ], + ) + + def style_flags_property_help_text( property_name: str, value: str, context: StylingContext ) -> HelpText: diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index dd973f64b9..b5ccc4802d 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -22,6 +22,7 @@ layout_property_help_text, offset_property_help_text, offset_single_axis_help_text, + position_help_text, property_invalid_value_help_text, scalar_help_text, scrollbar_size_property_help_text, @@ -47,6 +48,7 @@ VALID_KEYLINE, VALID_OVERFLOW, VALID_OVERLAY, + VALID_POSITION, VALID_SCROLLBAR_GUTTER, VALID_STYLE_FLAGS, VALID_TEXT_ALIGN, @@ -622,6 +624,17 @@ def process_offset_y(self, name: str, tokens: list[Token]) -> None: x = self.styles.offset.x self.styles._rules["offset"] = ScalarOffset(x, y) + def process_position(self, name: str, tokens: list[Token]): + if not tokens: + return + if len(tokens) != 1: + self.error(name, tokens[0], offset_single_axis_help_text(name)) + else: + token = tokens[0] + if token.value not in VALID_POSITION: + self.error(name, tokens[0], position_help_text(name)) + self.styles._rules["position"] = token.value + def process_layout(self, name: str, tokens: list[Token]) -> None: from textual.layouts.factory import MissingLayout, get_layout diff --git a/src/textual/css/constants.py b/src/textual/css/constants.py index 82af4e4912..b8ad1234a7 100644 --- a/src/textual/css/constants.py +++ b/src/textual/css/constants.py @@ -33,6 +33,7 @@ VALID_OVERFLOW: Final = {"scroll", "hidden", "auto"} VALID_ALIGN_HORIZONTAL: Final = {"left", "center", "right"} VALID_ALIGN_VERTICAL: Final = {"top", "middle", "bottom"} +VALID_POSITION: Final = {"relative", "absolute"} VALID_TEXT_ALIGN: Final = { "start", "end", diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 6452b61652..668042a592 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -45,6 +45,7 @@ VALID_DISPLAY, VALID_OVERFLOW, VALID_OVERLAY, + VALID_POSITION, VALID_SCROLLBAR_GUTTER, VALID_TEXT_ALIGN, VALID_VISIBILITY, @@ -99,6 +100,7 @@ class RulesMap(TypedDict, total=False): padding: Spacing margin: Spacing offset: ScalarOffset + position: str border_top: tuple[str, Color] border_right: tuple[str, Color] @@ -219,6 +221,7 @@ class StylesBase: "background", "background_tint", "opacity", + "position", "text_opacity", "tint", "scrollbar_color", @@ -307,6 +310,9 @@ class StylesBase: """Set the margin (spacing outside the border) of the widget.""" offset = OffsetProperty() """Set the offset of the widget relative to where it would have been otherwise.""" + position = StringEnumProperty(VALID_POSITION, "relative") + """If `relative` offset is applied to widgets current position, if `absolute` it is applied to (0, 0).""" + border = BorderProperty(layout=True) """Set the border of the widget e.g. ("rounded", "green") or "none".""" @@ -1003,6 +1009,8 @@ def append_declaration(name: str, value: str) -> None: if "offset" in rules: x, y = self.offset append_declaration("offset", f"{x} {y}") + if "position" in rules: + append_declaration("position", self.position) if "dock" in rules: append_declaration("dock", rules["dock"]) if "split" in rules: diff --git a/src/textual/css/types.py b/src/textual/css/types.py index f02ff80ed0..5e65ee5311 100644 --- a/src/textual/css/types.py +++ b/src/textual/css/types.py @@ -38,6 +38,7 @@ TextAlign = Literal["left", "start", "center", "right", "end", "justify"] Constrain = Literal["none", "inflect", "inside"] Overlay = Literal["none", "screen"] +Position = Literal["relative", "absolute"] Specificity3 = Tuple[int, int, int] Specificity6 = Tuple[int, int, int, int, int, int] diff --git a/src/textual/demo/demo_app.py b/src/textual/demo/demo_app.py index b98085d767..abadf35da2 100644 --- a/src/textual/demo/demo_app.py +++ b/src/textual/demo/demo_app.py @@ -2,6 +2,7 @@ from textual.app import App from textual.binding import Binding +from textual.demo.game import GameScreen from textual.demo.home import HomeScreen from textual.demo.projects import ProjectsScreen from textual.demo.widgets import WidgetsScreen @@ -26,6 +27,7 @@ class DemoApp(App): """ MODES = { + "game": GameScreen, "home": HomeScreen, "projects": ProjectsScreen, "widgets": WidgetsScreen, @@ -38,6 +40,12 @@ class DemoApp(App): "home", tooltip="Show the home screen", ), + Binding( + "g", + "app.switch_mode('game')", + "game", + tooltip="Unwind with a Textual game", + ), Binding( "p", "app.switch_mode('projects')", diff --git a/src/textual/demo/game.py b/src/textual/demo/game.py new file mode 100644 index 0000000000..8a8e6797e5 --- /dev/null +++ b/src/textual/demo/game.py @@ -0,0 +1,547 @@ +""" +An implementation of the "Sliding Tile" puzzle. + +Textual isn't a game engine exactly, but it wasn't hard to build this. + +""" + +from __future__ import annotations + +from asyncio import sleep +from collections import defaultdict +from dataclasses import dataclass +from itertools import product +from random import choice +from time import monotonic + +from rich.console import ConsoleRenderable +from rich.syntax import Syntax + +from textual import containers, events, on, work +from textual._loop import loop_last +from textual.app import ComposeResult +from textual.binding import Binding +from textual.demo.page import PageScreen +from textual.geometry import Offset, Size +from textual.reactive import reactive +from textual.screen import ModalScreen, Screen +from textual.timer import Timer +from textual.widgets import Button, Digits, Footer, Select, Static + + +@dataclass +class NewGame: + """A dataclass to report the desired game type.""" + + language: str + code: str + size: tuple[int, int] + + +PYTHON_CODE = '''\ +class SpatialMap(Generic[ValueType]): + """A spatial map allows for data to be associated with rectangular regions + in Euclidean space, and efficiently queried. + + When the SpatialMap is populated, a reference to each value is placed into one or + more buckets associated with a regular grid that covers 2D space. + + The SpatialMap is able to quickly retrieve the values under a given "window" region + by combining the values in the grid squares under the visible area. + """ + + def __init__(self, grid_width: int = 100, grid_height: int = 20) -> None: + """Create a spatial map with the given grid size. + + Args: + grid_width: Width of a grid square. + grid_height: Height of a grid square. + """ + self._grid_size = (grid_width, grid_height) + self.total_region = Region() + self._map: defaultdict[GridCoordinate, list[ValueType]] = defaultdict(list) + self._fixed: list[ValueType] = [] + + def _region_to_grid_coordinates(self, region: Region) -> Iterable[GridCoordinate]: + """Get the grid squares under a region. + + Args: + region: A region. + + Returns: + Iterable of grid coordinates (tuple of 2 values). + """ + # (x1, y1) is the coordinate of the top left cell + # (x2, y2) is the coordinate of the bottom right cell + x1, y1, width, height = region + x2 = x1 + width - 1 + y2 = y1 + height - 1 + grid_width, grid_height = self._grid_size + + return product( + range(x1 // grid_width, x2 // grid_width + 1), + range(y1 // grid_height, y2 // grid_height + 1), + ) +''' + +XML_CODE = """\ + + + + Back to the Future 1985 Robert Zemeckis + Science Fiction PG + + Michael J. Fox Marty McFly + Christopher Lloyd Dr. Emmett Brown + + + + The Breakfast Club 1985 John Hughes + Drama R + + Emilio Estevez Andrew Clark + Molly Ringwald Claire Standish + + + + Ghostbusters 1984 Ivan Reitman + Comedy PG + + Bill Murray Dr. Peter Venkman + Dan Aykroyd Dr. Raymond Stantz + + + + Die Hard 1988 John McTiernan + Action R + + Bruce Willis John McClane + Alan Rickman Hans Gruber + + + + E.T. the Extra-Terrestrial 1982 Steven Spielberg + Science Fiction PG + + Henry Thomas Elliott + Drew Barrymore Gertie + + +""" + +BF_CODE = """\ +[life.b -- John Horton Conway's Game of Life +(c) 2021 Daniel B. Cristofani +] + +>>>->+>+++++>(++++++++++)[[>>>+<<<-]>+++++>+>>+[<<+>>>>>+<<<-]<-]>>>>[ + [>>>+>+<<<<-]+++>>+[<+>>>+>+<<<-]>>[>[[>>>+<<<-]<]<<++>+>>>>>>-]<- +]+++>+>[[-]<+<[>+++++++++++++++++<-]<+]>>[ + [+++++++++.-------->>>]+[-<<<]>>>[>>,----------[>]<]<<[ + <<<[ + >--[<->>+>-<<-]<[[>>>]+>-[+>>+>-]+[<<<]<-]>++>[<+>-] + >[[>>>]+[<<<]>>>-]+[->>>]<-[++>]>[------<]>+++[<<<]> + ]< + ]>[ + -[+>>+>-]+>>+>>>+>[<<<]>->+>[ + >[->+>+++>>++[>>>]+++<<<++<<<++[>>>]>>>]<<<[>[>>>]+>>>] + <<<<<<<[<<++<+[-<<<+]->++>>>++>>>++<<<<]<<<+[-<<<+]+>->>->> + ]<<+<<+<<<+<<-[+<+<<-]+<+[ + ->+>[-<-<<[<<<]>[>>[>>>]<<+<[<<<]>-]] + <[<[<[<<<]>+>>[>>>]<<-]<[<<<]]>>>->>>[>>>]+> + ]>+[-<<[-]<]-[ + [>>>]<[<<[<<<]>>>>>+>[>>>]<-]>>>[>[>>>]<<<<+>[<<<]>>-]> + ]<<<<<<[---<-----[-[-[<->>+++<+++++++[-]]]]<+<+]> + ]>> +] + +[This program simulates the Game of Life cellular automaton. + +Type e.g. "be" to toggle the fifth cell in the second row, "q" to quit, +or a bare linefeed to advance one generation. + +Grid wraps toroidally. Board size in parentheses in first line (2-166 work). + +This program is licensed under a Creative Commons Attribution-ShareAlike 4.0 +International License (http://creativecommons.org/licenses/by-sa/4.0/).] +""" + + +LEVELS = {"Python": PYTHON_CODE, "XML": XML_CODE, "BF": BF_CODE} + + +class Tile(containers.Vertical): + """An individual tile in the puzzle. + + A Tile is a container with a static inside it. + The static contains the code (as a Rich Syntax object), scrolled so the + relevant portion is visible. + """ + + DEFAULT_CSS = """ + Tile { + position: absolute; + Static { + width: auto; + height: auto; + &:hover { tint: $primary 30%; } + } + &#blank { visibility: hidden; } + } + """ + + position: reactive[Offset] = reactive(Offset) + + def __init__( + self, + renderable: ConsoleRenderable, + tile: int | None, + size: Size, + position: Offset, + ) -> None: + self.renderable = renderable + self.tile = tile + self.tile_size = size + self.start_position = position + + super().__init__(id="blank" if tile is None else f"tile{self.tile}") + self.set_reactive(Tile.position, position) + + def compose(self) -> ComposeResult: + static = Static( + self.renderable, + classes="tile", + name="blank" if self.tile is None else str(self.tile), + ) + assert self.parent is not None + static.styles.width = self.parent.styles.width + static.styles.height = self.parent.styles.height + yield static + + def on_mount(self) -> None: + if self.tile is not None: + width, height = self.tile_size + self.styles.width = width + self.styles.height = height + column, row = self.position + self.set_scroll(column * width, row * height) + self.offset = self.position * self.tile_size + + def watch_position(self, position: Offset) -> None: + """The 'position' is in tile coordinate. + When it changes we animate it to the cell coordinates.""" + self.animate("offset", position * self.tile_size, duration=0.2) + + +class GameDialog(containers.VerticalGroup): + """A dialog to ask the user for the initial game parameters.""" + + DEFAULT_CSS = """ + GameDialog { + background: $boost; + border: thick $primary-muted; + padding: 0 2; + width: 50; + #values { + width: 1fr; + Select { margin: 1 0;} + } + Button { + margin: 0 1 1 1; + width: 1fr; + } + } + """ + + def compose(self) -> ComposeResult: + with containers.VerticalGroup(id="values"): + yield Select.from_values( + LEVELS.keys(), + prompt="Language", + value="Python", + id="language", + allow_blank=False, + ) + yield Select( + [ + ("Easy (3x3)", (3, 3)), + ("Medium (4x4)", (4, 4)), + ("Hard (5x5)", (5, 5)), + ], + prompt="Level", + value=(4, 4), + id="level", + allow_blank=False, + ) + yield Button("Start", variant="primary") + + @on(Button.Pressed) + def on_button_pressed(self) -> None: + language = self.query_one("#language", Select).selection + level = self.query_one("#level", Select).selection + assert language is not None and level is not None + self.screen.dismiss(NewGame(language, LEVELS[language], level)) + + +class GameDialogScreen(ModalScreen): + """Modal screen containing the dialog.""" + + CSS = """ + GameDialogScreen { + align: center middle; + } + """ + + BINDINGS = [("escape", "dismiss")] + + def compose(self) -> ComposeResult: + yield GameDialog() + + +class Game(containers.Vertical, can_focus=True): + """Widget for the game board.""" + + ALLOW_MAXIMIZE = False + DEFAULT_CSS = """ + Game { + visibility: hidden; + align: center middle; + hatch: right $panel; + border: heavy transparent; + &:focus { + border: heavy $success; + } + #grid { + border: heavy $primary; + hatch: right $panel; + box-sizing: content-box; + } + Digits { + width: auto; + color: $foreground; + } + } + """ + + BINDINGS = [ + Binding("up", "move('up')", "☝️", priority=True), + Binding("down", "move('down')", "👇", priority=True), + Binding("left", "move('left')", "👈", priority=True), + Binding("right", "move('right')", "👉", priority=True), + ] + + state = reactive("waiting") + play_start_time: reactive[float] = reactive(monotonic) + play_time = reactive(0.0, init=False) + code = reactive("") + dimensions = reactive(Size(3, 3)) + code = reactive("") + language = reactive("") + + def __init__( + self, + code: str, + language: str, + dimensions: tuple[int, int], + tile_size: tuple[int, int], + ) -> None: + self.set_reactive(Game.code, code) + self.set_reactive(Game.language, language) + self.locations: defaultdict[Offset, int | None] = defaultdict(None) + super().__init__() + self.dimensions = Size(*dimensions) + self.tile_size = Size(*tile_size) + self.play_timer: Timer | None = None + + def check_win(self) -> bool: + return all(tile.start_position == tile.position for tile in self.query(Tile)) + + def watch_dimensions(self, dimensions: Size) -> None: + self.locations.clear() + tile_width, tile_height = dimensions + for last, tile_no in loop_last(range(0, tile_width * tile_height)): + position = Offset(*divmod(tile_no, tile_width)) + self.locations[position] = None if last else tile_no + + def compose(self) -> ComposeResult: + syntax = Syntax( + self.code, + self.language.lower(), + indent_guides=True, + line_numbers=True, + theme="material", + ) + tile_width, tile_height = self.dimensions + self.state = "waiting" + yield Digits("") + with containers.HorizontalGroup(id="grid") as grid: + grid.styles.width = tile_width * self.tile_size[0] + grid.styles.height = tile_height * self.tile_size[1] + for row, column in product(range(tile_width), range(tile_height)): + position = Offset(row, column) + tile_no = self.locations[position] + yield Tile(syntax, tile_no, self.tile_size, position) + if self.language: + self.call_after_refresh(self.shuffle) + + def update_clock(self) -> None: + if self.state == "playing": + elapsed = monotonic() - self.play_start_time + self.play_time = elapsed + + def watch_play_time(self, play_time: float) -> None: + minutes, seconds = divmod(play_time, 60) + hours, minutes = divmod(minutes, 60) + self.query_one(Digits).update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:04.1f}") + + def watch_state(self, old_state: str, new_state: str) -> None: + if self.play_timer is not None: + self.play_timer.stop() + + if new_state == "playing": + self.play_start_time = monotonic() + self.play_timer = self.set_interval(1 / 10, self.update_clock) + + def get_tile(self, tile: int | None) -> Tile: + """Get a tile (int) or the blank (None).""" + return self.query_one("#blank" if tile is None else f"#tile{tile}", Tile) + + def get_tile_at(self, position: Offset) -> Tile: + """Get a tile at the given position, or raise an IndexError.""" + if position not in self.locations: + raise IndexError("No tile") + return self.get_tile(self.locations[position]) + + def move_tile(self, tile_no: int | None) -> None: + """Move a tile to the blank. + Note: this doesn't do any validation of legal moves. + """ + tile = self.get_tile(tile_no) + blank = self.get_tile(None) + blank_position = blank.position + + self.locations[tile.position] = None + blank.position = tile.position + + self.locations[blank_position] = tile_no + tile.position = blank_position + + if self.state == "playing" and self.check_win(): + self.state = "won" + self.notify("You won!", title="Sliding Tile Puzzle") + + def can_move(self, tile: int) -> bool: + """Check if a tile may move.""" + blank_position = self.get_tile(None).position + tile_position = self.get_tile(tile).position + return blank_position in ( + tile_position + (1, 0), + tile_position - (1, 0), + tile_position + (0, 1), + tile_position - (0, 1), + ) + + def action_move(self, direction: str) -> None: + if self.state != "playing": + self.app.bell() + return + blank = self.get_tile(None).position + if direction == "up": + position = blank + (0, -1) + elif direction == "down": + position = blank + (0, +1) + elif direction == "left": + position = blank + (-1, 0) + elif direction == "right": + position = blank + (+1, 0) + try: + tile = self.get_tile_at(position) + except IndexError: + return + self.move_tile(tile.tile) + + def get_legal_moves(self) -> set[Offset]: + """Get the positions of all tiles that can move.""" + blank = self.get_tile(None).position + moves: list[Offset] = [] + + DIRECTIONS = [(-1, 0), (+1, -0), (0, -1), (0, +1)] + moves = [ + blank + direction + for direction in DIRECTIONS + if (blank + direction) in self.locations + ] + return {self.get_tile_at(position).position for position in moves} + + @work(exclusive=True) + async def shuffle(self, shuffles: int = 150) -> None: + """A worker to do the shuffling.""" + self.visible = True + if self.play_timer is not None: + self.play_timer.stop() + self.query_one("#grid").border_title = "[reverse bold] SHUFFLING - Please Wait " + self.state = "shuffling" + previous_move: Offset = Offset(-1, -1) + for _ in range(shuffles): + legal_moves = self.get_legal_moves() + legal_moves.discard(previous_move) + previous_move = self.get_tile(None).position + move_position = choice(list(legal_moves)) + move_tile = self.get_tile_at(move_position) + self.move_tile(move_tile.tile) + await sleep(0.05) + self.query_one("#grid").border_title = "" + self.state = "playing" + + @on(events.Click, ".tile") + def on_tile_clicked(self, event: events.Click) -> None: + assert event.widget is not None + tile = int(event.widget.name or 0) + if self.state != "playing" or not self.can_move(tile): + self.app.bell() + return + self.move_tile(tile) + + +class GameScreen(PageScreen): + """The screen containing the game.""" + + BINDINGS = [ + ("s", "shuffle", "Shuffle"), + ("n", "new_game", "New Game"), + ] + + def compose(self) -> ComposeResult: + yield Game("\n" * 100, "", dimensions=(4, 4), tile_size=(16, 8)) + yield Footer() + + def action_shuffle(self) -> None: + self.query_one(Game).shuffle() + + def action_new_game(self) -> None: + self.app.push_screen(GameDialogScreen(), callback=self.new_game) + + async def new_game(self, new_game: NewGame | None) -> None: + if new_game is None: + return + game = self.query_one(Game) + game.state = "waiting" + game.code = new_game.code + game.language = new_game.language + game.dimensions = Size(*new_game.size) + await game.recompose() + game.focus() + + def on_mount(self) -> None: + self.action_new_game() + + +if __name__ == "__main__": + from textual.app import App + + class GameApp(App): + def get_default_screen(self) -> Screen: + return GameScreen() + + app = GameApp() + app.run() diff --git a/src/textual/demo/projects.py b/src/textual/demo/projects.py index a8e5c4406b..e264fd7143 100644 --- a/src/textual/demo/projects.py +++ b/src/textual/demo/projects.py @@ -222,3 +222,14 @@ def compose(self) -> ComposeResult: for project in PROJECTS: yield Project(project) yield Footer() + + +if __name__ == "__main__": + from textual.app import App + + class GameApp(App): + def get_default_screen(self) -> Screen: + return ProjectsScreen() + + app = GameApp() + app.run() diff --git a/src/textual/demo/widgets.py b/src/textual/demo/widgets.py index ead81194e5..fe7eaf8eff 100644 --- a/src/textual/demo/widgets.py +++ b/src/textual/demo/widgets.py @@ -300,12 +300,12 @@ class Logs(containers.VerticalGroup): Logs { Log, RichLog { width: 1fr; - height: 20; - border: blank; - padding: 0; + height: 20; + padding: 1; overflow-x: auto; + border: wide transparent; &:focus { - border: heavy $accent; + border: wide $border; } } TabPane { padding: 0; } @@ -674,11 +674,13 @@ class Trees(containers.VerticalGroup): Trees { Tree { height: 16; - &.-maximized { height: 1fr; } + padding: 1; + &.-maximized { height: 1fr; } + border: wide transparent; + &:focus { border: wide $border; } } VerticalGroup { - border: heavy transparent; - &:focus-within { border: heavy $border; } + } } @@ -806,3 +808,14 @@ def compose(self) -> ComposeResult: yield Trees() yield YourWidgets() yield Footer() + + +if __name__ == "__main__": + from textual.app import App + + class GameApp(App): + def get_default_screen(self) -> Screen: + return WidgetsScreen() + + app = GameApp() + app.run() diff --git a/src/textual/events.py b/src/textual/events.py index 6e7a21e8da..fd4dea3edd 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -567,7 +567,7 @@ class Timer(Event, bubble=False, verbose=True): - [X] Verbose """ - __slots__ = ["time", "count", "callback"] + __slots__ = ["timer", "time", "count", "callback"] def __init__( self, diff --git a/src/textual/geometry.py b/src/textual/geometry.py index b91fc8bf06..e6d190f939 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -126,6 +126,9 @@ def __mul__(self, other: object) -> Offset: if isinstance(other, (float, int)): x, y = self return Offset(int(x * other), int(y * other)) + if isinstance(other, tuple): + x, y = self + return Offset(int(x * other[0]), int(y * other[1])) return NotImplemented def __neg__(self) -> Offset: diff --git a/src/textual/layout.py b/src/textual/layout.py index c06bda3c5b..a3ef9ec006 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -91,6 +91,12 @@ class WidgetPlacement(NamedTuple): order: int = 0 fixed: bool = False overlay: bool = False + absolute: bool = False + + @property + def reset_origin(self) -> WidgetPlacement: + """Reset the origin in the placement (moves it to (0, 0)).""" + return self._replace(region=self.region.reset_offset) @classmethod def translate( @@ -119,11 +125,23 @@ def translate( order, fixed, overlay, + absolute, ) - for region, offset, margin, layout_widget, order, fixed, overlay in placements + for region, offset, margin, layout_widget, order, fixed, overlay, absolute in placements ] return placements + @classmethod + def apply_absolute(cls, placements: list[WidgetPlacement]) -> None: + """Applies absolute offsets (in place). + + Args: + placements: A list of placements. + """ + for index, placement in enumerate(placements): + if placement.absolute: + placements[index] = placement.reset_origin + @classmethod def get_bounds(cls, placements: Iterable[WidgetPlacement]) -> Region: """Get a bounding region around all placements. @@ -174,9 +192,9 @@ def process_offset( offset = region.offset - self.region.offset if offset != self.offset: - region, _offset, margin, widget, order, fixed, overlay = self + region, _offset, margin, widget, order, fixed, overlay, absolute = self placement = WidgetPlacement( - region, offset, margin, widget, order, fixed, overlay + region, offset, margin, widget, order, fixed, overlay, absolute ) return placement return self diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index c6217b54d0..d63c32123f 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -287,7 +287,7 @@ def apply_height_limits(widget: Widget, height: int) -> int: ) .crop_size(cell_size) .shrink(margin) - ) + ) + offset placement_offset = ( styles.offset.resolve(cell_size, viewport) @@ -296,8 +296,8 @@ def apply_height_limits(widget: Widget, height: int) -> int: ) add_placement( - WidgetPlacement( - region + offset, + _WidgetPlacement( + region, placement_offset, ( margin @@ -305,6 +305,7 @@ def apply_height_limits(widget: Widget, height: int) -> int: else margin.grow_maximum(gutter_spacing) ), widget, + styles.has_rule("position") and styles.position == "absolute", ) ) diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index 033e2b415a..660ce86053 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -22,6 +22,7 @@ class HorizontalLayout(Layout): def arrange( self, parent: Widget, children: list[Widget], size: Size ) -> ArrangeResult: + parent.pre_layout(self) placements: list[WidgetPlacement] = [] add_placement = placements.append viewport = parent.app.size @@ -94,23 +95,26 @@ def arrange( offset_y = box_margin.top next_x = x + content_width + region = _Region( + x.__floor__(), + offset_y, + (next_x - x.__floor__()).__floor__(), + content_height.__floor__(), + ) + absolute = styles.has_rule("position") and styles.position == "absolute" add_placement( _WidgetPlacement( - _Region( - x.__floor__(), - offset_y, - (next_x - x.__floor__()).__floor__(), - content_height.__floor__(), - ), + region, offset, box_margin, widget, 0, False, overlay, + absolute, ) ) - if not overlay: + if not overlay and not absolute: x = next_x + margin return placements diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 5691dfda02..91b06bab09 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -20,6 +20,7 @@ class VerticalLayout(Layout): def arrange( self, parent: Widget, children: list[Widget], size: Size ) -> ArrangeResult: + parent.pre_layout(self) placements: list[WidgetPlacement] = [] add_placement = placements.append viewport = parent.app.size @@ -97,23 +98,27 @@ def arrange( else NULL_OFFSET ) + region = _Region( + box_margin.left, + y.__floor__(), + content_width.__floor__(), + next_y.__floor__() - y.__floor__(), + ) + + absolute = styles.has_rule("position") and styles.position == "absolute" add_placement( _WidgetPlacement( - _Region( - box_margin.left, - y.__floor__(), - content_width.__floor__(), - next_y.__floor__() - y.__floor__(), - ), + region, offset, box_margin, widget, 0, False, overlay, + absolute, ) ) - if not overlay: + if not overlay and not absolute: y = next_y + margin return placements diff --git a/src/textual/widget.py b/src/textual/widget.py index 827288b042..aa2a081645 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2446,6 +2446,21 @@ def pre_layout(self, layout: Layout) -> None: """ + def set_scroll(self, x: float | None, y: float | None) -> None: + """Set the scroll position without any validation. + + This is a low-level method for when you want to see the scroll position in the next frame. + For a more fully featured method, see [`scroll_to`][textual.widget.Widget.scroll_to]. + + Args: + x: Desired `X` coordinate. + y: Desired `Y` coordinate. + """ + if x is not None: + self.set_reactive(Widget.scroll_x, round(x)) + if y is not None: + self.set_reactive(Widget.scroll_y, round(y)) + def scroll_to( self, x: float | None = None, diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index dbaac1b94f..76bfd07415 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -295,7 +295,7 @@ def __init__( """A dictionary of option IDs and the option indexes they relate to.""" self._content_render_cache: LRUCache[tuple[int, str, int], list[Strip]] - self._content_render_cache = LRUCache(256) + self._content_render_cache = LRUCache(1024) self._lines: list[tuple[int, int]] | None = None self._spans: list[OptionLineSpan] | None = None @@ -361,8 +361,6 @@ def _add_lines( else: self._lines.append(OptionLineSpan(-1, 0)) - self._populate() - self.virtual_size = Size(width, len(self._lines)) def _populate(self) -> None: @@ -376,7 +374,7 @@ def _populate(self) -> None: self._contents, self.scrollable_content_region.width - self._left_gutter_width(), ) - self.refresh() + self.refresh(layout=True) def get_content_width(self, container: Size, viewport: Size) -> int: """Get maximum width of options.""" @@ -548,7 +546,7 @@ def add_options(self, items: Iterable[NewOptionListContent]) -> Self: self.scrollable_content_region.width - self._left_gutter_width(), option_index=option_index, ) - self.refresh() + self.refresh(layout=True) return self def add_option(self, item: NewOptionListContent = None) -> Self: diff --git a/src/textual/widgets/_select.py b/src/textual/widgets/_select.py index 7698a780f1..3bb8e18e4e 100644 --- a/src/textual/widgets/_select.py +++ b/src/textual/widgets/_select.py @@ -378,6 +378,18 @@ def from_values( disabled=disabled, ) + @property + def selection(self) -> SelectType | None: + """The currently selected item. + + Unlike [value][textual.widgets.Select.value], this will not return Blanks. + If nothing is selected, this will return `None`. + + """ + value = self.value + assert not isinstance(value, NoSelection) + return value + def _setup_variables_for_options( self, options: Iterable[tuple[RenderableType, SelectType]], diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[position.py].svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[position.py].svg new file mode 100644 index 0000000000..5bcca5fc0e --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_css_property[position.py].svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PositionApp + + + + + + + + + + +█▀▀▀▀▀▀▀▀▀▀█ + +Absolute + +█▄▄▄▄▄▄▄▄▄▄█ + + + + +█▀▀▀▀▀▀▀▀▀▀█ + +Relative + +█▄▄▄▄▄▄▄▄▄▄█ + + + + + + + + + + + + diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_position_absolute.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_position_absolute.svg new file mode 100644 index 0000000000..22b7e8a190 --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_position_absolute.svg @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + AbsoluteApp + + + + + + + + + + + Absolute 1                                                                      +  Absolute 2                                                                     +   Absolute 3                                                                    + + + + + + + +                                    Relative 1                                   + +                                     Relative 2                                  + +                                      Relative 3                                 + + + + + + + + + + + diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index fc8b9c7bd3..cf4f6e849c 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -2637,3 +2637,47 @@ async def run_before(pilot: Pilot) -> None: await pilot.click(Select) assert snap_compare(OApp(), run_before=run_before) + + +def test_position_absolute(snap_compare): + """Check position: absolute works as expected. + You should see three staggered labels at the top-left, and three staggered relative labels in the center. + The relative labels will have an additional line between them. + """ + + class AbsoluteApp(App): + CSS = """ + Screen { + align: center middle; + + .absolute { + position: absolute; + } + + .relative { + position: relative; + } + + .offset1 { + offset: 1 1; + } + .offset2 { + offset: 2 2; + } + .offset3 { + offset: 3 3; + } + } + + """ + + def compose(self) -> ComposeResult: + yield Label("Absolute 1", classes="absolute offset1") + yield Label("Absolute 2", classes="absolute offset2") + yield Label("Absolute 3", classes="absolute offset3") + + yield Label("Relative 1", classes="relative offset1") + yield Label("Relative 2", classes="relative offset2") + yield Label("Relative 3", classes="relative offset3") + + assert snap_compare(AbsoluteApp())