diff --git a/docs/examples/clear_pixel.py b/docs/examples/clear_pixel.py new file mode 100644 index 0000000..338c409 --- /dev/null +++ b/docs/examples/clear_pixel.py @@ -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() diff --git a/docs/examples/clear_pixels.py b/docs/examples/clear_pixels.py new file mode 100644 index 0000000..1abea0e --- /dev/null +++ b/docs/examples/clear_pixels.py @@ -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() diff --git a/docs/source/guide.md b/docs/source/guide.md index b1a9d12..08fd49f 100644 --- a/docs/source/guide.md +++ b/docs/source/guide.md @@ -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: @@ -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 @@ -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. @@ -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 diff --git a/src/textual_canvas/__main__.py b/src/textual_canvas/__main__.py index 7720110..8992796 100644 --- a/src/textual_canvas/__main__.py +++ b/src/textual_canvas/__main__.py @@ -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: @@ -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__": diff --git a/src/textual_canvas/canvas.py b/src/textual_canvas/canvas.py index c0af2e0..47b7ba8 100644 --- a/src/textual_canvas/canvas.py +++ b/src/textual_canvas/canvas.py @@ -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 @@ -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: @@ -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? @@ -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. @@ -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. @@ -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 @@ -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. @@ -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)