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:
Isaiah Odhner 2023-09-21 14:12:13 -04:00
parent 7ce6459a27
commit 00bd187d5a
5 changed files with 122 additions and 171 deletions

View File

@ -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

View File

@ -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:

View File

@ -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)

View File

@ -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:

View File

@ -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