Skip to content

Commit f01db1c

Browse files
authored
🔀 Merge pull request #14 from davep/observe-css
The canvas will now redraw if styles change
2 parents 5d2c2b9 + c562565 commit f01db1c

File tree

5 files changed

+138
-56
lines changed

5 files changed

+138
-56
lines changed

docs/examples/clear_pixel.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from textual.app import App, ComposeResult
2+
from textual.color import Color
3+
4+
from textual_canvas import Canvas
5+
6+
7+
class ClearPixelApp(App[None]):
8+
CSS = """
9+
Canvas {
10+
background: $panel;
11+
color: blue;
12+
}
13+
"""
14+
15+
def compose(self) -> ComposeResult:
16+
yield Canvas(30, 30, Color.parse("cornflowerblue"))
17+
18+
def on_mount(self) -> None:
19+
self.query_one(Canvas).draw_line(10, 10, 15, 10).clear_pixel(12, 10)
20+
21+
22+
if __name__ == "__main__":
23+
ClearPixelApp().run()

docs/examples/clear_pixels.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from textual.app import App, ComposeResult
2+
from textual.color import Color
3+
4+
from textual_canvas import Canvas
5+
6+
7+
class ClearPixelsApp(App[None]):
8+
CSS = """
9+
Canvas {
10+
background: $panel;
11+
color: blue;
12+
}
13+
"""
14+
15+
def compose(self) -> ComposeResult:
16+
yield Canvas(30, 30, Color.parse("cornflowerblue"))
17+
18+
def on_mount(self) -> None:
19+
self.query_one(Canvas).draw_line(10, 10, 16, 10).clear_pixels(
20+
(
21+
(11, 10),
22+
(13, 10),
23+
(15, 10),
24+
)
25+
)
26+
27+
28+
if __name__ == "__main__":
29+
ClearPixelsApp().run()

docs/source/guide.md

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,9 @@ style this with CSS just as you always would. But the canvas itself -- the
5555
area that you'll be drawing in inside the widget -- can have its own
5656
background colour.
5757

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

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

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

99-
!!! note
100-
101-
The defaulting of the canvas background to the widget's background is
102-
something that only happens when the widget is mounted. If you style the
103-
widget's background differently later on, the canvas' background **will
104-
not change accordingly**.
105-
10698
#### The pen colour
10799

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

114-
!!! note
115-
116-
The defaulting of the pen colour to the widget's colour is
117-
something that only happens when the widget is mounted. If you style the
118-
widget's `color` differently later on, the canvas' pen colour **will
119-
not change accordingly**.
120-
121106
## Drawing on the canvas
122107

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

209+
### Clearing a single pixel
210+
211+
Use [`clear_pixel`][textual_canvas.Canvas.clear_pixel] to set a pixel's
212+
colour to the canvas' colour. For example:
213+
214+
=== "Clearing a single pixel"
215+
216+
```{.textual path="docs/examples/clear_pixel.py"}
217+
```
218+
219+
=== "clear_pixel.py"
220+
221+
```python
222+
--8<-- "docs/examples/clear_pixel.py"
223+
```
224+
225+
### Clearing multiple pixels
226+
227+
Use [`clear_pixels`][textual_canvas.Canvas.clear_pixels] to set the colour
228+
of multiple pixels to the canvas' colour. For example:
229+
230+
=== "Clearing multiple pixels"
231+
232+
```{.textual path="docs/examples/clear_pixels.py"}
233+
```
234+
235+
=== "clear_pixels.py"
236+
237+
```python
238+
--8<-- "docs/examples/clear_pixels.py"
239+
```
240+
224241
## Further help
225242

226243
You can find more detailed documentation of the API [in the next

src/textual_canvas/__main__.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ class CanvasTestApp(App[None]):
2929
"""
3030

3131
BINDINGS = [
32-
("r", "clear(255, 0, 0)"),
33-
("g", "clear(0, 255, 0)"),
34-
("b", "clear(0, 0, 255)"),
32+
("r", "canvas(255, 0, 0)"),
33+
("g", "canvas(0, 255, 0)"),
34+
("b", "canvas(0, 0, 255)"),
3535
]
3636

3737
def compose(self) -> ComposeResult:
@@ -61,10 +61,9 @@ def its_all_dark(self) -> None:
6161

6262
canvas.focus()
6363

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

6968

7069
if __name__ == "__main__":

src/textual_canvas/canvas.py

Lines changed: 45 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ def __init__(
6666
id: The ID of the canvas widget in the DOM.
6767
classes: The CSS classes of the canvas widget.
6868
disabled: Whether the canvas widget is disabled or not.
69+
70+
If `canvas_color` is omitted, the widget's `background` styling will
71+
be used.
72+
73+
If `pen_color` is omitted, the widget's `color` styling will be used.
6974
"""
7075
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
7176
self._width = width
@@ -76,25 +81,16 @@ def __init__(
7681
"""The background colour of the canvas itself."""
7782
self._pen_colour = pen_color
7883
"""The default pen colour, used when drawing pixels."""
79-
self._the_void: list[Color] = []
80-
"""The final empty line if the last row isn't part of the canvas."""
81-
self._canvas: list[list[Color]] = []
84+
self._canvas: list[list[Color | None]] = self._blank_canvas
8285
"""The canvas itself."""
8386
self.virtual_size = Size(width, ceil(height / 2))
8487

8588
@property
86-
def _blank_canvas(self) -> list[list[Color]]:
89+
def _blank_canvas(self) -> list[list[Color | None]]:
8790
"""A blank canvas."""
88-
canvas_colour = self._canvas_colour or self.styles.background
89-
return [[canvas_colour for _ in range(self.width)] for _ in range(self.height)]
90-
91-
def on_mount(self) -> None:
92-
"""Initialise the widget once the DOM is mounted."""
93-
# Now that we know the background colour, because CSS will have been
94-
# applied, we can create the void line.
95-
self._the_void = [self.styles.background for _ in range(self._width)]
96-
# For the same reason, it's now safe to actually create the canvas.
97-
self._canvas = self._blank_canvas
91+
return [
92+
[self._canvas_colour for _ in range(self.width)] for _ in range(self.height)
93+
]
9894

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

105+
def notify_style_update(self) -> None:
106+
self.refresh()
107+
return super().notify_style_update()
108+
109109
def _outwith_the_canvas(self, x: int, y: int) -> bool:
110110
"""Is the location outwith the canvas?
111111
@@ -145,12 +145,13 @@ def clear(self, color: Color | None = None) -> Self:
145145
making the canvas is used, this in turn becomes the new default
146146
color (and will then be used for subsequent clears, unless
147147
another color is provided).
148+
149+
Explicitly setting the colour to [`None`][None] will set the
150+
canvas colour to whatever the widget's `background` colour is.
148151
"""
149-
if color is not None:
150-
self._canvas_colour = color
152+
self._canvas_colour = color or self._canvas_colour
151153
self._canvas = self._blank_canvas
152-
self.refresh()
153-
return self
154+
return self.refresh()
154155

155156
def set_pen(self, color: Color | None) -> Self:
156157
"""Set the default pen colour.
@@ -209,12 +210,7 @@ def clear_pixels(self, locations: Iterable[tuple[int, int]]) -> Self:
209210
Note:
210211
The origin of the canvas is the top left corner.
211212
"""
212-
color = self._canvas_colour or self.styles.background
213-
for x, y in locations:
214-
self._pixel_check(x, y)
215-
self._canvas[y][x] = color
216-
self.refresh()
217-
return self
213+
return self.set_pixels(locations, self._canvas_colour)
218214

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

269265
def draw_line(
270266
self, x0: int, y0: int, x1: int, y1: int, color: Color | None = None
@@ -431,7 +427,7 @@ def render_line(self, y: int) -> Strip:
431427
y: The line to render.
432428
433429
Returns:
434-
A `Strip` that is the line to render.
430+
A [`Strip`][textual.strip.Strip] that is the line to render.
435431
"""
436432

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

445+
# Set up the two main background colours we need.
446+
background_colour = self.styles.background
447+
canvas_colour = self._canvas_colour or background_colour
448+
449+
# Reduce some attribute lookups.
450+
height = self._height
451+
width = self._width
452+
canvas = self._canvas
453+
449454
# Now, the bottom line is easy enough to work out.
450455
bottom_line = top_line + 1
451456

452457
# Get the pixel values for the top line.
453-
top_pixels = self._canvas[top_line]
458+
top_pixels = canvas[top_line]
454459

455-
# It's possible that the bottom line might be in the void, so...
460+
# It's possible that the bottom line might be outwith the canvas
461+
# itself; so here we set the bottom line to the widget's background
462+
# colour if it is, otherwise we use the line form the canvas.
456463
bottom_pixels = (
457-
self._the_void if bottom_line >= self.height else self._canvas[bottom_line]
464+
[background_colour for _ in range(width)]
465+
if bottom_line >= height
466+
else canvas[bottom_line]
458467
)
459468

460469
# At this point we know what colours we're going to be mashing
461470
# together into the terminal line we're drawing. So let's get to it.
471+
# Note that in every case, if the colour we have is `None` that
472+
# means we're using the canvas colour.
462473
return (
463474
Strip(
464475
[
465-
self._segment_of(top_pixels[pixel], bottom_pixels[pixel])
466-
for pixel in range(self.width)
476+
self._segment_of(
477+
top_pixels[pixel] or canvas_colour,
478+
bottom_pixels[pixel] or canvas_colour,
479+
)
480+
for pixel in range(width)
467481
]
468482
)
469483
.crop(scroll_x, scroll_x + self.scrollable_content_region.width)

0 commit comments

Comments
 (0)