Move Canvas widget and MetaGlyphFont class to new files

This commit is contained in:
Isaiah Odhner 2023-09-13 19:36:42 -04:00
parent 1946188927
commit 5e561c02b0
3 changed files with 460 additions and 429 deletions

400
src/textual_paint/canvas.py Normal file
View File

@ -0,0 +1,400 @@
"""The Canvas widget."""
from typing import TYPE_CHECKING, Any, Optional
from rich.segment import Segment
from rich.style import Style
from textual import events
from textual.geometry import Offset, Region, Size
from textual.message import Message
from textual.reactive import reactive
from textual.strip import Strip
from textual.widget import Widget
from textual_paint.ansi_art_document import AnsiArtDocument, Selection
from textual_paint.args import args
from textual_paint.meta_glyph_font import largest_font_that_fits
def scale_region(region: Region, scale: int) -> Region:
"""Returns the region scaled by the given factor."""
return Region(region.x * scale, region.y * scale, region.width * scale, region.height * scale)
class Canvas(Widget):
"""The image document widget."""
magnification = reactive(1, layout=True)
show_grid = reactive(False)
# Is it kosher to include an event in a message?
# Is it better (and possible) to bubble up the event, even though I'm capturing the mouse?
# Or would it be better to just have Canvas own duplicate state for all tool parameters?
# That's what I was refactoring to avoid. So far I've made things more complicated,
# but I'm betting it will be good when implementing different tools.
# Maybe the PaintApp widget can capture the mouse events instead?
# Not sure if that would work as nicely when implementing selections.
# I'd have to think about it.
# But it would make the Canvas just be a widget for rendering, which seems good.
class ToolStart(Message):
"""Message when starting drawing."""
def __init__(self, mouse_down_event: events.MouseDown) -> None:
self.x = mouse_down_event.x
self.y = mouse_down_event.y
self.button = mouse_down_event.button
self.ctrl = mouse_down_event.ctrl
super().__init__()
class ToolUpdate(Message):
"""Message when dragging on the canvas."""
def __init__(self, mouse_move_event: events.MouseMove) -> None:
self.x = mouse_move_event.x
self.y = mouse_move_event.y
super().__init__()
class ToolStop(Message):
"""Message when releasing the mouse."""
def __init__(self, mouse_up_event: events.MouseUp) -> None:
self.x = mouse_up_event.x
self.y = mouse_up_event.y
super().__init__()
class ToolPreviewUpdate(Message):
"""Message when moving the mouse while the mouse is up."""
def __init__(self, mouse_move_event: events.MouseMove) -> None:
self.x = mouse_move_event.x
self.y = mouse_move_event.y
super().__init__()
class ToolPreviewStop(Message):
"""Message when the mouse leaves the canvas while previewing (not while drawing)."""
def __init__(self) -> None:
super().__init__()
def __init__(self, **kwargs: Any) -> None:
"""Initialize the canvas."""
super().__init__(**kwargs)
self.image: AnsiArtDocument|None = None
self.pointer_active: bool = False
self.magnifier_preview_region: Optional[Region] = None
self.select_preview_region: Optional[Region] = None
self.which_button: Optional[int] = None
def on_mouse_down(self, event: events.MouseDown) -> None:
"""Called when a mouse button is pressed.
This either starts drawing, or if both mouse buttons are pressed, cancels the current action.
"""
if self.app.has_class("view_bitmap"):
# Exiting is handled by the PaintApp.
return
self.fix_mouse_event(event) # not needed, pointer isn't captured yet.
event.x //= self.magnification
event.y //= self.magnification
if self.pointer_active and self.which_button != event.button:
if TYPE_CHECKING:
from textual_paint.paint import PaintApp
assert isinstance(self.app, PaintApp)
# TODO: Pyright is marking this as Never... maybe use cast instead?
# Not sure why it would be Never though, might be a bug.
self.app.stop_action_in_progress()
return
self.post_message(self.ToolStart(event))
self.pointer_active = True
self.which_button = event.button
self.capture_mouse(True)
def fix_mouse_event(self, event: events.MouseEvent) -> None:
"""Work around inconsistent widget-relative mouse coordinates by calculating from screen coordinates."""
# Hack to fix mouse coordinates, not needed for mouse down,
# or while the mouse is up.
# This seems like a bug.
# I think it's due to coordinates being calculated differently during mouse capture.
# if self.pointer_active:
# assert isinstance(self.parent, Widget)
# event.x += int(self.parent.scroll_x)
# event.y += int(self.parent.scroll_y)
# The above fix sometimes works but maybe sometimes shouldn't apply or isn't right.
# In order to make this robust without knowing the exact cause,
# I'm going to always calculate straight from the screen coordinates.
# This should also make it robust against the bugs in the library being fixed.
# node: DOMNode|None = self
offset = event.screen_offset
# while node:
# offset = offset - node.offset
# node = node.parent
# assert isinstance(self.parent, Widget)
offset = offset - self.region.offset #+ Offset(int(self.parent.scroll_x), int(self.parent.scroll_y))
event.x = offset.x
event.y = offset.y
def on_mouse_move(self, event: events.MouseMove) -> None:
"""Called when the mouse is moved. Update the tool action or preview."""
self.fix_mouse_event(event)
event.x //= self.magnification
event.y //= self.magnification
if self.pointer_active:
self.post_message(self.ToolUpdate(event))
else:
# I put this in the else block just for performance.
# Hopefully it wouldn't matter much, but
# the pointer should never be active in View Bitmap mode.
if self.app.has_class("view_bitmap"):
return
self.post_message(self.ToolPreviewUpdate(event))
def on_mouse_up(self, event: events.MouseUp) -> None:
"""Called when a mouse button is released. Stop the current tool."""
self.fix_mouse_event(event)
event.x //= self.magnification
event.y //= self.magnification
if self.pointer_active:
self.post_message(self.ToolStop(event))
self.pointer_active = False
self.capture_mouse(False)
def on_leave(self, event: events.Leave) -> None:
"""Called when the mouse leaves the canvas. Stop preview if applicable."""
if not self.pointer_active:
self.post_message(self.ToolPreviewStop())
def get_content_width(self, container: Size, viewport: Size) -> int:
"""Defines the intrinsic width of the widget."""
if self.image is None:
return 0 # shouldn't really happen
return self.image.width * self.magnification
def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
"""Defines the intrinsic height of the widget."""
if self.image is None:
return 0 # shouldn't really happen
return self.image.height * self.magnification
def render_line(self, y: int) -> Strip:
"""Render a line of the widget. y is relative to the top of the widget."""
assert self.image is not None
# self.size.width/height already is multiplied by self.magnification.
if y >= self.size.height:
return Strip.blank(self.size.width)
segments: list[Segment] = []
sel = self.image.selection
# Avoiding "possibly unbound" errors.
magnifier_preview_region = None
inner_magnifier_preview_region = None
select_preview_region = None
inner_select_preview_region = None
selection_region = None
inner_selection_region = None
if self.magnifier_preview_region:
magnifier_preview_region = scale_region(self.magnifier_preview_region, self.magnification)
inner_magnifier_preview_region = magnifier_preview_region.shrink((1, 1, 1, 1))
if self.select_preview_region:
select_preview_region = scale_region(self.select_preview_region, self.magnification)
inner_select_preview_region = select_preview_region.shrink((1, 1, 1, 1))
if sel:
selection_region = scale_region(sel.region, self.magnification)
inner_selection_region = selection_region.shrink((1, 1, 1, 1))
for x in range(self.size.width):
cell_x = x // self.magnification
cell_y = y // self.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]
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]
except IndexError:
# This should be easier to debug visually.
bg = "#555555"
fg = "#cccccc"
ch = "?"
if self.magnification > 1:
ch = self.big_ch(ch, x % self.magnification, y % self.magnification)
if self.show_grid and self.magnification >= 4:
if x % self.magnification == 0 or y % self.magnification == 0:
# Not setting `bg` 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.
fg = "#c0c0c0" if (x + y) % 2 == 0 else "#808080"
if x % self.magnification == 0 and y % self.magnification == 0:
ch = "+" if args.ascii_only else "" # "┼" # (🭽 may render as wide)
elif x % self.magnification == 0:
ch = "|" if args.ascii_only else "" # "┆" # (▏, not 🭰)
elif y % self.magnification == 0:
ch = "-" if args.ascii_only else "" # "┄" # (▔, not 🭶)
style = Style(color=fg, bgcolor=bg)
assert style.color is not None
assert style.bgcolor is not None
def within_text_selection_highlight(textbox: Selection) -> int:
if cell_x >= textbox.region.right or cell_x < textbox.region.x:
# Prevent inverting outside the textbox.
return False
def offset_to_text_index(offset: Offset) -> int:
return offset.y * textbox.region.width + offset.x
start_index = offset_to_text_index(textbox.text_selection_start)
end_index = offset_to_text_index(textbox.text_selection_end)
min_index = min(start_index, end_index)
max_index = max(start_index, end_index)
cell_index = offset_to_text_index(Offset(cell_x, cell_y) - textbox.region.offset)
return min_index <= cell_index <= max_index
if TYPE_CHECKING:
from textual_paint.paint import PaintApp
assert isinstance(self.app, PaintApp)
if (
(self.magnifier_preview_region and magnifier_preview_region.contains(x, y) and (not inner_magnifier_preview_region.contains(x, y))) or # type: ignore
(self.select_preview_region and select_preview_region.contains(x, y) and (not inner_select_preview_region.contains(x, y))) or # type: ignore
(sel and (not sel.textbox_mode) and (self.app.selection_drag_offset is None) and selection_region.contains(x, y) and (not inner_selection_region.contains(x, y))) or # type: ignore
(sel and sel.textbox_mode and within_text_selection_highlight(sel))
):
# invert the colors
inverse_color = f"rgb({255 - style.color.triplet.red},{255 - style.color.triplet.green},{255 - style.color.triplet.blue})"
inverse_bgcolor = f"rgb({255 - style.bgcolor.triplet.red},{255 - style.bgcolor.triplet.green},{255 - style.bgcolor.triplet.blue})"
style = Style(color=inverse_color, bgcolor=inverse_bgcolor)
segments.append(Segment(ch, style))
return Strip(segments, self.size.width)
def refresh_scaled_region(self, region: Region) -> None:
"""Refresh a region of the widget, scaled by the magnification."""
if self.magnification == 1:
self.refresh(region)
return
# TODO: are these offsets needed? I added them because of a problem which I've fixed
self.refresh(Region(
(region.x - 1) * self.magnification,
(region.y - 1) * self.magnification,
(region.width + 2) * self.magnification,
(region.height + 2) * self.magnification,
))
def watch_magnification(self) -> None:
"""Called when magnification changes."""
self.active_meta_glyph_font = largest_font_that_fits(self.magnification, self.magnification)
def big_ch(self, ch: str, x: int, y: int) -> str:
"""Return a character part of a meta-glyph."""
if self.active_meta_glyph_font and ch in self.active_meta_glyph_font.glyphs:
glyph_lines = self.active_meta_glyph_font.glyphs[ch]
x -= (self.magnification - self.active_meta_glyph_font.width) // 2
y -= (self.magnification - self.active_meta_glyph_font.height) // 2
if y >= len(glyph_lines) or y < 0:
return " "
glyph_line = glyph_lines[y]
if x >= len(glyph_line) or x < 0:
return " "
return glyph_line[x]
if ch in " ░▒▓█":
return ch
match ch:
# These are now obsolete special cases of below fractional block character handling.
# case "▄":
# return "█" if y >= self.magnification // 2 else " "
# case "▀":
# return "█" if y < self.magnification // 2 else " "
# case "▌":
# return "█" if x < self.magnification // 2 else " "
# case "▐":
# return "█" if x >= self.magnification // 2 else " "
# Corner triangles
case "":
diagonal = x - y
return "" if diagonal < 0 else " " if diagonal > 0 else ""
case "":
diagonal = x - y
return "" if diagonal > 0 else " " if diagonal < 0 else ""
case "":
diagonal = x + y + 1 - self.magnification
return "" if diagonal > 0 else " " if diagonal < 0 else ""
case "":
diagonal = x + y + 1 - self.magnification
return "" if diagonal < 0 else " " if diagonal > 0 else ""
case "":
diagonal = x + y + 1 - self.magnification
return "" if diagonal == 0 else " "
case "":
diagonal = x - y
return "" if diagonal == 0 else " "
case "":
diagonal_1 = x + y + 1 - self.magnification
diagonal_2 = x - y
return "" if diagonal_2 == 0 else "" if diagonal_1 == 0 else " "
case "/":
diagonal = x + y + 1 - self.magnification
return "/" if diagonal == 0 else " "
case "\\":
diagonal = x - y
return "\\" if diagonal == 0 else " "
# Fractional blocks
# These are at the end because `in` may be slow.
# Note: the order of the gradient strings is chosen so that
# the dividing line is at the top/left at index 0.
case ch if ch in "█▇▆▅▄▃▂▁":
gradient = "█▇▆▅▄▃▂▁ "
index = gradient.index(ch)
threshold_y = int(index / 8 * self.magnification)
if y == threshold_y:
# Within the threshold cell, which is at y here,
# use one of the fractional characters.
# If you look at a 3/8ths character, to scale it up 2x,
# you need a 6/8ths character. It simply scales with the magnification.
# If you look at a 6/8ths character, to scale it up 2x,
# you need a full block and a 4/8ths character, 4/8ths being the threshold cell here,
# so it needs to wrap around, taking the remainder.
return gradient[index * self.magnification % 8]
elif y > threshold_y:
return ""
else:
return " "
case ch if ch in "▏▎▍▌▋▊▉█":
gradient = " ▏▎▍▌▋▊▉█"
index = gradient.index(ch)
threshold_x = int(index / 8 * self.magnification)
if x == threshold_x:
return gradient[index * self.magnification % 8]
elif x < threshold_x:
return ""
else:
return " "
case ch if ch in "▔🮂🮃▀🮄🮅🮆█":
gradient = " ▔🮂🮃▀🮄🮅🮆█"
index = gradient.index(ch)
threshold_y = int(index / 8 * self.magnification)
if y == threshold_y:
return gradient[index * self.magnification % 8]
elif y < threshold_y:
return ""
else:
return " "
case ch if ch in "█🮋🮊🮉▐🮈🮇▕":
gradient = "█🮋🮊🮉▐🮈🮇▕ "
index = gradient.index(ch)
threshold_x = int(index / 8 * self.magnification)
if x == threshold_x:
return gradient[index * self.magnification % 8]
elif x > threshold_x:
return ""
else:
return " "
case _: pass
# Fall back to showing the character in a single cell, approximately centered.
if x == self.magnification // 2 and y == self.magnification // 2:
return ch
else:
return " "

View File

@ -0,0 +1,58 @@
"""Drawing large text characters with smaller characters."""
import os
from pyfiglet import Figlet, FigletFont # type: ignore
class MetaGlyphFont:
"""A font where each character is drawn with sub-characters."""
def __init__(self, file_path: str, width: int, height: int, covered_characters: str):
self.file_path = file_path
"""The path to the font file."""
self.glyphs: dict[str, list[str]] = {}
"""Maps characters to meta-glyphs, where each meta-glyph is a list of rows of characters."""
self.width = width
"""The width in characters of a meta-glyph."""
self.height = height
"""The height in characters of a meta-glyph."""
self.covered_characters = covered_characters
"""The characters supported by this font."""
self.load()
def load(self):
"""Load the font from the .flf FIGlet font file."""
# fig = Figlet(font=self.file_path) # gives FontNotFound error!
# Figlet constructor only supports looking for installed fonts.
# I could install the font, with FigletFont.installFonts,
# maybe with some prefixed name, but I don't want to do that.
with open(self.file_path, encoding="utf-8") as f:
flf = f.read()
fig_font = FigletFont()
fig_font.data = flf
fig_font.loadFont()
fig = Figlet()
# fig.setFont(font=fig_font) # nope, that's also looking for a font name
fig.font = self.file_path # may not be used
fig.Font = fig_font # this feels so wrong
for char in self.covered_characters:
meta_glyph = fig.renderText(char)
self.glyphs[char] = meta_glyph.split("\n")
covered_characters = R""" !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~"""
meta_glyph_fonts: dict[int, MetaGlyphFont] = {
2: MetaGlyphFont(os.path.join(os.path.dirname(__file__), "fonts/NanoTiny/NanoTiny_v14_2x2.flf"), 2, 2, covered_characters),
# 4: MetaGlyphFont(os.path.join(os.path.dirname(__file__), "fonts/NanoTiny/NanoTiny_v14_4x4.flf"), 4, 4, covered_characters),
# TODO: less specialized (more practical) fonts for larger sizes
}
def largest_font_that_fits(max_width: int, max_height: int) -> MetaGlyphFont | None:
"""Get the largest font with glyphs that can all fit in the given dimensions."""
for font_size in sorted(meta_glyph_fonts.keys(), reverse=True):
font = meta_glyph_fonts[font_size]
if font.width <= max_width and font.height <= max_height:
return font
return None

View File

@ -13,7 +13,6 @@ from typing import Any, Callable, Coroutine, Iterator, Optional
from uuid import uuid4
from PIL import Image, UnidentifiedImageError
from pyfiglet import Figlet, FigletFont # type: ignore
from rich.segment import Segment
from rich.style import Style
from rich.text import Text
@ -27,7 +26,7 @@ from textual.dom import DOMNode
from textual.filter import LineFilter
from textual.geometry import Offset, Region, Size
from textual.message import Message
from textual.reactive import reactive, var
from textual.reactive import var
from textual.strip import Strip
from textual.widget import Widget
from textual.widgets import (Button, Header, Input, RadioButton, RadioSet,
@ -45,6 +44,7 @@ from textual_paint.ansi_art_document import (SAVE_DISABLED_FORMATS,
from textual_paint.args import args, get_help_text
from textual_paint.ascii_mode import set_ascii_only_mode
from textual_paint.auto_restart import restart_on_changes, restart_program
from textual_paint.canvas import Canvas
from textual_paint.character_picker import CharacterSelectorDialogWindow
from textual_paint.edit_colors import EditColorsDialogWindow
from textual_paint.file_dialogs import OpenDialogWindow, SaveAsDialogWindow
@ -74,58 +74,6 @@ DOUBLE_CLICK_TIME = 0.8 # seconds; overridden in tests to avoid flakiness
load_language(args.language)
class MetaGlyphFont:
"""A font where each character is drawn with sub-characters."""
def __init__(self, file_path: str, width: int, height: int, covered_characters: str):
self.file_path = file_path
"""The path to the font file."""
self.glyphs: dict[str, list[str]] = {}
"""Maps characters to meta-glyphs, where each meta-glyph is a list of rows of characters."""
self.width = width
"""The width in characters of a meta-glyph."""
self.height = height
"""The height in characters of a meta-glyph."""
self.covered_characters = covered_characters
"""The characters supported by this font."""
self.load()
def load(self):
"""Load the font from the .flf FIGlet font file."""
# fig = Figlet(font=self.file_path) # gives FontNotFound error!
# Figlet constructor only supports looking for installed fonts.
# I could install the font, with FigletFont.installFonts,
# maybe with some prefixed name, but I don't want to do that.
with open(self.file_path, encoding="utf-8") as f:
flf = f.read()
fig_font = FigletFont()
fig_font.data = flf
fig_font.loadFont()
fig = Figlet()
# fig.setFont(font=fig_font) # nope, that's also looking for a font name
fig.font = self.file_path # may not be used
fig.Font = fig_font # this feels so wrong
for char in self.covered_characters:
meta_glyph = fig.renderText(char)
self.glyphs[char] = meta_glyph.split("\n")
covered_characters = R""" !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~"""
meta_glyph_fonts: dict[int, MetaGlyphFont] = {
2: MetaGlyphFont(os.path.join(os.path.dirname(__file__), "fonts/NanoTiny/NanoTiny_v14_2x2.flf"), 2, 2, covered_characters),
# 4: MetaGlyphFont(os.path.join(os.path.dirname(__file__), "fonts/NanoTiny/NanoTiny_v14_4x4.flf"), 4, 4, covered_characters),
# TODO: less specialized (more practical) fonts for larger sizes
}
def largest_font_that_fits(max_width: int, max_height: int) -> MetaGlyphFont | None:
"""Get the largest font with glyphs that can all fit in the given dimensions."""
for font_size in sorted(meta_glyph_fonts.keys(), reverse=True):
font = meta_glyph_fonts[font_size]
if font.width <= max_width and font.height <= max_height:
return font
return None
palette = list(DEFAULT_PALETTE)
class ToolsBox(Container):
@ -322,381 +270,6 @@ def selected_text(textbox: Selection) -> str:
return text
def scale_region(region: Region, scale: int) -> Region:
"""Returns the region scaled by the given factor."""
return Region(region.x * scale, region.y * scale, region.width * scale, region.height * scale)
class Canvas(Widget):
"""The image document widget."""
magnification = reactive(1, layout=True)
show_grid = reactive(False)
# Is it kosher to include an event in a message?
# Is it better (and possible) to bubble up the event, even though I'm capturing the mouse?
# Or would it be better to just have Canvas own duplicate state for all tool parameters?
# That's what I was refactoring to avoid. So far I've made things more complicated,
# but I'm betting it will be good when implementing different tools.
# Maybe the PaintApp widget can capture the mouse events instead?
# Not sure if that would work as nicely when implementing selections.
# I'd have to think about it.
# But it would make the Canvas just be a widget for rendering, which seems good.
class ToolStart(Message):
"""Message when starting drawing."""
def __init__(self, mouse_down_event: events.MouseDown) -> None:
self.x = mouse_down_event.x
self.y = mouse_down_event.y
self.button = mouse_down_event.button
self.ctrl = mouse_down_event.ctrl
super().__init__()
class ToolUpdate(Message):
"""Message when dragging on the canvas."""
def __init__(self, mouse_move_event: events.MouseMove) -> None:
self.x = mouse_move_event.x
self.y = mouse_move_event.y
super().__init__()
class ToolStop(Message):
"""Message when releasing the mouse."""
def __init__(self, mouse_up_event: events.MouseUp) -> None:
self.x = mouse_up_event.x
self.y = mouse_up_event.y
super().__init__()
class ToolPreviewUpdate(Message):
"""Message when moving the mouse while the mouse is up."""
def __init__(self, mouse_move_event: events.MouseMove) -> None:
self.x = mouse_move_event.x
self.y = mouse_move_event.y
super().__init__()
class ToolPreviewStop(Message):
"""Message when the mouse leaves the canvas while previewing (not while drawing)."""
def __init__(self) -> None:
super().__init__()
def __init__(self, **kwargs: Any) -> None:
"""Initialize the canvas."""
super().__init__(**kwargs)
self.image: AnsiArtDocument|None = None
self.pointer_active: bool = False
self.magnifier_preview_region: Optional[Region] = None
self.select_preview_region: Optional[Region] = None
self.which_button: Optional[int] = None
def on_mouse_down(self, event: events.MouseDown) -> None:
"""Called when a mouse button is pressed.
This either starts drawing, or if both mouse buttons are pressed, cancels the current action.
"""
if self.app.has_class("view_bitmap"):
# Exiting is handled by the PaintApp.
return
self.fix_mouse_event(event) # not needed, pointer isn't captured yet.
event.x //= self.magnification
event.y //= self.magnification
if self.pointer_active and self.which_button != event.button:
assert isinstance(self.app, PaintApp)
self.app.stop_action_in_progress()
return
self.post_message(self.ToolStart(event))
self.pointer_active = True
self.which_button = event.button
self.capture_mouse(True)
def fix_mouse_event(self, event: events.MouseEvent) -> None:
"""Work around inconsistent widget-relative mouse coordinates by calculating from screen coordinates."""
# Hack to fix mouse coordinates, not needed for mouse down,
# or while the mouse is up.
# This seems like a bug.
# I think it's due to coordinates being calculated differently during mouse capture.
# if self.pointer_active:
# assert isinstance(self.parent, Widget)
# event.x += int(self.parent.scroll_x)
# event.y += int(self.parent.scroll_y)
# The above fix sometimes works but maybe sometimes shouldn't apply or isn't right.
# In order to make this robust without knowing the exact cause,
# I'm going to always calculate straight from the screen coordinates.
# This should also make it robust against the bugs in the library being fixed.
# node: DOMNode|None = self
offset = event.screen_offset
# while node:
# offset = offset - node.offset
# node = node.parent
# assert isinstance(self.parent, Widget)
offset = offset - self.region.offset #+ Offset(int(self.parent.scroll_x), int(self.parent.scroll_y))
event.x = offset.x
event.y = offset.y
def on_mouse_move(self, event: events.MouseMove) -> None:
"""Called when the mouse is moved. Update the tool action or preview."""
self.fix_mouse_event(event)
event.x //= self.magnification
event.y //= self.magnification
if self.pointer_active:
self.post_message(self.ToolUpdate(event))
else:
# I put this in the else block just for performance.
# Hopefully it wouldn't matter much, but
# the pointer should never be active in View Bitmap mode.
if self.app.has_class("view_bitmap"):
return
self.post_message(self.ToolPreviewUpdate(event))
def on_mouse_up(self, event: events.MouseUp) -> None:
"""Called when a mouse button is released. Stop the current tool."""
self.fix_mouse_event(event)
event.x //= self.magnification
event.y //= self.magnification
if self.pointer_active:
self.post_message(self.ToolStop(event))
self.pointer_active = False
self.capture_mouse(False)
def on_leave(self, event: events.Leave) -> None:
"""Called when the mouse leaves the canvas. Stop preview if applicable."""
if not self.pointer_active:
self.post_message(self.ToolPreviewStop())
def get_content_width(self, container: Size, viewport: Size) -> int:
"""Defines the intrinsic width of the widget."""
if self.image is None:
return 0 # shouldn't really happen
return self.image.width * self.magnification
def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
"""Defines the intrinsic height of the widget."""
if self.image is None:
return 0 # shouldn't really happen
return self.image.height * self.magnification
def render_line(self, y: int) -> Strip:
"""Render a line of the widget. y is relative to the top of the widget."""
assert self.image is not None
# self.size.width/height already is multiplied by self.magnification.
if y >= self.size.height:
return Strip.blank(self.size.width)
segments: list[Segment] = []
sel = self.image.selection
# Avoiding "possibly unbound" errors.
magnifier_preview_region = None
inner_magnifier_preview_region = None
select_preview_region = None
inner_select_preview_region = None
selection_region = None
inner_selection_region = None
if self.magnifier_preview_region:
magnifier_preview_region = scale_region(self.magnifier_preview_region, self.magnification)
inner_magnifier_preview_region = magnifier_preview_region.shrink((1, 1, 1, 1))
if self.select_preview_region:
select_preview_region = scale_region(self.select_preview_region, self.magnification)
inner_select_preview_region = select_preview_region.shrink((1, 1, 1, 1))
if sel:
selection_region = scale_region(sel.region, self.magnification)
inner_selection_region = selection_region.shrink((1, 1, 1, 1))
for x in range(self.size.width):
cell_x = x // self.magnification
cell_y = y // self.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]
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]
except IndexError:
# This should be easier to debug visually.
bg = "#555555"
fg = "#cccccc"
ch = "?"
if self.magnification > 1:
ch = self.big_ch(ch, x % self.magnification, y % self.magnification)
if self.show_grid and self.magnification >= 4:
if x % self.magnification == 0 or y % self.magnification == 0:
# Not setting `bg` 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.
fg = "#c0c0c0" if (x + y) % 2 == 0 else "#808080"
if x % self.magnification == 0 and y % self.magnification == 0:
ch = "+" if args.ascii_only else "" # "┼" # (🭽 may render as wide)
elif x % self.magnification == 0:
ch = "|" if args.ascii_only else "" # "┆" # (▏, not 🭰)
elif y % self.magnification == 0:
ch = "-" if args.ascii_only else "" # "┄" # (▔, not 🭶)
style = Style(color=fg, bgcolor=bg)
assert style.color is not None
assert style.bgcolor is not None
def within_text_selection_highlight(textbox: Selection) -> int:
if cell_x >= textbox.region.right or cell_x < textbox.region.x:
# Prevent inverting outside the textbox.
return False
def offset_to_text_index(offset: Offset) -> int:
return offset.y * textbox.region.width + offset.x
start_index = offset_to_text_index(textbox.text_selection_start)
end_index = offset_to_text_index(textbox.text_selection_end)
min_index = min(start_index, end_index)
max_index = max(start_index, end_index)
cell_index = offset_to_text_index(Offset(cell_x, cell_y) - textbox.region.offset)
return min_index <= cell_index <= max_index
assert isinstance(self.app, PaintApp)
if (
(self.magnifier_preview_region and magnifier_preview_region.contains(x, y) and (not inner_magnifier_preview_region.contains(x, y))) or # type: ignore
(self.select_preview_region and select_preview_region.contains(x, y) and (not inner_select_preview_region.contains(x, y))) or # type: ignore
(sel and (not sel.textbox_mode) and (self.app.selection_drag_offset is None) and selection_region.contains(x, y) and (not inner_selection_region.contains(x, y))) or # type: ignore
(sel and sel.textbox_mode and within_text_selection_highlight(sel))
):
# invert the colors
inverse_color = f"rgb({255 - style.color.triplet.red},{255 - style.color.triplet.green},{255 - style.color.triplet.blue})"
inverse_bgcolor = f"rgb({255 - style.bgcolor.triplet.red},{255 - style.bgcolor.triplet.green},{255 - style.bgcolor.triplet.blue})"
style = Style(color=inverse_color, bgcolor=inverse_bgcolor)
segments.append(Segment(ch, style))
return Strip(segments, self.size.width)
def refresh_scaled_region(self, region: Region) -> None:
"""Refresh a region of the widget, scaled by the magnification."""
if self.magnification == 1:
self.refresh(region)
return
# TODO: are these offsets needed? I added them because of a problem which I've fixed
self.refresh(Region(
(region.x - 1) * self.magnification,
(region.y - 1) * self.magnification,
(region.width + 2) * self.magnification,
(region.height + 2) * self.magnification,
))
def watch_magnification(self) -> None:
"""Called when magnification changes."""
self.active_meta_glyph_font = largest_font_that_fits(self.magnification, self.magnification)
def big_ch(self, ch: str, x: int, y: int) -> str:
"""Return a character part of a meta-glyph."""
if self.active_meta_glyph_font and ch in self.active_meta_glyph_font.glyphs:
glyph_lines = self.active_meta_glyph_font.glyphs[ch]
x -= (self.magnification - self.active_meta_glyph_font.width) // 2
y -= (self.magnification - self.active_meta_glyph_font.height) // 2
if y >= len(glyph_lines) or y < 0:
return " "
glyph_line = glyph_lines[y]
if x >= len(glyph_line) or x < 0:
return " "
return glyph_line[x]
if ch in " ░▒▓█":
return ch
match ch:
# These are now obsolete special cases of below fractional block character handling.
# case "▄":
# return "█" if y >= self.magnification // 2 else " "
# case "▀":
# return "█" if y < self.magnification // 2 else " "
# case "▌":
# return "█" if x < self.magnification // 2 else " "
# case "▐":
# return "█" if x >= self.magnification // 2 else " "
# Corner triangles
case "":
diagonal = x - y
return "" if diagonal < 0 else " " if diagonal > 0 else ""
case "":
diagonal = x - y
return "" if diagonal > 0 else " " if diagonal < 0 else ""
case "":
diagonal = x + y + 1 - self.magnification
return "" if diagonal > 0 else " " if diagonal < 0 else ""
case "":
diagonal = x + y + 1 - self.magnification
return "" if diagonal < 0 else " " if diagonal > 0 else ""
case "":
diagonal = x + y + 1 - self.magnification
return "" if diagonal == 0 else " "
case "":
diagonal = x - y
return "" if diagonal == 0 else " "
case "":
diagonal_1 = x + y + 1 - self.magnification
diagonal_2 = x - y
return "" if diagonal_2 == 0 else "" if diagonal_1 == 0 else " "
case "/":
diagonal = x + y + 1 - self.magnification
return "/" if diagonal == 0 else " "
case "\\":
diagonal = x - y
return "\\" if diagonal == 0 else " "
# Fractional blocks
# These are at the end because `in` may be slow.
# Note: the order of the gradient strings is chosen so that
# the dividing line is at the top/left at index 0.
case ch if ch in "█▇▆▅▄▃▂▁":
gradient = "█▇▆▅▄▃▂▁ "
index = gradient.index(ch)
threshold_y = int(index / 8 * self.magnification)
if y == threshold_y:
# Within the threshold cell, which is at y here,
# use one of the fractional characters.
# If you look at a 3/8ths character, to scale it up 2x,
# you need a 6/8ths character. It simply scales with the magnification.
# If you look at a 6/8ths character, to scale it up 2x,
# you need a full block and a 4/8ths character, 4/8ths being the threshold cell here,
# so it needs to wrap around, taking the remainder.
return gradient[index * self.magnification % 8]
elif y > threshold_y:
return ""
else:
return " "
case ch if ch in "▏▎▍▌▋▊▉█":
gradient = " ▏▎▍▌▋▊▉█"
index = gradient.index(ch)
threshold_x = int(index / 8 * self.magnification)
if x == threshold_x:
return gradient[index * self.magnification % 8]
elif x < threshold_x:
return ""
else:
return " "
case ch if ch in "▔🮂🮃▀🮄🮅🮆█":
gradient = " ▔🮂🮃▀🮄🮅🮆█"
index = gradient.index(ch)
threshold_y = int(index / 8 * self.magnification)
if y == threshold_y:
return gradient[index * self.magnification % 8]
elif y < threshold_y:
return ""
else:
return " "
case ch if ch in "█🮋🮊🮉▐🮈🮇▕":
gradient = "█🮋🮊🮉▐🮈🮇▕ "
index = gradient.index(ch)
threshold_x = int(index / 8 * self.magnification)
if x == threshold_x:
return gradient[index * self.magnification % 8]
elif x > threshold_x:
return ""
else:
return " "
case _: pass
# Fall back to showing the character in a single cell, approximately centered.
if x == self.magnification // 2 and y == self.magnification // 2:
return ch
else:
return " "
class PaintApp(App[None]):
"""MS Paint like image editor in the terminal."""