Skip to content

The canvas will now redraw if styles change #14

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Apr 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/examples/clear_pixel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from textual.app import App, ComposeResult
from textual.color import Color

from textual_canvas import Canvas


class ClearPixelApp(App[None]):
CSS = """
Canvas {
background: $panel;
color: blue;
}
"""

def compose(self) -> ComposeResult:
yield Canvas(30, 30, Color.parse("cornflowerblue"))

def on_mount(self) -> None:
self.query_one(Canvas).draw_line(10, 10, 15, 10).clear_pixel(12, 10)


if __name__ == "__main__":
ClearPixelApp().run()
29 changes: 29 additions & 0 deletions docs/examples/clear_pixels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from textual.app import App, ComposeResult
from textual.color import Color

from textual_canvas import Canvas


class ClearPixelsApp(App[None]):
CSS = """
Canvas {
background: $panel;
color: blue;
}
"""

def compose(self) -> ComposeResult:
yield Canvas(30, 30, Color.parse("cornflowerblue"))

def on_mount(self) -> None:
self.query_one(Canvas).draw_line(10, 10, 16, 10).clear_pixels(
(
(11, 10),
(13, 10),
(15, 10),
)
)


if __name__ == "__main__":
ClearPixelsApp().run()
53 changes: 35 additions & 18 deletions docs/source/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,9 @@ style this with CSS just as you always would. But the canvas itself -- the
area that you'll be drawing in inside the widget -- can have its own
background colour.

By default the canvas background colour will be set to the widget's
background colour; but you can pass
[`canvas_color`][textual_canvas.canvas.Canvas] as a parameter to change
this.
By default the canvas background colour will be the widget's background
colour; but you can pass [`canvas_color`][textual_canvas.canvas.Canvas] as a
parameter to change this.

To illustrate, here is a `Canvas` widget where no background colour is
specified, so the canvas background and the widget background are the same:
Expand Down Expand Up @@ -96,13 +95,6 @@ background colour when we create it:

1. Note how `Canvas` is given its own background colour.

!!! note

The defaulting of the canvas background to the widget's background is
something that only happens when the widget is mounted. If you style the
widget's background differently later on, the canvas' background **will
not change accordingly**.

#### The pen colour

The `Canvas` widget has a "pen" colour; any time a drawing operation is
Expand All @@ -111,13 +103,6 @@ default that colour is taken from the
[`color`](https://textual.textualize.io/styles/color/) styling of the
widget.

!!! note

The defaulting of the pen colour to the widget's colour is
something that only happens when the widget is mounted. If you style the
widget's `color` differently later on, the canvas' pen colour **will
not change accordingly**.

## Drawing on the canvas

The canvas widget provides a number of methods for drawing on it.
Expand Down Expand Up @@ -221,6 +206,38 @@ circle on the canvas. For example:
--8<-- "docs/examples/draw_circle.py"
```

### Clearing a single pixel

Use [`clear_pixel`][textual_canvas.Canvas.clear_pixel] to set a pixel's
colour to the canvas' colour. For example:

=== "Clearing a single pixel"

```{.textual path="docs/examples/clear_pixel.py"}
```

=== "clear_pixel.py"

```python
--8<-- "docs/examples/clear_pixel.py"
```

### Clearing multiple pixels

Use [`clear_pixels`][textual_canvas.Canvas.clear_pixels] to set the colour
of multiple pixels to the canvas' colour. For example:

=== "Clearing multiple pixels"

```{.textual path="docs/examples/clear_pixels.py"}
```

=== "clear_pixels.py"

```python
--8<-- "docs/examples/clear_pixels.py"
```

## Further help

You can find more detailed documentation of the API [in the next
Expand Down
13 changes: 6 additions & 7 deletions src/textual_canvas/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ class CanvasTestApp(App[None]):
"""

BINDINGS = [
("r", "clear(255, 0, 0)"),
("g", "clear(0, 255, 0)"),
("b", "clear(0, 0, 255)"),
("r", "canvas(255, 0, 0)"),
("g", "canvas(0, 255, 0)"),
("b", "canvas(0, 0, 255)"),
]

def compose(self) -> ComposeResult:
Expand Down Expand Up @@ -61,10 +61,9 @@ def its_all_dark(self) -> None:

canvas.focus()

def action_clear(self, red: int, green: int, blue: int) -> None:
"""Handle the clear keyboard action."""
self.query_one(Canvas).clear(Color(red, green, blue))
self.its_all_dark()
def action_canvas(self, red: int, green: int, blue: int) -> None:
"""Change the canvas colour."""
self.query_one(Canvas).styles.background = Color(red, green, blue)


if __name__ == "__main__":
Expand Down
76 changes: 45 additions & 31 deletions src/textual_canvas/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ def __init__(
id: The ID of the canvas widget in the DOM.
classes: The CSS classes of the canvas widget.
disabled: Whether the canvas widget is disabled or not.

If `canvas_color` is omitted, the widget's `background` styling will
be used.

If `pen_color` is omitted, the widget's `color` styling will be used.
"""
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
self._width = width
Expand All @@ -76,25 +81,16 @@ def __init__(
"""The background colour of the canvas itself."""
self._pen_colour = pen_color
"""The default pen colour, used when drawing pixels."""
self._the_void: list[Color] = []
"""The final empty line if the last row isn't part of the canvas."""
self._canvas: list[list[Color]] = []
self._canvas: list[list[Color | None]] = self._blank_canvas
"""The canvas itself."""
self.virtual_size = Size(width, ceil(height / 2))

@property
def _blank_canvas(self) -> list[list[Color]]:
def _blank_canvas(self) -> list[list[Color | None]]:
"""A blank canvas."""
canvas_colour = self._canvas_colour or self.styles.background
return [[canvas_colour for _ in range(self.width)] for _ in range(self.height)]

def on_mount(self) -> None:
"""Initialise the widget once the DOM is mounted."""
# Now that we know the background colour, because CSS will have been
# applied, we can create the void line.
self._the_void = [self.styles.background for _ in range(self._width)]
# For the same reason, it's now safe to actually create the canvas.
self._canvas = self._blank_canvas
return [
[self._canvas_colour for _ in range(self.width)] for _ in range(self.height)
]

@property
def width(self) -> int:
Expand All @@ -106,6 +102,10 @@ def height(self) -> int:
"""The height of the canvas in 'pixels'."""
return self._height

def notify_style_update(self) -> None:
self.refresh()
return super().notify_style_update()

def _outwith_the_canvas(self, x: int, y: int) -> bool:
"""Is the location outwith the canvas?

Expand Down Expand Up @@ -145,12 +145,13 @@ def clear(self, color: Color | None = None) -> Self:
making the canvas is used, this in turn becomes the new default
color (and will then be used for subsequent clears, unless
another color is provided).

Explicitly setting the colour to [`None`][None] will set the
canvas colour to whatever the widget's `background` colour is.
"""
if color is not None:
self._canvas_colour = color
self._canvas_colour = color or self._canvas_colour
self._canvas = self._blank_canvas
self.refresh()
return self
return self.refresh()

def set_pen(self, color: Color | None) -> Self:
"""Set the default pen colour.
Expand Down Expand Up @@ -209,12 +210,7 @@ def clear_pixels(self, locations: Iterable[tuple[int, int]]) -> Self:
Note:
The origin of the canvas is the top left corner.
"""
color = self._canvas_colour or self.styles.background
for x, y in locations:
self._pixel_check(x, y)
self._canvas[y][x] = color
self.refresh()
return self
return self.set_pixels(locations, self._canvas_colour)

def set_pixel(self, x: int, y: int, color: Color | None = None) -> Self:
"""Set the colour of a specific pixel on the canvas.
Expand Down Expand Up @@ -264,7 +260,7 @@ def get_pixel(self, x: int, y: int) -> Color:
The origin of the canvas is the top left corner.
"""
self._pixel_check(x, y)
return self._canvas[y][x]
return self._canvas[y][x] or self.styles.background

def draw_line(
self, x0: int, y0: int, x1: int, y1: int, color: Color | None = None
Expand Down Expand Up @@ -431,7 +427,7 @@ def render_line(self, y: int) -> Strip:
y: The line to render.

Returns:
A `Strip` that is the line to render.
A [`Strip`][textual.strip.Strip] that is the line to render.
"""

# Get where we're scrolled to.
Expand All @@ -446,24 +442,42 @@ def render_line(self, y: int) -> Strip:
# Yup. Don't bother drawing anything.
return Strip([])

# Set up the two main background colours we need.
background_colour = self.styles.background
canvas_colour = self._canvas_colour or background_colour

# Reduce some attribute lookups.
height = self._height
width = self._width
canvas = self._canvas

# Now, the bottom line is easy enough to work out.
bottom_line = top_line + 1

# Get the pixel values for the top line.
top_pixels = self._canvas[top_line]
top_pixels = canvas[top_line]

# It's possible that the bottom line might be in the void, so...
# It's possible that the bottom line might be outwith the canvas
# itself; so here we set the bottom line to the widget's background
# colour if it is, otherwise we use the line form the canvas.
bottom_pixels = (
self._the_void if bottom_line >= self.height else self._canvas[bottom_line]
[background_colour for _ in range(width)]
if bottom_line >= height
else canvas[bottom_line]
)

# At this point we know what colours we're going to be mashing
# together into the terminal line we're drawing. So let's get to it.
# Note that in every case, if the colour we have is `None` that
# means we're using the canvas colour.
return (
Strip(
[
self._segment_of(top_pixels[pixel], bottom_pixels[pixel])
for pixel in range(self.width)
self._segment_of(
top_pixels[pixel] or canvas_colour,
bottom_pixels[pixel] or canvas_colour,
)
for pixel in range(width)
]
)
.crop(scroll_x, scroll_x + self.scrollable_content_region.width)
Expand Down
Loading