mirror of
https://github.com/1j01/textual-paint.git
synced 2024-10-05 20:07:45 +03:00
Convert document model to use 2D array of Style objects directly
This brings it back to a single source of truth for the color values.
This commit is contained in:
parent
7ce6459a27
commit
00bd187d5a
@ -13,6 +13,7 @@ from rich.console import Console
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
from rich.text import Text
|
||||
from rich.color import Color as RichColor
|
||||
from stransi.instruction import Instruction
|
||||
from textual.color import Color, ColorParseError
|
||||
from textual.geometry import Offset, Region
|
||||
@ -96,30 +97,18 @@ class AnsiArtDocument:
|
||||
self.height: int = height
|
||||
self.ch = [[" " for _ in range(width)] for _ in range(height)]
|
||||
"""2D array of characters."""
|
||||
self.bg = [[default_bg for _ in range(width)] for _ in range(height)]
|
||||
"""2D array of background colors."""
|
||||
self.fg = [[default_fg for _ in range(width)] for _ in range(height)]
|
||||
"""2D array of foreground colors."""
|
||||
style = Style(color=default_fg, bgcolor=default_bg)
|
||||
self.sc = [[style for _ in range(width)] for _ in range(height)]
|
||||
"""Style cache 2D array."""
|
||||
self.st = [[style for _ in range(width)] for _ in range(height)]
|
||||
"""2D array of styles."""
|
||||
self.selection: Optional[Selection] = None
|
||||
"""The current selection, which can itself contain an ANSI art document."""
|
||||
|
||||
def update_style_cache(self) -> None:
|
||||
"""Update the style cache based on foreground and background colors."""
|
||||
for y in range(self.height):
|
||||
for x in range(self.width):
|
||||
self.sc[y][x] = Style(color=self.fg[y][x], bgcolor=self.bg[y][x])
|
||||
|
||||
def copy(self, source: 'AnsiArtDocument') -> None:
|
||||
"""Copy the image size and data from another document. Does not copy the selection."""
|
||||
self.width = source.width
|
||||
self.height = source.height
|
||||
self.ch = [row[:] for row in source.ch]
|
||||
self.bg = [row[:] for row in source.bg]
|
||||
self.fg = [row[:] for row in source.fg]
|
||||
self.sc = [row[:] for row in source.sc]
|
||||
self.st = [row[:] for row in source.st]
|
||||
self.selection = None
|
||||
|
||||
def copy_region(self, source: 'AnsiArtDocument', source_region: Region|None = None, target_region: Region|None = None, mask: list[list[bool]]|None = None) -> None:
|
||||
@ -137,40 +126,29 @@ class AnsiArtDocument:
|
||||
for x in range(target_region.width):
|
||||
if source_region.contains(x + source_offset.x, y + source_offset.y) and (mask is None or mask[y][x]):
|
||||
self.ch[y + target_offset.y][x + target_offset.x] = source.ch[y + source_offset.y][x + source_offset.x]
|
||||
self.bg[y + target_offset.y][x + target_offset.x] = source.bg[y + source_offset.y][x + source_offset.x]
|
||||
self.fg[y + target_offset.y][x + target_offset.x] = source.fg[y + source_offset.y][x + source_offset.x]
|
||||
self.sc[y + target_offset.y][x + target_offset.x] = source.sc[y + source_offset.y][x + source_offset.x]
|
||||
self.st[y + target_offset.y][x + target_offset.x] = source.st[y + source_offset.y][x + source_offset.x]
|
||||
if DEBUG_REGION_UPDATES:
|
||||
assert random_color is not None
|
||||
# self.bg[y + target_offset.y][x + target_offset.x] = "rgb(" + str((x + source_offset.x) * 255 // self.width) + "," + str((y + source_offset.y) * 255 // self.height) + ",0)"
|
||||
self.bg[y + target_offset.y][x + target_offset.x] = random_color
|
||||
self.st[y + target_offset.y][x + target_offset.x] += Style(bgcolor=random_color)
|
||||
else:
|
||||
if DEBUG_REGION_UPDATES:
|
||||
self.ch[y + target_offset.y][x + target_offset.x] = "?"
|
||||
self.bg[y + target_offset.y][x + target_offset.x] = "#ff00ff"
|
||||
self.fg[y + target_offset.y][x + target_offset.x] = "#000000"
|
||||
self.sc[y + target_offset.y][x + target_offset.x] = Style(color="#000000", bgcolor="#ff00ff")
|
||||
self.st[y + target_offset.y][x + target_offset.x] = Style(color="#000000", bgcolor="#ff00ff")
|
||||
|
||||
def resize(self, width: int, height: int, default_bg: str = "#ffffff", default_fg: str = "#000000") -> None:
|
||||
"""Resize the document."""
|
||||
if width == self.width and height == self.height:
|
||||
return
|
||||
new_ch = [[" " for _ in range(width)] for _ in range(height)]
|
||||
new_bg = [[default_bg for _ in range(width)] for _ in range(height)]
|
||||
new_fg = [[default_fg for _ in range(width)] for _ in range(height)]
|
||||
new_sc = [[Style(color=default_fg, bgcolor=default_bg) for _ in range(width)] for _ in range(height)]
|
||||
new_st = [[Style(color=default_fg, bgcolor=default_bg) for _ in range(width)] for _ in range(height)]
|
||||
for y in range(min(height, self.height)):
|
||||
for x in range(min(width, self.width)):
|
||||
new_ch[y][x] = self.ch[y][x]
|
||||
new_bg[y][x] = self.bg[y][x]
|
||||
new_fg[y][x] = self.fg[y][x]
|
||||
new_sc[y][x] = self.sc[y][x]
|
||||
new_st[y][x] = self.st[y][x]
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.ch = new_ch
|
||||
self.bg = new_bg
|
||||
self.fg = new_fg
|
||||
self.sc = new_sc
|
||||
self.st = new_st
|
||||
|
||||
def invert(self) -> None:
|
||||
"""Invert the foreground and background colors."""
|
||||
@ -181,15 +159,22 @@ class AnsiArtDocument:
|
||||
# TODO: DRY color inversion, and/or simplify it. It shouldn't need a Style object.
|
||||
for y in range(region.y, region.y + region.height):
|
||||
for x in range(region.x, region.x + region.width):
|
||||
# style = Style(color=self.fg[y][x], bgcolor=self.bg[y][x])
|
||||
style = self.sc[y][x]
|
||||
style = self.st[y][x]
|
||||
assert style.color is not None
|
||||
assert style.bgcolor is not None
|
||||
assert style.color.triplet is not None
|
||||
assert style.bgcolor.triplet is not None
|
||||
self.bg[y][x] = f"#{(255 - style.bgcolor.triplet.red):02x}{(255 - style.bgcolor.triplet.green):02x}{(255 - style.bgcolor.triplet.blue):02x}"
|
||||
self.fg[y][x] = f"#{(255 - style.color.triplet.red):02x}{(255 - style.color.triplet.green):02x}{(255 - style.color.triplet.blue):02x}"
|
||||
self.sc[y][x] = Style(color=self.fg[y][x], bgcolor=self.bg[y][x])
|
||||
|
||||
style = Style.from_color(
|
||||
color=RichColor.parse(f"#{(255 - style.color.triplet.red):02x}{(255 - style.color.triplet.green):02x}{(255 - style.color.triplet.blue):02x}"),
|
||||
bgcolor=RichColor.parse(f"#{(255 - style.bgcolor.triplet.red):02x}{(255 - style.bgcolor.triplet.green):02x}{(255 - style.bgcolor.triplet.blue):02x}")
|
||||
)
|
||||
# Better but causes invisible changes to the snapshot tests:
|
||||
# style = Style.from_color(
|
||||
# color=RichColor.from_rgb(255 - style.color.triplet.red, 255 - style.color.triplet.green, 255 - style.color.triplet.blue),
|
||||
# bgcolor=RichColor.from_rgb(255 - style.bgcolor.triplet.red, 255 - style.bgcolor.triplet.green, 255 - style.bgcolor.triplet.blue)
|
||||
# )
|
||||
self.st[y][x] = style
|
||||
|
||||
@staticmethod
|
||||
def format_from_extension(file_path: str) -> str | None:
|
||||
@ -258,9 +243,7 @@ class AnsiArtDocument:
|
||||
assert pixels is not None, "failed to load pixels for new image"
|
||||
for y in range(self.height):
|
||||
for x in range(self.width):
|
||||
# color = Color.parse(self.bg[y][x])
|
||||
# pixels[x, y] = (color.r, color.g, color.b)
|
||||
color = self.sc[y][x].bgcolor
|
||||
color = self.st[y][x].bgcolor
|
||||
assert color is not None
|
||||
pixels[x, y] = color.triplet
|
||||
buffer = io.BytesIO()
|
||||
@ -367,8 +350,7 @@ class AnsiArtDocument:
|
||||
for y in range(self.height):
|
||||
line = Text()
|
||||
for x in range(self.width):
|
||||
# line.append(self.ch[y][x], style=Style(bgcolor=self.bg[y][x], color=self.fg[y][x]))
|
||||
line.append(self.ch[y][x], style=self.sc[y][x])
|
||||
line.append(self.ch[y][x], style=self.st[y][x])
|
||||
lines.append(line)
|
||||
result = joiner.join(lines)
|
||||
return result
|
||||
@ -406,6 +388,7 @@ class AnsiArtDocument:
|
||||
def from_irc(text: str, default_bg: str = "#ffffff", default_fg: str = "#000000") -> 'AnsiArtDocument':
|
||||
"""Creates a document from text with mIRC codes."""
|
||||
|
||||
default_style = Style(color=default_fg, bgcolor=default_bg)
|
||||
document = AnsiArtDocument(0, 0, default_bg, default_fg)
|
||||
# Minimum size of 1x1, so that the document is never empty.
|
||||
width = 1
|
||||
@ -413,8 +396,7 @@ class AnsiArtDocument:
|
||||
|
||||
x = 0
|
||||
y = 0
|
||||
bg_color = default_bg
|
||||
fg_color = default_fg
|
||||
current_style = default_style
|
||||
|
||||
color_escape = "\x03"
|
||||
# an optional escape code at the end disambiguates a digit from part of the color code
|
||||
@ -429,17 +411,18 @@ class AnsiArtDocument:
|
||||
if match:
|
||||
index += len(match.group(0))
|
||||
# TODO: should a one-value syntax reset the background?
|
||||
bg_color = default_bg
|
||||
fg_color = default_fg
|
||||
# currently it does, since default_style contains a background color
|
||||
# It would be slightly simpler to not affect it, now, but I'm preserving previous behavior.
|
||||
color_change = default_style
|
||||
if match.group(1):
|
||||
fg_color = IRC_PALETTE[int(match.group(1))]
|
||||
color_change += Style.from_color(color=RichColor.parse(IRC_PALETTE[int(match.group(1))]))
|
||||
if match.group(2):
|
||||
bg_color = IRC_PALETTE[int(match.group(2))]
|
||||
color_change += Style.from_color(bgcolor=RichColor.parse(IRC_PALETTE[int(match.group(2))]))
|
||||
current_style += color_change
|
||||
continue
|
||||
if char == reset_escape:
|
||||
index += 1
|
||||
bg_color = default_bg
|
||||
fg_color = default_fg
|
||||
current_style = default_style
|
||||
continue
|
||||
if char == "\n":
|
||||
width = max(width, x)
|
||||
@ -451,17 +434,12 @@ class AnsiArtDocument:
|
||||
# Handle a single character, adding rows/columns as needed.
|
||||
while len(document.ch) <= y:
|
||||
document.ch.append([])
|
||||
document.bg.append([])
|
||||
document.fg.append([])
|
||||
document.sc.append([])
|
||||
document.st.append([])
|
||||
while len(document.ch[y]) <= x:
|
||||
document.ch[y].append(' ')
|
||||
document.bg[y].append(default_bg)
|
||||
document.fg[y].append(default_fg)
|
||||
document.sc[y].append(Style.null())
|
||||
document.st[y].append(default_style)
|
||||
document.ch[y][x] = char
|
||||
document.bg[y][x] = bg_color
|
||||
document.fg[y][x] = fg_color
|
||||
document.st[y][x] = current_style
|
||||
width = max(x + 1, width)
|
||||
height = max(y + 1, height)
|
||||
x += 1
|
||||
@ -472,18 +450,13 @@ class AnsiArtDocument:
|
||||
# Handle minimum height.
|
||||
while len(document.ch) <= document.height:
|
||||
document.ch.append([])
|
||||
document.bg.append([])
|
||||
document.fg.append([])
|
||||
document.sc.append([])
|
||||
document.st.append([])
|
||||
# Pad rows to a consistent width.
|
||||
for y in range(document.height):
|
||||
for x in range(len(document.ch[y]), document.width):
|
||||
document.ch[y].append(' ')
|
||||
document.bg[y].append(default_bg)
|
||||
document.fg[y].append(default_fg)
|
||||
document.sc[y].append(Style.null())
|
||||
document.st[y].append(default_style)
|
||||
|
||||
document.update_style_cache()
|
||||
return document
|
||||
|
||||
@staticmethod
|
||||
@ -513,6 +486,7 @@ class AnsiArtDocument:
|
||||
|
||||
ansi = stransi.Ansi(text)
|
||||
|
||||
default_style = Style(color=default_fg, bgcolor=default_bg)
|
||||
document = AnsiArtDocument(0, 0, default_bg, default_fg)
|
||||
# Minimum size of 1x1, so that the document is never empty.
|
||||
width = 1
|
||||
@ -520,8 +494,7 @@ class AnsiArtDocument:
|
||||
|
||||
x = 0
|
||||
y = 0
|
||||
bg_color = default_bg
|
||||
fg_color = default_fg
|
||||
current_style = default_style
|
||||
instruction: Instruction[Any] | str
|
||||
for instruction in ansi.instructions():
|
||||
if isinstance(instruction, str):
|
||||
@ -553,17 +526,12 @@ class AnsiArtDocument:
|
||||
else:
|
||||
while len(document.ch) <= y:
|
||||
document.ch.append([])
|
||||
document.bg.append([])
|
||||
document.fg.append([])
|
||||
document.sc.append([])
|
||||
document.st.append([])
|
||||
while len(document.ch[y]) <= x:
|
||||
document.ch[y].append(' ')
|
||||
document.bg[y].append(default_bg)
|
||||
document.fg[y].append(default_fg)
|
||||
document.sc[y].append(Style.null())
|
||||
document.st[y].append(default_style)
|
||||
document.ch[y][x] = char
|
||||
document.bg[y][x] = bg_color
|
||||
document.fg[y][x] = fg_color
|
||||
document.st[y][x] = current_style
|
||||
width = max(x + 1, width)
|
||||
height = max(y + 1, height)
|
||||
x += 1
|
||||
@ -575,10 +543,14 @@ class AnsiArtDocument:
|
||||
# (maybe just for initial state?)
|
||||
if instruction.role == stransi.color.ColorRole.FOREGROUND:
|
||||
rgb = instruction.color.rgb
|
||||
fg_color = "rgb(" + str(int(rgb.red * 255)) + "," + str(int(rgb.green * 255)) + "," + str(int(rgb.blue * 255)) + ")"
|
||||
# fg_color = "rgb(" + str(int(rgb.red * 255)) + "," + str(int(rgb.green * 255)) + "," + str(int(rgb.blue * 255)) + ")"
|
||||
current_style += Style.from_color(color=RichColor.parse("rgb(" + str(int(rgb.red * 255)) + "," + str(int(rgb.green * 255)) + "," + str(int(rgb.blue * 255)) + ")"))
|
||||
# current_style += Style.from_color(color=RichColor.from_rgb(rgb.red * 255, rgb.green * 255, rgb.blue * 255))
|
||||
elif instruction.role == stransi.color.ColorRole.BACKGROUND:
|
||||
rgb = instruction.color.rgb
|
||||
bg_color = "rgb(" + str(int(rgb.red * 255)) + "," + str(int(rgb.green * 255)) + "," + str(int(rgb.blue * 255)) + ")"
|
||||
# bg_color = "rgb(" + str(int(rgb.red * 255)) + "," + str(int(rgb.green * 255)) + "," + str(int(rgb.blue * 255)) + ")"
|
||||
current_style += Style.from_color(bgcolor=RichColor.parse("rgb(" + str(int(rgb.red * 255)) + "," + str(int(rgb.green * 255)) + "," + str(int(rgb.blue * 255)) + ")"))
|
||||
# current_style += Style.from_color(bgcolor=RichColor.from_rgb(rgb.red * 255, rgb.green * 255, rgb.blue * 255))
|
||||
elif isinstance(instruction, stransi.SetCursor):
|
||||
# Cursor position is encoded as y;x, so stransi understandably gets this backwards.
|
||||
# TODO: fix stransi to interpret ESC[<y>;<x>H correctly
|
||||
@ -597,9 +569,7 @@ class AnsiArtDocument:
|
||||
height = max(y + 1, height)
|
||||
while len(document.ch) <= y:
|
||||
document.ch.append([])
|
||||
document.bg.append([])
|
||||
document.fg.append([])
|
||||
document.sc.append([])
|
||||
document.st.append([])
|
||||
elif isinstance(instruction, stransi.SetClear):
|
||||
def clear_line(row_to_clear: int, before: bool, after: bool):
|
||||
cols_to_clear: list[int] = []
|
||||
@ -609,8 +579,7 @@ class AnsiArtDocument:
|
||||
cols_to_clear += range(x, len(document.ch[row_to_clear]))
|
||||
for col_to_clear in cols_to_clear:
|
||||
document.ch[row_to_clear][col_to_clear] = ' '
|
||||
document.bg[row_to_clear][col_to_clear] = default_bg
|
||||
document.fg[row_to_clear][col_to_clear] = default_fg
|
||||
document.st[row_to_clear][col_to_clear] = default_style
|
||||
match instruction.region:
|
||||
case stransi.clear.Clear.LINE:
|
||||
# Clear the current line
|
||||
@ -647,17 +616,12 @@ class AnsiArtDocument:
|
||||
# Handle minimum height.
|
||||
while len(document.ch) <= document.height:
|
||||
document.ch.append([])
|
||||
document.bg.append([])
|
||||
document.fg.append([])
|
||||
document.sc.append([])
|
||||
document.st.append([])
|
||||
# Pad rows to a consistent width.
|
||||
for y in range(document.height):
|
||||
for x in range(len(document.ch[y]), document.width):
|
||||
document.ch[y].append(' ')
|
||||
document.bg[y].append(default_bg)
|
||||
document.fg[y].append(default_fg)
|
||||
document.sc[y].append(Style.null())
|
||||
document.update_style_cache()
|
||||
document.st[y].append(default_style)
|
||||
return document
|
||||
|
||||
@staticmethod
|
||||
@ -681,8 +645,7 @@ class AnsiArtDocument:
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
r, g, b = rgb_image.getpixel((x, y)) # type: ignore
|
||||
document.bg[y][x] = "#" + hex(r)[2:].zfill(2) + hex(g)[2:].zfill(2) + hex(b)[2:].zfill(2) # type: ignore
|
||||
document.update_style_cache()
|
||||
document.st[y][x] += Style.from_color(color=RichColor.from_rgb(r, g, b)) # type: ignore
|
||||
return document
|
||||
|
||||
@staticmethod
|
||||
@ -1018,7 +981,7 @@ class AnsiArtDocument:
|
||||
y = int((y - min_y) / cell_height)
|
||||
if fill is not None:
|
||||
try:
|
||||
document.bg[y][x] = fill
|
||||
document.st[y][x] += Style.from_color(bgcolor=RichColor.parse(fill))
|
||||
except IndexError:
|
||||
print("Warning: rect out of bounds: " + ET.tostring(rect, encoding="unicode"))
|
||||
|
||||
@ -1056,7 +1019,7 @@ class AnsiArtDocument:
|
||||
try:
|
||||
document.ch[y][x] = ch
|
||||
if fill is not None:
|
||||
document.fg[y][x] = fill
|
||||
document.st[y][x] += Style.from_color(color=RichColor.parse(fill))
|
||||
except IndexError:
|
||||
print("Warning: text element is out of bounds: " + ET.tostring(text, encoding="unicode"))
|
||||
continue
|
||||
@ -1065,8 +1028,6 @@ class AnsiArtDocument:
|
||||
if DEBUG_SVG_LOADING:
|
||||
ET.ElementTree(root).write("debug.svg", encoding="unicode")
|
||||
|
||||
document.update_style_cache()
|
||||
|
||||
return document
|
||||
|
||||
@staticmethod
|
||||
|
@ -215,26 +215,20 @@ class Canvas(Widget):
|
||||
cell_y = y // magnification
|
||||
try:
|
||||
if sel and sel.contained_image and sel.region.contains(cell_x, cell_y) and (sel.mask is None or sel.mask[cell_y - sel.region.y][cell_x - sel.region.x]):
|
||||
bg = sel.contained_image.bg[cell_y - sel.region.y][cell_x - sel.region.x]
|
||||
# fg = sel.contained_image.fg[cell_y - sel.region.y][cell_x - sel.region.x]
|
||||
ch = sel.contained_image.ch[cell_y - sel.region.y][cell_x - sel.region.x]
|
||||
style = sel.contained_image.sc[cell_y - sel.region.y][cell_x - sel.region.x]
|
||||
style = sel.contained_image.st[cell_y - sel.region.y][cell_x - sel.region.x]
|
||||
else:
|
||||
bg = self.image.bg[cell_y][cell_x]
|
||||
# fg = self.image.fg[cell_y][cell_x]
|
||||
ch = self.image.ch[cell_y][cell_x]
|
||||
style = self.image.sc[cell_y][cell_x]
|
||||
style = self.image.st[cell_y][cell_x]
|
||||
except IndexError:
|
||||
# This should be easier to debug visually.
|
||||
bg = "#555555"
|
||||
fg = "#cccccc"
|
||||
ch = "?"
|
||||
style = Style.from_color(color=Color.parse(fg), bgcolor=Color.parse(bg))
|
||||
style = Style.from_color(color=Color.parse("#cccccc"), bgcolor=Color.parse("#555555"))
|
||||
if magnification > 1:
|
||||
ch = self.big_ch(ch, x % magnification, y % magnification, magnification)
|
||||
if show_grid:
|
||||
if x % magnification == 0 or y % magnification == 0:
|
||||
# Not setting `bg` here, because:
|
||||
# Not overriding `bgcolor` here, because:
|
||||
# Its actually useful to see the background color of the cell,
|
||||
# as it lets you distinguish between a space " " and a full block "█".
|
||||
# Plus this lets the grid be more subtle, visually taking up less than a cell.
|
||||
@ -245,8 +239,7 @@ class Canvas(Widget):
|
||||
ch = "|" if args.ascii_only else "▌" # "┆" # (▏, not 🭰)
|
||||
elif y % magnification == 0:
|
||||
ch = "-" if args.ascii_only else "▀" # "┄" # (▔, not 🭶)
|
||||
style = Style.from_color(color=Color.parse(fg), bgcolor=Color.parse(bg))
|
||||
# style = Style.from_color(color=Color.parse(fg), bgcolor=Color.parse(bg))
|
||||
style += Style.from_color(color=Color.parse(fg))
|
||||
assert style.color is not None
|
||||
assert style.bgcolor is not None
|
||||
def within_text_selection_highlight(textbox: Selection) -> int:
|
||||
|
@ -1,6 +1,7 @@
|
||||
"""Drawing utilities for use with the AnsiArtDocument class."""
|
||||
|
||||
from typing import TYPE_CHECKING, Iterator
|
||||
from rich.style import Style
|
||||
|
||||
from textual.geometry import Offset, Region
|
||||
|
||||
@ -183,10 +184,11 @@ def midpoint_ellipse(xc: int, yc: int, rx: int, ry: int) -> Iterator[tuple[int,
|
||||
def flood_fill(document: 'AnsiArtDocument', x: int, y: int, fill_ch: str, fill_fg: str, fill_bg: str) -> Region|None:
|
||||
"""Flood fill algorithm."""
|
||||
|
||||
fill_style = Style(color=fill_fg, bgcolor=fill_bg)
|
||||
|
||||
# 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_style = document.st[y][x]
|
||||
original_ch = document.ch[y][x]
|
||||
|
||||
# Track the region affected by the fill.
|
||||
@ -196,21 +198,26 @@ def flood_fill(document: 'AnsiArtDocument', x: int, y: int, fill_ch: str, fill_f
|
||||
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."""
|
||||
"""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.
|
||||
|
||||
TODO: treat colors as equal if only their names are different, e.g. "rgb(0,0,0)" and "#000000"
|
||||
See stamp_char's handling of Color Eraser for some threshold handling code
|
||||
"""
|
||||
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) and
|
||||
(document.ch[y][x] != fill_ch or document.bg[y][x] != fill_bg or document.fg[y][x] != fill_fg)
|
||||
document.st[y][x].bgcolor == original_style.bgcolor and
|
||||
(original_ch == " " or document.st[y][x].color == original_style.color) and
|
||||
(document.ch[y][x] != fill_ch or document.st[y][x].bgcolor != fill_style.bgcolor or document.st[y][x].color != fill_style.color)
|
||||
)
|
||||
|
||||
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
|
||||
document.st[y][x] = fill_style
|
||||
nonlocal min_x, min_y, max_x, max_y
|
||||
min_x = min(min_x, x)
|
||||
min_y = min(min_y, y)
|
||||
@ -243,7 +250,5 @@ def flood_fill(document: 'AnsiArtDocument', x: int, y: int, fill_ch: str, fill_f
|
||||
x1 = x1 + 1
|
||||
x = x1
|
||||
|
||||
document.update_style_cache()
|
||||
|
||||
# Return the affected region.
|
||||
return Region(min_x, min_y, max_x - min_x + 1, max_y - min_y + 1)
|
||||
|
@ -13,6 +13,7 @@ from typing import Any, Callable, Iterator, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from rich.color import Color as RichColor
|
||||
from rich.style import Style
|
||||
from rich.text import Text
|
||||
from textual import events, on, work
|
||||
@ -290,12 +291,13 @@ class PaintApp(App[None]):
|
||||
# CharInput now handles the background style itself PARTIALLY; it doesn't affect the whole area.
|
||||
|
||||
# update Text tool textbox immediately
|
||||
# TODO: DRY
|
||||
style = Style(bgcolor=selected_bg_color)
|
||||
if self.image.selection and self.image.selection.textbox_mode:
|
||||
assert self.image.selection.contained_image is not None, "textbox_mode without contained_image"
|
||||
for y in range(self.image.selection.region.height):
|
||||
for x in range(self.image.selection.region.width):
|
||||
self.image.selection.contained_image.bg[y][x] = self.selected_bg_color
|
||||
self.image.selection.contained_image.update_style_cache()
|
||||
self.image.selection.contained_image.st[y][x] += style
|
||||
self.canvas.refresh_scaled_region(self.image.selection.region)
|
||||
|
||||
# update Polygon/Curve tool preview immediately
|
||||
@ -309,12 +311,12 @@ class PaintApp(App[None]):
|
||||
self.query_one("#selected_color_char_input", CharInput).refresh()
|
||||
|
||||
# update Text tool textbox immediately
|
||||
style = Style(color=selected_fg_color)
|
||||
if self.image.selection and self.image.selection.textbox_mode:
|
||||
assert self.image.selection.contained_image is not None, "textbox_mode without contained_image"
|
||||
for y in range(self.image.selection.region.height):
|
||||
for x in range(self.image.selection.region.width):
|
||||
self.image.selection.contained_image.fg[y][x] = self.selected_fg_color
|
||||
self.image.selection.contained_image.update_style_cache()
|
||||
self.image.selection.contained_image.st[y][x] += style
|
||||
self.canvas.refresh_scaled_region(self.image.selection.region)
|
||||
|
||||
# update Polygon/Curve tool preview immediately
|
||||
@ -374,13 +376,9 @@ class PaintApp(App[None]):
|
||||
fg_color = "#000000"
|
||||
if self.color_eraser_mode:
|
||||
char = self.image.ch[y][x]
|
||||
# fg_color = self.selected_bg_color if self.image.fg[y][x] == self.selected_fg_color else self.image.fg[y][x]
|
||||
# bg_color = self.selected_bg_color if self.image.bg[y][x] == self.selected_fg_color else self.image.bg[y][x]
|
||||
|
||||
# Use color comparison instead of string comparison because "#000000" != "rgb(0,0,0)"
|
||||
# This stuff might be simpler and more efficient if we used Color objects in the document model
|
||||
# style = Style(color=self.image.fg[y][x], bgcolor=self.image.bg[y][x])
|
||||
style = self.image.sc[y][x]
|
||||
style = self.image.st[y][x]
|
||||
selected_fg_style = Style(color=self.selected_fg_color)
|
||||
assert style.color is not None
|
||||
assert style.bgcolor is not None
|
||||
@ -393,31 +391,33 @@ class PaintApp(App[None]):
|
||||
assert selected_fg_style.color.triplet is not None
|
||||
fg_matches = abs(style.color.triplet[0] - selected_fg_style.color.triplet[0]) < threshold and abs(style.color.triplet[1] - selected_fg_style.color.triplet[1]) < threshold and abs(style.color.triplet[2] - selected_fg_style.color.triplet[2]) < threshold
|
||||
bg_matches = abs(style.bgcolor.triplet[0] - selected_fg_style.color.triplet[0]) < threshold and abs(style.bgcolor.triplet[1] - selected_fg_style.color.triplet[1]) < threshold and abs(style.bgcolor.triplet[2] - selected_fg_style.color.triplet[2]) < threshold
|
||||
fg_color = self.selected_bg_color if fg_matches else self.image.fg[y][x]
|
||||
bg_color = self.selected_bg_color if bg_matches else self.image.bg[y][x]
|
||||
fg = self.image.st[y][x].color
|
||||
bg = self.image.st[y][x].bgcolor
|
||||
assert fg is not None
|
||||
assert bg is not None
|
||||
fg_triplet = fg.get_truecolor()
|
||||
bg_triplet = bg.get_truecolor()
|
||||
fg_color = self.selected_bg_color if fg_matches else fg_triplet.hex
|
||||
bg_color = self.selected_bg_color if bg_matches else bg_triplet.hex
|
||||
if self.selected_tool == Tool.airbrush:
|
||||
if random() < 0.7:
|
||||
return
|
||||
if self.selected_tool == Tool.free_form_select:
|
||||
# Invert the underlying colors
|
||||
# TODO: DRY color inversion, and/or simplify it. It shouldn't need a Style object.
|
||||
# style = Style(color=self.image.fg[y][x], bgcolor=self.image.bg[y][x])
|
||||
style = self.image.sc[y][x]
|
||||
# TODO: DRY color inversion, and/or simplify it.
|
||||
style = self.image.st[y][x]
|
||||
assert style.color is not None
|
||||
assert style.bgcolor is not None
|
||||
assert style.color.triplet is not None
|
||||
assert style.bgcolor.triplet is not None
|
||||
# self.image.bg[y][x] = f"rgb({255 - style.bgcolor.triplet.red},{255 - style.bgcolor.triplet.green},{255 - style.bgcolor.triplet.blue})"
|
||||
# self.image.fg[y][x] = f"rgb({255 - style.color.triplet.red},{255 - style.color.triplet.green},{255 - style.color.triplet.blue})"
|
||||
# Use hex instead, for less memory usage, theoretically
|
||||
self.image.bg[y][x] = f"#{(255 - style.bgcolor.triplet.red):02x}{(255 - style.bgcolor.triplet.green):02x}{(255 - style.bgcolor.triplet.blue):02x}"
|
||||
self.image.fg[y][x] = f"#{(255 - style.color.triplet.red):02x}{(255 - style.color.triplet.green):02x}{(255 - style.color.triplet.blue):02x}"
|
||||
self.image.sc[y][x] = Style(color=self.image.fg[y][x], bgcolor=self.image.bg[y][x])
|
||||
style = Style.from_color(
|
||||
color=RichColor.from_rgb(255 - style.color.triplet.red, 255 - style.color.triplet.green, 255 - style.color.triplet.blue),
|
||||
bgcolor=RichColor.from_rgb(255 - style.bgcolor.triplet.red, 255 - style.bgcolor.triplet.green, 255 - style.bgcolor.triplet.blue)
|
||||
)
|
||||
self.image.st[y][x] = style
|
||||
else:
|
||||
self.image.ch[y][x] = char
|
||||
self.image.bg[y][x] = bg_color
|
||||
self.image.fg[y][x] = fg_color
|
||||
self.image.sc[y][x] = Style(color=fg_color, bgcolor=bg_color)
|
||||
self.image.st[y][x] = Style(color=fg_color, bgcolor=bg_color)
|
||||
|
||||
def erase_region(self, region: Region, mask: Optional[list[list[bool]]] = None) -> None:
|
||||
"""Clears the given region."""
|
||||
@ -1820,9 +1820,7 @@ Columns: {len(self.palette) // 2}
|
||||
for y in range(self.image.height):
|
||||
for x in range(self.image.width):
|
||||
self.image.ch[y][self.image.width - x - 1] = source.ch[y][x]
|
||||
self.image.fg[y][self.image.width - x - 1] = source.fg[y][x]
|
||||
self.image.bg[y][self.image.width - x - 1] = source.bg[y][x]
|
||||
self.image.sc[y][self.image.width - x - 1] = source.sc[y][x]
|
||||
self.image.st[y][self.image.width - x - 1] = source.st[y][x]
|
||||
self.canvas.refresh()
|
||||
|
||||
def action_flip_vertical(self) -> None:
|
||||
@ -1838,9 +1836,7 @@ Columns: {len(self.palette) // 2}
|
||||
for y in range(self.image.height):
|
||||
for x in range(self.image.width):
|
||||
self.image.ch[self.image.height - y - 1][x] = source.ch[y][x]
|
||||
self.image.fg[self.image.height - y - 1][x] = source.fg[y][x]
|
||||
self.image.bg[self.image.height - y - 1][x] = source.bg[y][x]
|
||||
self.image.sc[self.image.height - y - 1][x] = source.sc[y][x]
|
||||
self.image.st[self.image.height - y - 1][x] = source.st[y][x]
|
||||
self.canvas.refresh()
|
||||
|
||||
def action_rotate_by_angle(self, angle: int) -> None:
|
||||
@ -1860,19 +1856,13 @@ Columns: {len(self.palette) // 2}
|
||||
for x in range(self.image.width):
|
||||
if angle == 90:
|
||||
self.image.ch[y][x] = source.ch[self.image.width - x - 1][y]
|
||||
self.image.fg[y][x] = source.fg[self.image.width - x - 1][y]
|
||||
self.image.bg[y][x] = source.bg[self.image.width - x - 1][y]
|
||||
self.image.sc[y][x] = source.sc[self.image.width - x - 1][y]
|
||||
self.image.st[y][x] = source.st[self.image.width - x - 1][y]
|
||||
elif angle == 180:
|
||||
self.image.ch[y][x] = source.ch[self.image.height - y - 1][self.image.width - x - 1]
|
||||
self.image.fg[y][x] = source.fg[self.image.height - y - 1][self.image.width - x - 1]
|
||||
self.image.bg[y][x] = source.bg[self.image.height - y - 1][self.image.width - x - 1]
|
||||
self.image.sc[y][x] = source.sc[self.image.height - y - 1][self.image.width - x - 1]
|
||||
self.image.st[y][x] = source.st[self.image.height - y - 1][self.image.width - x - 1]
|
||||
elif angle == 270:
|
||||
self.image.ch[y][x] = source.ch[x][self.image.height - y - 1]
|
||||
self.image.fg[y][x] = source.fg[x][self.image.height - y - 1]
|
||||
self.image.bg[y][x] = source.bg[x][self.image.height - y - 1]
|
||||
self.image.sc[y][x] = source.sc[x][self.image.height - y - 1]
|
||||
self.image.st[y][x] = source.st[x][self.image.height - y - 1]
|
||||
self.canvas.refresh(layout=True)
|
||||
|
||||
def action_stretch_skew(self) -> None:
|
||||
@ -1957,6 +1947,8 @@ Columns: {len(self.palette) // 2}
|
||||
def action_stretch_skew_by(self, horizontal_stretch: float, vertical_stretch: float, horizontal_skew: float, vertical_skew: float) -> None:
|
||||
"""Stretch/skew the image by the given amounts."""
|
||||
|
||||
default_style = Style(color="#000000", bgcolor="#ffffff")
|
||||
|
||||
# Convert units
|
||||
horizontal_stretch = horizontal_stretch / 100
|
||||
vertical_stretch = vertical_stretch / 100
|
||||
@ -2017,14 +2009,10 @@ Columns: {len(self.palette) // 2}
|
||||
|
||||
if 0 <= sample_x < source.width and 0 <= sample_y < source.height:
|
||||
self.image.ch[y][x] = source.ch[sample_y][sample_x]
|
||||
self.image.fg[y][x] = source.fg[sample_y][sample_x]
|
||||
self.image.bg[y][x] = source.bg[sample_y][sample_x]
|
||||
self.image.sc[y][x] = source.sc[sample_y][sample_x]
|
||||
self.image.st[y][x] = source.st[sample_y][sample_x]
|
||||
else:
|
||||
self.image.ch[y][x] = " "
|
||||
self.image.fg[y][x] = "#000000" # default_fg — if this was a variable, would it allocate less strings?
|
||||
self.image.bg[y][x] = "#ffffff" # default_bg
|
||||
self.image.sc[y][x] = Style(color=self.image.fg[y][x], bgcolor=self.image.bg[y][x])
|
||||
self.image.st[y][x] = default_style
|
||||
self.canvas.refresh(layout=True)
|
||||
|
||||
def action_invert_colors_unless_should_switch_focus(self) -> None:
|
||||
@ -2141,12 +2129,11 @@ Columns: {len(self.palette) // 2}
|
||||
action.update(self.image)
|
||||
self.add_action(action)
|
||||
|
||||
default_style = Style(color="#000000", bgcolor="#ffffff")
|
||||
for y in range(self.image.height):
|
||||
for x in range(self.image.width):
|
||||
self.image.ch[y][x] = " "
|
||||
self.image.fg[y][x] = "#000000"
|
||||
self.image.bg[y][x] = "#ffffff"
|
||||
self.image.sc[y][x] = Style(color=self.image.fg[y][x], bgcolor=self.image.bg[y][x])
|
||||
self.image.st[y][x] = default_style
|
||||
|
||||
self.canvas.refresh()
|
||||
|
||||
@ -2324,8 +2311,12 @@ Columns: {len(self.palette) // 2}
|
||||
"""Select a color from the image."""
|
||||
if x < 0 or y < 0 or x >= self.image.width or y >= self.image.height:
|
||||
return
|
||||
self.selected_bg_color = self.image.bg[y][x]
|
||||
self.selected_fg_color = self.image.fg[y][x]
|
||||
bgcolor = self.image.st[y][x].bgcolor
|
||||
color = self.image.st[y][x].color
|
||||
assert bgcolor is not None
|
||||
assert color is not None
|
||||
self.selected_bg_color = bgcolor.get_truecolor().hex
|
||||
self.selected_fg_color = color.get_truecolor().hex
|
||||
self.selected_char = self.image.ch[y][x]
|
||||
|
||||
def get_prospective_magnification(self) -> int:
|
||||
|
@ -90,17 +90,18 @@ def rasterize(doc: 'AnsiArtDocument') -> Image.Image:
|
||||
# draw cell backgrounds
|
||||
for y in range(doc.height):
|
||||
for x in range(doc.width):
|
||||
bg_color = doc.bg[y][x]
|
||||
draw.rectangle((x * ch_width, y * ch_height, (x + 1) * ch_width, (y + 1) * ch_height), fill=bg_color)
|
||||
bg_color = doc.st[y][x].bgcolor
|
||||
assert bg_color is not None
|
||||
draw.rectangle((x * ch_width, y * ch_height, (x + 1) * ch_width, (y + 1) * ch_height), fill=bg_color.get_truecolor().hex)
|
||||
|
||||
# draw text
|
||||
for y in range(doc.height):
|
||||
for x in range(doc.width):
|
||||
char = doc.ch[y][x]
|
||||
bg_color = doc.bg[y][x]
|
||||
fg_color = doc.fg[y][x]
|
||||
fg_color = doc.st[y][x].color
|
||||
assert fg_color is not None
|
||||
try:
|
||||
draw.text((x * ch_width, y * ch_height), char, font=font, fill=fg_color)
|
||||
draw.text((x * ch_width, y * ch_height), char, font=font, fill=fg_color.get_truecolor().hex)
|
||||
except UnicodeEncodeError:
|
||||
pass
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user