diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index 4d4e489ee..08010d397 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -9,7 +9,6 @@ from .geometry import Region, Size, Spacing from ._layout import DockArrangeResult, WidgetPlacement from ._partition import partition - if TYPE_CHECKING: from .widget import Widget @@ -115,7 +114,7 @@ def arrange( for placement in layout_placements ] ).size - placement_offset += styles._align_size(placement_size, size) + placement_offset += styles._align_size(placement_size, size).clamped if placement_offset: layout_placements = [ diff --git a/src/textual/app.py b/src/textual/app.py index 8b452dc56..8141cb3c7 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -439,13 +439,8 @@ class App(Generic[ReturnType], DOMNode): """Watches the dark bool.""" self.screen.dark = dark - if dark: - self.add_class("-dark-mode") - self.remove_class("-light-mode") - else: - self.remove_class("-dark-mode") - self.add_class("-light-mode") - + self.set_class(dark, "-dark-mode") + self.set_class(not dark, "-light-mode") self.refresh_css() def get_driver_class(self) -> Type[Driver]: @@ -1000,12 +995,13 @@ class App(Generic[ReturnType], DOMNode): Args: widget (Widget): A widget that is removed. """ - for sibling in widget.siblings: - if sibling.can_focus: - sibling.focus() - break - else: - self.focused = None + if self.focused is widget: + for sibling in widget.siblings: + if sibling.can_focus: + sibling.focus() + break + else: + self.focused = None async def _set_mouse_over(self, widget: Widget | None) -> None: """Called when the mouse is over another widget. diff --git a/src/textual/cli/cli.py b/src/textual/cli/cli.py index 13e167411..509df361d 100644 --- a/src/textual/cli/cli.py +++ b/src/textual/cli/cli.py @@ -111,3 +111,11 @@ def easing(): from textual.cli.previews import easing easing.app.run() + + +@run.command("colors") +def colors(): + """Explore the design system.""" + from textual.cli.previews import colors + + colors.app.run() diff --git a/src/textual/cli/previews/colors.css b/src/textual/cli/previews/colors.css new file mode 100644 index 000000000..f4203933d --- /dev/null +++ b/src/textual/cli/previews/colors.css @@ -0,0 +1,71 @@ +ColorButtons { + dock: left; + overflow-y: auto; + width: 30; +} + +ColorButtons > Button { + width: 30; +} + +ColorsView { + width: 100%; + height: 100%; + align: center middle; + overflow-x: auto; + background: $background; + scrollbar-gutter: stable; +} + +ColorItem { + layout: horizontal; + height: 3; + width: 1fr; +} + +ColorBar { + height: auto; + width: 1fr; + content-align: center middle; +} + + +ColorItem { + width: 100%; + padding: 1 2; +} + +ColorGroup { + margin: 2 0; + width: 110; + height: auto; + padding: 2 4; + background: $surface; + border: wide $surface; +} + + +ColorGroup.-active { + border: wide $secondary; +} + +.text { + color: $text; +} + +.muted { + color: $text-muted; +} + + +.disabled { + color: $text-disabled; +} + + +ColorLabel { + padding: 1 0; + content-align: center middle; + color: $text; + text-style: bold; +} diff --git a/src/textual/cli/previews/colors.py b/src/textual/cli/previews/colors.py new file mode 100644 index 000000000..1ab4e745b --- /dev/null +++ b/src/textual/cli/previews/colors.py @@ -0,0 +1,92 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal, Vertical +from textual.reactive import var +from textual.widgets import Button, Static, Footer + +from textual.design import ColorSystem + + +class ColorButtons(Vertical): + def compose(self) -> ComposeResult: + for border in ColorSystem.COLOR_NAMES: + if border: + yield Button(border, id=border) + + +class ColorBar(Static): + pass + + +class ColorItem(Horizontal): + pass + + +class ColorGroup(Vertical): + pass + + +class Content(Vertical): + pass + + +class ColorLabel(Static): + pass + + +class ColorsView(Vertical): + def compose(self) -> ComposeResult: + + LEVELS = [ + "darken-3", + "darken-2", + "darken-1", + "", + "lighten-1", + "lighten-2", + "lighten-3", + ] + + variables = self.app.stylesheet._variables + for color_name in ColorSystem.COLOR_NAMES: + + items = [ColorLabel(f'"{color_name}"')] + for level in LEVELS: + color = f"{color_name}-{level}" if level else color_name + item = ColorItem( + ColorBar(f"${color}", classes="text"), + ColorBar(f"$text", classes="text"), + ColorBar(f"$text-muted", classes="muted"), + ColorBar(f"$text-disabled", classes="disabled"), + ) + item.styles.background = variables[color] + items.append(item) + + yield ColorGroup(*items, id=f"group-{color_name}") + + +class ColorsApp(App): + CSS_PATH = "colors.css" + + BINDINGS = [("d", "toggle_dark", "Toggle dark mode")] + + def compose(self) -> ComposeResult: + yield Content(ColorButtons(), ColorsView()) + yield Footer() + + def on_button_pressed(self, event: Button.Pressed) -> None: + self.query(ColorGroup).remove_class("-active") + group = self.query_one(f"#group-{event.button.id}", ColorGroup) + group.add_class("-active") + group.scroll_visible(speed=150) + + def action_toggle_dark(self) -> None: + content = self.query_one("Content", Content) + self.dark = not self.dark + content.mount(ColorsView()) + content.query("ColorsView").first().remove() + + +app = ColorsApp() + +if __name__ == "__main__": + app.run() diff --git a/src/textual/geometry.py b/src/textual/geometry.py index e6dac8220..62c860fec 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -68,6 +68,16 @@ class Offset(NamedTuple): """ return self == (0, 0) + @property + def clamped(self) -> Offset: + """Ensure x and y are above zero. + + Returns: + Offset: New offset. + """ + x, y = self + return Offset(max(x, 0), max(y, 0)) + def __bool__(self) -> bool: return self != (0, 0) diff --git a/src/textual/screen.py b/src/textual/screen.py index e3c1787cb..39780d5b9 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -80,9 +80,6 @@ class Screen(Widget): """Get a list of visible widgets.""" return list(self._compositor.visible_widgets) - def watch_dark(self, dark: bool) -> None: - pass - def render(self) -> RenderableType: background = self.styles.background if background.is_transparent: diff --git a/src/textual/widget.py b/src/textual/widget.py index 43f48fa40..32c2324e3 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1739,6 +1739,7 @@ class Widget(DOMNode): def remove(self) -> None: """Remove the Widget from the DOM (effectively deleting it)""" + self.display = False self.app.post_message_no_wait(events.Remove(self, widget=self)) def render(self) -> RenderableType: diff --git a/tests/test_geometry.py b/tests/test_geometry.py index 766dc703f..02cac2fa0 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -75,6 +75,13 @@ def test_offset_is_origin(): assert not Offset(1, 0).is_origin +def test_clamped(): + assert Offset(-10, 0).clamped == Offset(0, 0) + assert Offset(-10, -5).clamped == Offset(0, 0) + assert Offset(5, -5).clamped == Offset(5, 0) + assert Offset(5, 10).clamped == Offset(5, 10) + + def test_offset_add(): assert Offset(1, 1) + Offset(2, 2) == Offset(3, 3) assert Offset(1, 2) + Offset(3, 4) == Offset(4, 6)