mirror of
https://github.com/1j01/textual-paint.git
synced 2024-09-11 15:56:03 +03:00
Add flood fill support
* Implemented the Fill With Color tool using the algorithm described as "combined-scan-and-fill span filler" on Wikipedia. * I added handling for the affected region being None, which turned out more complicated than I would like... Some cases may be able to be simplified or removed. * Also, I moved event.stop() to the top so I don't need to call it in multiple places when there are multiple return points.
This commit is contained in:
parent
3dbbbc1c5f
commit
3652b2bd8e
112
paint.py
112
paint.py
@ -320,8 +320,9 @@ class Action:
|
||||
|
||||
def update(self, document: AnsiArtDocument) -> None:
|
||||
"""Grabs the image data from the current region of the document."""
|
||||
self.sub_image_before = AnsiArtDocument(self.region.width, self.region.height)
|
||||
self.sub_image_before.copy_region(document, self.region)
|
||||
if self.region:
|
||||
self.sub_image_before = AnsiArtDocument(self.region.width, self.region.height)
|
||||
self.sub_image_before.copy_region(document, self.region)
|
||||
|
||||
def undo(self, target_document: AnsiArtDocument) -> None:
|
||||
"""Undo this action. Note that a canvas refresh is not performed here."""
|
||||
@ -406,6 +407,70 @@ def midpoint_ellipse(xc: int, yc: int, rx: int, ry: int) -> None:
|
||||
dy = dy - (2 * rx * rx)
|
||||
d2 = d2 + dx - dy + (rx * rx)
|
||||
|
||||
def flood_fill(document: AnsiArtDocument, x: int, y: int, fill_ch: str, fill_fg: str, fill_bg: str) -> None:
|
||||
"""Flood fill algorithm."""
|
||||
|
||||
# Get the original value of the cell.
|
||||
# This is the color to be replaced.
|
||||
original_fg = document.fg[y][x]
|
||||
original_bg = document.bg[y][x]
|
||||
original_ch = document.ch[y][x]
|
||||
|
||||
# Track the region affected by the fill.
|
||||
min_x = x
|
||||
min_y = y
|
||||
max_x = x
|
||||
max_y = y
|
||||
|
||||
def inside(x: int, y: int) -> bool:
|
||||
"""Returns true if the cell at the given coordinates matches the color to be replaced. Treats foreground color as equal if character is a space."""
|
||||
if x < 0 or x >= document.width or y < 0 or y >= document.height:
|
||||
return False
|
||||
return (
|
||||
document.ch[y][x] == original_ch and
|
||||
document.bg[y][x] == original_bg and
|
||||
(original_ch == " " or document.fg[y][x] == original_fg)
|
||||
)
|
||||
|
||||
def set_cell(x: int, y: int) -> None:
|
||||
"""Sets the cell at the given coordinates to the fill color, and updates the region bounds."""
|
||||
document.ch[y][x] = fill_ch
|
||||
document.fg[y][x] = fill_fg
|
||||
document.bg[y][x] = fill_bg
|
||||
nonlocal min_x, min_y, max_x, max_y
|
||||
min_x = min(min_x, x)
|
||||
min_y = min(min_y, y)
|
||||
max_x = max(max_x, x)
|
||||
max_y = max(max_y, y)
|
||||
|
||||
# Simple translation of the "final, combined-scan-and-fill span filler"
|
||||
# pseudo-code from https://en.wikipedia.org/wiki/Flood_fill
|
||||
if not inside(x, y):
|
||||
return
|
||||
stack = [(x, x, y, 1), (x, x, y - 1, -1)]
|
||||
while stack:
|
||||
x1, x2, y, dy = stack.pop()
|
||||
x = x1
|
||||
if inside(x, y):
|
||||
while inside(x - 1, y):
|
||||
set_cell(x - 1, y)
|
||||
x = x - 1
|
||||
if x < x1:
|
||||
stack.append((x, x1-1, y-dy, -dy))
|
||||
while x1 <= x2:
|
||||
while inside(x1, y):
|
||||
set_cell(x1, y)
|
||||
x1 = x1 + 1
|
||||
stack.append((x, x1 - 1, y+dy, dy))
|
||||
if x1 - 1 > x2:
|
||||
stack.append((x2 + 1, x1 - 1, y-dy, -dy))
|
||||
x1 = x1 + 1
|
||||
while x1 < x2 and not inside(x1, y):
|
||||
x1 = x1 + 1
|
||||
x = x1
|
||||
|
||||
# Return the affected region.
|
||||
return Region(min_x, min_y, max_x - min_x + 1, max_y - min_y + 1)
|
||||
|
||||
class Canvas(Widget):
|
||||
"""The image document widget."""
|
||||
@ -649,7 +714,9 @@ class PaintApp(App):
|
||||
|
||||
def on_canvas_tool_start(self, event: Canvas.ToolStart) -> None:
|
||||
"""Called when the user starts drawing on the canvas."""
|
||||
if self.selected_tool in [Tool.free_form_select, Tool.select, Tool.eraser, Tool.fill, Tool.pick_color, Tool.magnifier, Tool.airbrush, Tool.text, Tool.curve, Tool.polygon]:
|
||||
event.stop()
|
||||
|
||||
if self.selected_tool in [Tool.free_form_select, Tool.select, Tool.eraser, Tool.pick_color, Tool.magnifier, Tool.airbrush, Tool.text, Tool.curve, Tool.polygon]:
|
||||
self.selected_tool = Tool.pencil
|
||||
# TODO: support other tools
|
||||
self.image_at_start = AnsiArtDocument(self.image.width, self.image.height)
|
||||
@ -659,19 +726,29 @@ class PaintApp(App):
|
||||
self.redos = []
|
||||
action = Action(self.selected_tool.get_name(), self.image)
|
||||
self.undos.append(action)
|
||||
|
||||
affected_region = None
|
||||
if self.selected_tool == Tool.pencil or self.selected_tool == Tool.brush:
|
||||
region = self.stamp_brush(event.mouse_down_event.x, event.mouse_down_event.y)
|
||||
action.region = region
|
||||
affected_region = self.stamp_brush(event.mouse_down_event.x, event.mouse_down_event.y)
|
||||
elif self.selected_tool == Tool.fill:
|
||||
affected_region = flood_fill(self.image, event.mouse_down_event.x, event.mouse_down_event.y, self.selected_char, "#ffffff", self.selected_color)
|
||||
|
||||
if affected_region:
|
||||
action.region = affected_region
|
||||
action.region = action.region.intersection(Region(0, 0, self.image.width, self.image.height))
|
||||
action.update(self.image_at_start)
|
||||
self.canvas.refresh(region)
|
||||
event.stop()
|
||||
self.canvas.refresh(affected_region)
|
||||
|
||||
def on_canvas_tool_update(self, event: Canvas.ToolUpdate) -> None:
|
||||
"""Called when the user is drawing on the canvas."""
|
||||
event.stop()
|
||||
|
||||
if self.selected_tool == Tool.fill:
|
||||
return
|
||||
|
||||
mm = event.mouse_move_event
|
||||
action = self.undos[-1]
|
||||
affected_region = Region(mm.x, mm.y, 1, 1)
|
||||
affected_region = None
|
||||
|
||||
replace_action = self.selected_tool in [Tool.ellipse, Tool.rectangle, Tool.line, Tool.rounded_rectangle]
|
||||
if replace_action:
|
||||
@ -725,17 +802,20 @@ class PaintApp(App):
|
||||
raise NotImplementedError
|
||||
|
||||
# Update action region and image data
|
||||
action.region = action.region.union(affected_region)
|
||||
action.region = action.region.intersection(Region(0, 0, self.image.width, self.image.height))
|
||||
action.update(self.image_at_start)
|
||||
if action.region and affected_region:
|
||||
action.region = action.region.union(affected_region)
|
||||
elif affected_region:
|
||||
action.region = affected_region
|
||||
if action.region:
|
||||
action.region = action.region.intersection(Region(0, 0, self.image.width, self.image.height))
|
||||
action.update(self.image_at_start)
|
||||
|
||||
# Only for refreshing, include replaced action region
|
||||
# (The new action is allowed to shrink the region compared to the old one)
|
||||
if replace_action:
|
||||
affected_region = affected_region.union(old_action.region)
|
||||
self.canvas.refresh(affected_region)
|
||||
|
||||
event.stop()
|
||||
if affected_region:
|
||||
if replace_action:
|
||||
affected_region = affected_region.union(old_action.region)
|
||||
self.canvas.refresh(affected_region)
|
||||
|
||||
def on_key(self, event: events.Key) -> None:
|
||||
"""Called when the user presses a key."""
|
||||
|
Loading…
Reference in New Issue
Block a user