Remove all trailing whitespace

using regular expression: \s+$
This commit is contained in:
Isaiah Odhner 2023-09-04 21:43:57 -04:00
parent bd6ce9b3a7
commit c9757a4549
17 changed files with 180 additions and 180 deletions

View File

@ -382,7 +382,7 @@ cspell-cli lint .
# Type checking # Type checking
# I use the "Python" and "Pylance" VS Code extensions, and the Pyright CLI: # I use the "Python" and "Pylance" VS Code extensions, and the Pyright CLI:
pyright pyright
# It should give 0 errors at this version of Pyright: # It should give 0 errors at this version of Pyright:
PYRIGHT_PYTHON_FORCE_VERSION=1.1.317 pyright PYRIGHT_PYTHON_FORCE_VERSION=1.1.317 pyright
# It gives 508 errors with the next version (the current latest) for some reason: # It gives 508 errors with the next version (the current latest) for some reason:

View File

@ -86,7 +86,7 @@ def write_ansi_file(file: TextIO) -> None:
write(BOX_VERTICAL) write(BOX_VERTICAL)
write(f'\u001b[{start_y + k + 1};{start_x + box_outer_width - 1}H') write(f'\u001b[{start_y + k + 1};{start_x + box_outer_width - 1}H')
write(BOX_VERTICAL) write(BOX_VERTICAL)
# Write the character in the center of the box # Write the character in the center of the box
# write(f'\u001b[{start_y + box_inner_height // 2 + 1};{start_x + box_inner_width // 2 - 1}H') # write(f'\u001b[{start_y + box_inner_height // 2 + 1};{start_x + box_inner_width // 2 - 1}H')
# write(character) # write(character)

View File

@ -54,7 +54,7 @@ def generate_ansi_art(width: int, height: int, file: TextIO) -> None:
# Write the colored glyph to the file # Write the colored glyph to the file
file.write(color + GLYPHS[glyph_index]) file.write(color + GLYPHS[glyph_index])
# Reset the color at the end of each row and add a newline character # Reset the color at the end of each row and add a newline character
file.write(RESET + '\n') file.write(RESET + '\n')

View File

@ -46,7 +46,7 @@ def extract_textures(image_path: str):
# Create a new image to store the extracted textures # Create a new image to store the extracted textures
extracted_image = Image.new('RGB', (num_textures_x * texture_width, num_textures_y * texture_height)) extracted_image = Image.new('RGB', (num_textures_x * texture_width, num_textures_y * texture_height))
half_size_meta_glyphs: dict[int, str] = {} half_size_meta_glyphs: dict[int, str] = {}
full_size_meta_glyphs: dict[int, str] = {} full_size_meta_glyphs: dict[int, str] = {}
@ -98,9 +98,9 @@ def extract_textures(image_path: str):
# Add a newline after each row # Add a newline after each row
extracted_text_half += '\n' extracted_text_half += '\n'
half_size_meta_glyphs[ordinal] = extracted_text_half half_size_meta_glyphs[ordinal] = extracted_text_half
# Extract as full-size FIGlet font # Extract as full-size FIGlet font
extracted_text_full = "" extracted_text_full = ""
for y in range(texture_height): for y in range(texture_height):
@ -109,9 +109,9 @@ def extract_textures(image_path: str):
# Add a newline after each row # Add a newline after each row
extracted_text_full += '\n' extracted_text_full += '\n'
full_size_meta_glyphs[ordinal] = extracted_text_full full_size_meta_glyphs[ordinal] = extracted_text_full
for figChars in [half_size_meta_glyphs, full_size_meta_glyphs]: for figChars in [half_size_meta_glyphs, full_size_meta_glyphs]:
# Fill in the space characters with hard blanks # Fill in the space characters with hard blanks
# figChars[32] = figChars[32].replace(' ', '$') # figChars[32] = figChars[32].replace(' ', '$')
@ -123,7 +123,7 @@ def extract_textures(image_path: str):
# although it won't look pretty having the dollar signs scattered in the font file. # although it won't look pretty having the dollar signs scattered in the font file.
for ordinal in figChars: for ordinal in figChars:
figChars[ordinal] = '\n'.join([row.rstrip() + '$' for row in figChars[ordinal].split('\n')]) figChars[ordinal] = '\n'.join([row.rstrip() + '$' for row in figChars[ordinal].split('\n')])
shared_comment_lines = [ shared_comment_lines = [
"by Isaiah Odhner", "by Isaiah Odhner",
"", "",

View File

@ -46,7 +46,7 @@ def update_cli_help_on_readme():
parser.formatter_class = lambda prog: argparse.HelpFormatter(prog, width=width) parser.formatter_class = lambda prog: argparse.HelpFormatter(prog, width=width)
help_text = parser.format_help() help_text = parser.format_help()
parser.formatter_class = old_formatter_class parser.formatter_class = old_formatter_class
md = f.read() md = f.read()
start_match = readme_help_start.search(md) start_match = readme_help_start.search(md)
if start_match is None: if start_match is None:

View File

@ -59,7 +59,7 @@ def restart_program() -> None:
def restart_on_changes(app: PaintApp) -> None: def restart_on_changes(app: PaintApp) -> None:
"""Restarts the current program when a file is changed""" """Restarts the current program when a file is changed"""
from watchdog.events import PatternMatchingEventHandler, FileSystemEvent, EVENT_TYPE_CLOSED, EVENT_TYPE_OPENED from watchdog.events import PatternMatchingEventHandler, FileSystemEvent, EVENT_TYPE_CLOSED, EVENT_TYPE_OPENED
from watchdog.observers import Observer from watchdog.observers import Observer

View File

@ -50,7 +50,7 @@ class ColorGrid(Container):
class Changed(Message): class Changed(Message):
"""A message that is sent when the selected color changes.""" """A message that is sent when the selected color changes."""
def __init__(self, color: str, color_grid: "ColorGrid", index: int) -> None: def __init__(self, color: str, color_grid: "ColorGrid", index: int) -> None:
"""Initialize the message.""" """Initialize the message."""
super().__init__() super().__init__()
@ -68,7 +68,7 @@ class ColorGrid(Container):
self._color_by_button: dict[Button, str] = {} self._color_by_button: dict[Button, str] = {}
self.color_list = color_list # This immediately calls `watch_color_list`. self.color_list = color_list # This immediately calls `watch_color_list`.
self.can_focus = True self.can_focus = True
def on_mount(self) -> None: def on_mount(self) -> None:
"""Called when the window is mounted.""" """Called when the window is mounted."""
found_match = False found_match = False
@ -107,7 +107,7 @@ class ColorGrid(Container):
self._navigate_absolute(len(self.color_list) - 1) self._navigate_absolute(len(self.color_list) - 1)
elif event.key in ("space", "enter"): elif event.key in ("space", "enter"):
self._select_focused_color() self._select_focused_color()
def _select_focused_color(self) -> None: def _select_focused_color(self) -> None:
try: try:
focused = self.query_one(".focused", Button) focused = self.query_one(".focused", Button)
@ -119,7 +119,7 @@ class ColorGrid(Container):
self.selected_color = self._color_by_button[focused] self.selected_color = self._color_by_button[focused]
index = list(self._color_by_button.keys()).index(focused) index = list(self._color_by_button.keys()).index(focused)
self.post_message(self.Changed(self.selected_color, self, index)) self.post_message(self.Changed(self.selected_color, self, index))
def _navigate_relative(self, delta: int) -> None: def _navigate_relative(self, delta: int) -> None:
"""Navigate to a color relative to the currently focused color.""" """Navigate to a color relative to the currently focused color."""
try: try:
@ -144,7 +144,7 @@ class ColorGrid(Container):
for button in self._color_by_button: for button in self._color_by_button:
button.remove_class("focused") button.remove_class("focused")
target_button.add_class("focused") target_button.add_class("focused")
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
"""Called when a button is clicked or activated with the keyboard.""" """Called when a button is clicked or activated with the keyboard."""
self.selected_color = self._color_by_button[event.button] self.selected_color = self._color_by_button[event.button]
@ -207,7 +207,7 @@ class LuminosityRamp(Widget):
self._update_color(event.y) self._update_color(event.y)
self._mouse_down = True self._mouse_down = True
self.capture_mouse() self.capture_mouse()
def on_mouse_up(self, event: events.MouseUp) -> None: def on_mouse_up(self, event: events.MouseUp) -> None:
"""Called when the mouse is released.""" """Called when the mouse is released."""
self.release_mouse() self.release_mouse()
@ -217,7 +217,7 @@ class LuminosityRamp(Widget):
"""Called when the mouse is moved.""" """Called when the mouse is moved."""
if self._mouse_down: if self._mouse_down:
self._update_color(event.y) self._update_color(event.y)
def _update_color(self, y: int) -> None: def _update_color(self, y: int) -> None:
"""Update the color based on the given y coordinate.""" """Update the color based on the given y coordinate."""
self.luminosity = max(0, min(1, 1 - y / (self.size.height - 1))) self.luminosity = max(0, min(1, 1 - y / (self.size.height - 1)))
@ -268,7 +268,7 @@ class ColorField(Widget):
self._update_color(event.offset) self._update_color(event.offset)
self._mouse_down = True self._mouse_down = True
self.capture_mouse() self.capture_mouse()
def on_mouse_up(self, event: events.MouseUp) -> None: def on_mouse_up(self, event: events.MouseUp) -> None:
"""Called when the mouse is released.""" """Called when the mouse is released."""
self.release_mouse() self.release_mouse()
@ -278,7 +278,7 @@ class ColorField(Widget):
"""Called when the mouse is moved.""" """Called when the mouse is moved."""
if self._mouse_down: if self._mouse_down:
self._update_color(event.offset) self._update_color(event.offset)
def _update_color(self, offset: Offset) -> None: def _update_color(self, offset: Offset) -> None:
"""Update the color based on the given offset.""" """Update the color based on the given offset."""
x, y = offset x, y = offset
@ -310,7 +310,7 @@ class IntegerInput(Input):
self.min = min self.min = min
self.max = max self.max = max
self.last_valid_int = 0 self.last_valid_int = 0
def _track_valid_int(self, value: str) -> int: def _track_valid_int(self, value: str) -> int:
try: try:
value_as_int = int(value) value_as_int = int(value)
@ -348,7 +348,7 @@ class EditColorsDialogWindow(DialogWindow):
self._inputs_by_letter: dict[str, IntegerInput] = {} self._inputs_by_letter: dict[str, IntegerInput] = {}
self._custom_colors_index = 0 self._custom_colors_index = 0
self.handle_selected_color = handle_selected_color self.handle_selected_color = handle_selected_color
def handle_button(self, button: Button) -> None: def handle_button(self, button: Button) -> None:
"""Called when a button is clicked or activated with the keyboard.""" """Called when a button is clicked or activated with the keyboard."""
if button.has_class("cancel"): if button.has_class("cancel"):

View File

@ -24,7 +24,7 @@ class EnhancedDirectoryTree(DirectoryTree):
node_highlighted_by_expand_to_path = var(False) node_highlighted_by_expand_to_path = var(False)
"""Whether a NodeHighlighted event was triggered by expand_to_path. """Whether a NodeHighlighted event was triggered by expand_to_path.
(An alternative would be to create a new message type wrapping `NodeHighlighted`, (An alternative would be to create a new message type wrapping `NodeHighlighted`,
which includes a flag.) which includes a flag.)
(Also, this could be a simple attribute, but I didn't want to make an `__init__` (Also, this could be a simple attribute, but I didn't want to make an `__init__`
@ -55,7 +55,7 @@ class EnhancedDirectoryTree(DirectoryTree):
# * (adds NodeHighlighted to queue) # * (adds NodeHighlighted to queue)
# * clear flag # * clear flag
# * on_tree_node_highlighted # * on_tree_node_highlighted
# #
# So instead, listen for NodeHighlighted, # So instead, listen for NodeHighlighted,
# and then clear the flag. # and then clear the flag.
@ -69,7 +69,7 @@ class EnhancedDirectoryTree(DirectoryTree):
def on_tree_node_highlighted(self, event: Tree.NodeHighlighted[DirEntry]) -> None: def on_tree_node_highlighted(self, event: Tree.NodeHighlighted[DirEntry]) -> None:
"""Called when a node is highlighted in the DirectoryTree. """Called when a node is highlighted in the DirectoryTree.
This handler is used to clear the flag set by expand_to_path. This handler is used to clear the flag set by expand_to_path.
See _go_to_node for more details. See _go_to_node for more details.
""" """
@ -88,7 +88,7 @@ class EnhancedDirectoryTree(DirectoryTree):
def _expand_matching_child(self, node: TreeNode[DirEntry], remaining_parts: tuple[str], callback: Callable[[], None]) -> None: def _expand_matching_child(self, node: TreeNode[DirEntry], remaining_parts: tuple[str], callback: Callable[[], None]) -> None:
"""Hooks into DirectoryTree's add method, and expands the child node matching the next path part, recursively. """Hooks into DirectoryTree's add method, and expands the child node matching the next path part, recursively.
Once the last part of the path is reached, it scrolls to and selects the node. Once the last part of the path is reached, it scrolls to and selects the node.
""" """
# print("_expand_matching_child", node, remaining_parts) # print("_expand_matching_child", node, remaining_parts)

View File

@ -29,7 +29,7 @@ from enum import Enum
class FIGletFontWriter: class FIGletFontWriter:
"""Used to write FIGlet fonts. """Used to write FIGlet fonts.
createFigFileData() returns a string that can be written to a .flf file. createFigFileData() returns a string that can be written to a .flf file.
It can automatically fix some common problems with FIGlet fonts, such as It can automatically fix some common problems with FIGlet fonts, such as
@ -52,7 +52,7 @@ class FIGletFontWriter:
charOrder: list[int] = [ii for ii in range(32, 127)] + [196, 214, 220, 228, 246, 252, 223] charOrder: list[int] = [ii for ii in range(32, 127)] + [196, 214, 220, 228, 246, 252, 223]
R"""Character codes that are required to be in any FIGlet font. R"""Character codes that are required to be in any FIGlet font.
Printable portion of the ASCII character set: Printable portion of the ASCII character set:
32 (blank/space) 64 @ 96 ` 32 (blank/space) 64 @ 96 `
33 ! 65 A 97 a 33 ! 65 A 97 a
@ -144,55 +144,55 @@ Additional characters must use code tagged characters, which are not yet support
self.figChars: dict[int, str] = figChars self.figChars: dict[int, str] = figChars
"""Dictionary that maps character codes to FIGcharacter strings.""" """Dictionary that maps character codes to FIGcharacter strings."""
self.height = height self.height = height
"""Height of a FIGcharacter, in sub-characters.""" """Height of a FIGcharacter, in sub-characters."""
self.baseline = baseline self.baseline = baseline
"""Distance from the top of the FIGcharacter to the baseline. If not specified, defaults to height.""" """Distance from the top of the FIGcharacter to the baseline. If not specified, defaults to height."""
self.maxLength = maxLength self.maxLength = maxLength
"""Maximum length of a line INCLUDING two endMark characters.""" """Maximum length of a line INCLUDING two endMark characters."""
self.commentLines: list[str] = commentLines self.commentLines: list[str] = commentLines
"""List of comment lines to be included in the header. It's recommended to include at least the name of the font and the name of the author.""" """List of comment lines to be included in the header. It's recommended to include at least the name of the font and the name of the author."""
self.rightToLeft = rightToLeft self.rightToLeft = rightToLeft
"""Indicates RTL writing direction (or LTR if False).""" """Indicates RTL writing direction (or LTR if False)."""
self.codeTagCount = codeTagCount self.codeTagCount = codeTagCount
"""Number of extra FIGcharacters included in the font (in addition to the required 102 untagged characters). Outputting tagged characters is not yet supported.""" """Number of extra FIGcharacters included in the font (in addition to the required 102 untagged characters). Outputting tagged characters is not yet supported."""
self.hardBlank = hardBlank self.hardBlank = hardBlank
"""Character rendered as a space which can prevent smushing.""" """Character rendered as a space which can prevent smushing."""
self.endMark = endMark self.endMark = endMark
"""Denotes the end of a line. Two of these characters in a row denotes the end of a FIGcharacter.""" """Denotes the end of a line. Two of these characters in a row denotes the end of a FIGcharacter."""
self.horizontalLayout = horizontalLayout self.horizontalLayout = horizontalLayout
"""Defines how FIGcharacters are spaced horizontally.""" """Defines how FIGcharacters are spaced horizontally."""
self.verticalLayout = verticalLayout self.verticalLayout = verticalLayout
"""Defines how FIGcharacters are spaced vertically.""" """Defines how FIGcharacters are spaced vertically."""
self.hRule = [False] * 7 self.hRule = [False] * 7
"""Horizontal Smushing Rules, 1-6 (0 is not used, so that indices correspond with the names of the parameters). """Horizontal Smushing Rules, 1-6 (0 is not used, so that indices correspond with the names of the parameters).
horizontalLayout must be Layout.CONTROLLED_SMUSHING for these to take effect.""" horizontalLayout must be Layout.CONTROLLED_SMUSHING for these to take effect."""
self.vRule = [False] * 6 self.vRule = [False] * 6
"""Vertical Smushing Rules, 1-5 (0 is not used, so that indices correspond with the names of the parameters). """Vertical Smushing Rules, 1-5 (0 is not used, so that indices correspond with the names of the parameters).
verticalLayout must be Layout.CONTROLLED_SMUSHING for these to take effect.""" verticalLayout must be Layout.CONTROLLED_SMUSHING for these to take effect."""
self.caseInsensitive = caseInsensitive self.caseInsensitive = caseInsensitive
"""Makes lowercase same as uppercase. Note that this is one-way overwrite. It doesn't check if a character already exists, and it won't fill in uppercase using lowercase.""" """Makes lowercase same as uppercase. Note that this is one-way overwrite. It doesn't check if a character already exists, and it won't fill in uppercase using lowercase."""
self._validateOptions() self._validateOptions()
def _validateOptions(self) -> None: def _validateOptions(self) -> None:
"""Called on init and before generating a font file. """Called on init and before generating a font file.
See also _fixFigChars() which actively fixes things. See also _fixFigChars() which actively fixes things.
""" """
# Check enums # Check enums

View File

@ -14,7 +14,7 @@ from .enhanced_directory_tree import EnhancedDirectoryTree
class FileDialogWindow(DialogWindow): class FileDialogWindow(DialogWindow):
"""A dialog window that lets the user select a file.""" """A dialog window that lets the user select a file."""
def __init__( def __init__(
self, self,
*children: Widget, *children: Widget,
@ -99,12 +99,12 @@ class FileDialogWindow(DialogWindow):
def on_tree_node_highlighted(self, event: Tree.NodeHighlighted[DirEntry]) -> None: def on_tree_node_highlighted(self, event: Tree.NodeHighlighted[DirEntry]) -> None:
""" """
Called when a file/folder is selected in the DirectoryTree. Called when a file/folder is selected in the DirectoryTree.
This message comes from Tree. This message comes from Tree.
DirectoryTree gives FileSelected, but only for files, not folders. DirectoryTree gives FileSelected, but only for files, not folders.
""" """
assert event.node.data assert event.node.data
if event.node.data.path.is_dir(): if event.node.data.path.is_dir():
self._directory_tree_selected_path = str(event.node.data.path) self._directory_tree_selected_path = str(event.node.data.path)
elif event.node.parent: elif event.node.parent:
@ -128,7 +128,7 @@ class FileDialogWindow(DialogWindow):
class OpenDialogWindow(FileDialogWindow): class OpenDialogWindow(FileDialogWindow):
"""A dialog window that lets the user select a file to open. """A dialog window that lets the user select a file to open.
`handle_selected_file_path` is called when the user clicks the Open button, `handle_selected_file_path` is called when the user clicks the Open button,
and the window is NOT closed in that case. and the window is NOT closed in that case.
""" """
@ -152,7 +152,7 @@ class OpenDialogWindow(FileDialogWindow):
class SaveAsDialogWindow(FileDialogWindow): class SaveAsDialogWindow(FileDialogWindow):
"""A dialog window that lets the user select a file to save to. """A dialog window that lets the user select a file to save to.
`handle_selected_file_path` is called when the user clicks the Save button, `handle_selected_file_path` is called when the user clicks the Save button,
and the window is NOT closed in that case. and the window is NOT closed in that case.
""" """

View File

@ -19,7 +19,7 @@ with others.
The OFL allows the licensed fonts to be used, studied, modified and The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded, fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives, names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The however, cannot be released under any other type of license. The

View File

@ -125,13 +125,13 @@ def midpoint_ellipse(xc: int, yc: int, rx: int, ry: int) -> Iterator[tuple[int,
x = 0 x = 0
y = ry y = ry
# Initial decision parameter of region 1 # Initial decision parameter of region 1
d1 = ((ry * ry) - (rx * rx * ry) + d1 = ((ry * ry) - (rx * rx * ry) +
(0.25 * rx * rx)) (0.25 * rx * rx))
dx = 2 * ry * ry * x dx = 2 * ry * ry * x
dy = 2 * rx * rx * y dy = 2 * rx * rx * y
# For region 1 # For region 1
while (dx < dy): while (dx < dy):
# Yield points based on 4-way symmetry # Yield points based on 4-way symmetry
@ -139,7 +139,7 @@ def midpoint_ellipse(xc: int, yc: int, rx: int, ry: int) -> Iterator[tuple[int,
yield -x + xc, y + yc yield -x + xc, y + yc
yield x + xc, -y + yc yield x + xc, -y + yc
yield -x + xc, -y + yc yield -x + xc, -y + yc
# Checking and updating value of # Checking and updating value of
# decision parameter based on algorithm # decision parameter based on algorithm
if (d1 < 0): if (d1 < 0):
@ -152,12 +152,12 @@ def midpoint_ellipse(xc: int, yc: int, rx: int, ry: int) -> Iterator[tuple[int,
dx = dx + (2 * ry * ry) dx = dx + (2 * ry * ry)
dy = dy - (2 * rx * rx) dy = dy - (2 * rx * rx)
d1 = d1 + dx - dy + (ry * ry) d1 = d1 + dx - dy + (ry * ry)
# Decision parameter of region 2 # Decision parameter of region 2
d2 = (((ry * ry) * ((x + 0.5) * (x + 0.5))) + d2 = (((ry * ry) * ((x + 0.5) * (x + 0.5))) +
((rx * rx) * ((y - 1) * (y - 1))) - ((rx * rx) * ((y - 1) * (y - 1))) -
(rx * rx * ry * ry)) (rx * rx * ry * ry))
# Plotting points of region 2 # Plotting points of region 2
while (y >= 0): while (y >= 0):
# Yielding points based on 4-way symmetry # Yielding points based on 4-way symmetry
@ -165,7 +165,7 @@ def midpoint_ellipse(xc: int, yc: int, rx: int, ry: int) -> Iterator[tuple[int,
yield -x + xc, y + yc yield -x + xc, y + yc
yield x + xc, -y + yc yield x + xc, -y + yc
yield -x + xc, -y + yc yield -x + xc, -y + yc
# Checking and updating parameter # Checking and updating parameter
# value based on algorithm # value based on algorithm
if (d2 > 0): if (d2 > 0):

View File

@ -152,7 +152,7 @@ def subtract_multiple_regions(base: Region, negations: Iterable[Region]) -> list
class DOMTree(Tree[DOMNode]): class DOMTree(Tree[DOMNode]):
"""A widget that displays the widget hierarchy.""" """A widget that displays the widget hierarchy."""
# TODO: live update # TODO: live update
class Hovered(Message, bubble=True): class Hovered(Message, bubble=True):
"""Posted when a node in the tree is hovered with the mouse or highlighted with the keyboard. """Posted when a node in the tree is hovered with the mouse or highlighted with the keyboard.
@ -277,7 +277,7 @@ class DOMTree(Tree[DOMNode]):
self.post_message(self.Hovered(self, node, node.data)) self.post_message(self.Hovered(self, node, node.data))
else: else:
self.post_message(self.Hovered(self, None, None)) self.post_message(self.Hovered(self, None, None))
def on_leave(self, event: events.Leave) -> None: def on_leave(self, event: events.Leave) -> None:
"""Handle the mouse leaving the tree.""" """Handle the mouse leaving the tree."""
self.hover_line = -1 self.hover_line = -1
@ -343,7 +343,7 @@ class PropertiesTree(Tree[object]):
self._already_loaded: dict[TreeNode[object], set[str]] = {} self._already_loaded: dict[TreeNode[object], set[str]] = {}
"""A mapping of tree nodes to the keys that have already been loaded. """A mapping of tree nodes to the keys that have already been loaded.
This allows the tree to be collapsed and expanded without duplicating nodes. This allows the tree to be collapsed and expanded without duplicating nodes.
It's also used for lazy-loading nodes when clicking the ellipsis in long lists... It's also used for lazy-loading nodes when clicking the ellipsis in long lists...
""" """
@ -403,11 +403,11 @@ class PropertiesTree(Tree[object]):
"a_frame": inspect.currentframe(), "a_frame": inspect.currentframe(),
"a_traceback": traceback.extract_stack(), "a_traceback": traceback.extract_stack(),
} }
@property @property
def AAA_test_property_that_raises_exception(self) -> str: def AAA_test_property_that_raises_exception(self) -> str:
"""This property raises an exception when accessed. """This property raises an exception when accessed.
Navigate to this node in the DOM Tree and look in the Properties Panel to see the error message. Navigate to this node in the DOM Tree and look in the Properties Panel to see the error message.
""" """
raise Exception("EMIT: Error Message Itself Test") raise Exception("EMIT: Error Message Itself Test")
@ -420,7 +420,7 @@ class PropertiesTree(Tree[object]):
def _populate_node(self, node: TreeNode[object], load_more: bool = False) -> None: def _populate_node(self, node: TreeNode[object], load_more: bool = False) -> None:
"""Populate a node with its children, or some of them. """Populate a node with its children, or some of them.
If load_more is True (ellipsis node clicked), load more children. If load_more is True (ellipsis node clicked), load more children.
Otherwise just load an initial batch. Otherwise just load an initial batch.
If the node is collapsed and re-expanded, no new nodes should be added. If the node is collapsed and re-expanded, no new nodes should be added.
@ -442,7 +442,7 @@ class PropertiesTree(Tree[object]):
ellipsis_node: TreeNode[object] | None = None ellipsis_node: TreeNode[object] | None = None
"""Node to show more properties when clicked.""" """Node to show more properties when clicked."""
only_counting = False only_counting = False
"""Flag set when we've reached the limit and aren't adding any more nodes.""" """Flag set when we've reached the limit and aren't adding any more nodes."""
@ -477,7 +477,7 @@ class PropertiesTree(Tree[object]):
iterator = map(with_no_error, enumerate(data)) # type: ignore iterator = map(with_no_error, enumerate(data)) # type: ignore
else: else:
iterator = safe_dir_items(data) # type: ignore iterator = safe_dir_items(data) # type: ignore
self._num_keys_accessed[node] = 0 self._num_keys_accessed[node] = 0
for key, value, exception in iterator: for key, value, exception in iterator:
count += 1 count += 1
@ -600,7 +600,7 @@ class NodeInfo(Container):
class StaticWithLinkSupport(Static): class StaticWithLinkSupport(Static):
"""Static text that supports DOM node links and file opening links. """Static text that supports DOM node links and file opening links.
This class exists because actions can't target an arbitrary parent. This class exists because actions can't target an arbitrary parent.
The only supported namespaces are `screen` and `app`. The only supported namespaces are `screen` and `app`.
So action_select_node has to be defined directly on the widget that So action_select_node has to be defined directly on the widget that
@ -620,7 +620,7 @@ class NodeInfo(Container):
if dom_node is None: if dom_node is None:
return return
self.post_message(NodeInfo.FollowLinkToNode(dom_node)) self.post_message(NodeInfo.FollowLinkToNode(dom_node))
def action_open_file(self, path: str, line_number: int | None = None, column_number: int | None = None) -> None: def action_open_file(self, path: str, line_number: int | None = None, column_number: int | None = None) -> None:
"""Open a file.""" """Open a file."""
# print("action_open_file", path, line_number, column_number) # print("action_open_file", path, line_number, column_number)
@ -726,7 +726,7 @@ class NodeInfo(Container):
selector_set = rule_set.selector_set selector_set = rule_set.selector_set
if match(selector_set, dom_node): if match(selector_set, dom_node):
applicable_rule_sets.append(rule_set) applicable_rule_sets.append(rule_set)
to_ignore = [ to_ignore = [
("inspector.py", "set_rule"), # inspector's instrumentation ("inspector.py", "set_rule"), # inspector's instrumentation
("styles.py", "set_rule"), ("styles.py", "set_rule"),
@ -769,7 +769,7 @@ class NodeInfo(Container):
if frame_info.filename.endswith("inspector.py") and frame_info.function == "_apply_style_value": if frame_info.filename.endswith("inspector.py") and frame_info.function == "_apply_style_value":
return "EDITED_WITH_INSPECTOR" return "EDITED_WITH_INSPECTOR"
return (frame_info.filename, frame_info.lineno) return (frame_info.filename, frame_info.lineno)
def format_location_info(location: tuple[str, int | None] | Literal["EDITED_WITH_INSPECTOR"] | None) -> Text: def format_location_info(location: tuple[str, int | None] | Literal["EDITED_WITH_INSPECTOR"] | None) -> Text:
"""Shows a link to open the the source code where a style is set.""" """Shows a link to open the the source code where a style is set."""
if location is None: if location is None:
@ -788,7 +788,7 @@ class NodeInfo(Container):
# css_lines = dom_node.styles.inline.css_lines # css_lines = dom_node.styles.inline.css_lines
# But we need to associate the snake_cased/hyphenated/shorthand CSS property names, # But we need to associate the snake_cased/hyphenated/shorthand CSS property names,
# in order to provide links to the source code. # in order to provide links to the source code.
def format_style_line(rule: str, styles: Styles, rules: RulesMap, inline: bool) -> Text: def format_style_line(rule: str, styles: Styles, rules: RulesMap, inline: bool) -> Text:
"""Formats a single CSS line for display, with a link to open the source code.""" """Formats a single CSS line for display, with a link to open the source code."""
# TODO: probably refactor arguments to take simpler data (!important flag bool etc....) # TODO: probably refactor arguments to take simpler data (!important flag bool etc....)
@ -826,7 +826,7 @@ class NodeInfo(Container):
# Note: rules[rule] won't be a Color for border-left etc. even if it SHOWS as just a color. # Note: rules[rule] won't be a Color for border-left etc. even if it SHOWS as just a color.
# if isinstance(value, Color): # if isinstance(value, Color):
# value_text = Text.styled(value_str, Style(bgcolor=value.rich_color, color=value.get_contrast_text().rich_color)) # value_text = Text.styled(value_str, Style(bgcolor=value.rich_color, color=value.get_contrast_text().rich_color))
# This is a bit specific, but it handles border values that are a border style followed by a color # This is a bit specific, but it handles border values that are a border style followed by a color
# (as well as plain colors). # (as well as plain colors).
if " " in value_str: if " " in value_str:
@ -872,7 +872,7 @@ class NodeInfo(Container):
"}", "}",
) )
inline_style_text = format_styles_block(dom_node.styles.inline, Text.styled("inline styles", "italic"), None) inline_style_text = format_styles_block(dom_node.styles.inline, Text.styled("inline styles", "italic"), None)
def format_rule_set(rule_set: RuleSet) -> Text: def format_rule_set(rule_set: RuleSet) -> Text:
"""Formats a CSS rule set for display, with a link to open the source code.""" """Formats a CSS rule set for display, with a link to open the source code."""
path: str | None = None path: str | None = None
@ -916,11 +916,11 @@ class NodeInfo(Container):
# key_bindings_static.update("\n".join(map(repr, dom_node.BINDINGS)) or "(None defined with BINDINGS)") # key_bindings_static.update("\n".join(map(repr, dom_node.BINDINGS)) or "(None defined with BINDINGS)")
# highlighter = ReprHighlighter() # highlighter = ReprHighlighter()
# key_bindings_static.update(Text("\n").join(map(lambda binding: highlighter(repr(binding)), dom_node.BINDINGS)) or "(None defined with BINDINGS)") # key_bindings_static.update(Text("\n").join(map(lambda binding: highlighter(repr(binding)), dom_node.BINDINGS)) or "(None defined with BINDINGS)")
# sources = [dom_node] # sources = [dom_node]
sources = dom_node.ancestors_with_self sources = dom_node.ancestors_with_self
nodes_and_bindings = [ nodes_and_bindings = [
(ancestor, binding) (ancestor, binding)
for ancestor in sources for ancestor in sources
for binding in ancestor._bindings.keys.values() # keys as in keybindings for binding in ancestor._bindings.keys.values() # keys as in keybindings
] ]
@ -1016,7 +1016,7 @@ class NodeInfo(Container):
usage_info = Text("\n\n").join(usages) usage_info = Text("\n\n").join(usages)
else: else:
usage_info = Text(f"No listeners found for {' or '.join(handler_names)}") usage_info = Text(f"No listeners found for {' or '.join(handler_names)}")
def_location = format_object_location_info(message_class) def_location = format_object_location_info(message_class)
qualname = message_class.__qualname__ qualname = message_class.__qualname__
doc = inspect.getdoc(message_class) or '(No docstring)' doc = inspect.getdoc(message_class) or '(No docstring)'
@ -1070,7 +1070,7 @@ class NodeInfo(Container):
input_parent.mount(self._style_value_input) # before setting value input_parent.mount(self._style_value_input) # before setting value
input_parent.mount(self._style_value_error) input_parent.mount(self._style_value_error)
self._style_value_input.display = True self._style_value_input.display = True
# Find leftmost and rightmost positions of the rule # Find leftmost and rightmost positions of the rule
while x > 0 and self._get_rule_at(x - 1, y) == rule: while x > 0 and self._get_rule_at(x - 1, y) == rule:
x -= 1 x -= 1
@ -1121,12 +1121,12 @@ class NodeInfo(Container):
# Tip: `merge` rather than `set_rule` allows a little trick of adding a new rule with # Tip: `merge` rather than `set_rule` allows a little trick of adding a new rule with
# "<old rule value>; <new rule>: <new rule value>" # "<old rule value>; <new rule>: <new rule value>"
# which is useful as a stopgap until there's a proper way to add new rules. # which is useful as a stopgap until there's a proper way to add new rules.
# Prevent "(edited)" if the rule is unchanged. # Prevent "(edited)" if the rule is unchanged.
for rule in self.dom_node._inline_styles.get_rules(): for rule in self.dom_node._inline_styles.get_rules():
if new_styles.get_rule(rule) == self.dom_node._inline_styles.get_rule(rule): if new_styles.get_rule(rule) == self.dom_node._inline_styles.get_rule(rule):
new_styles.clear_rule(rule) new_styles.clear_rule(rule)
self.dom_node._inline_styles.merge(new_styles) self.dom_node._inline_styles.merge(new_styles)
self.dom_node.refresh(layout=True) self.dom_node.refresh(layout=True)
self.watch_dom_node(self.dom_node) # refresh the inspector self.watch_dom_node(self.dom_node) # refresh the inspector
@ -1139,7 +1139,7 @@ class NodeInfo(Container):
class ResizeHandle(Widget): class ResizeHandle(Widget):
"""A handle for resizing a panel. """A handle for resizing a panel.
This should be a child of the panel. This should be a child of the panel.
Therefore, one of the sides of the divide needs to be a container. Therefore, one of the sides of the divide needs to be a container.
It will be positioned on the edge of the panel according to the `side` parameter. It will be positioned on the edge of the panel according to the `side` parameter.
@ -1425,7 +1425,7 @@ class Inspector(Container):
# Only widgets have a region, App (the root) doesn't. # Only widgets have a region, App (the root) doesn't.
self.reset_highlight() self.reset_highlight()
return return
# Rainbow highlight of ancestors. # Rainbow highlight of ancestors.
""" """
if dom_node and dom_node is not self.screen: if dom_node and dom_node is not self.screen:
@ -1465,7 +1465,7 @@ class Inspector(Container):
if "inspector_highlight" not in self.app.styles.layers: if "inspector_highlight" not in self.app.styles.layers:
self.app.styles.layers += ("inspector_highlight",) self.app.styles.layers += ("inspector_highlight",)
if dom_node not in self._highlight_boxes: if dom_node not in self._highlight_boxes:
self._highlight_boxes[dom_node] = {} self._highlight_boxes[dom_node] = {}
used_boxes: list[Container] = [] used_boxes: list[Container] = []

View File

@ -88,7 +88,7 @@ class Menu(Container):
item.press() item.press()
break break
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
"""Called when a button is clicked or activated with the keyboard.""" """Called when a button is clicked or activated with the keyboard."""
@ -181,7 +181,7 @@ class Menu(Container):
plain_parts = item.label.plain.split("\t") plain_parts = item.label.plain.split("\t")
if len(markup_parts) > 1: if len(markup_parts) > 1:
item.label = markup_parts[0] + " " * (max_width - len(plain_parts[0])) + markup_parts[1] item.label = markup_parts[0] + " " * (max_width - len(plain_parts[0])) + markup_parts[1]
def close(self): def close(self):
for item in self.items: for item in self.items:
if item.submenu: if item.submenu:
@ -189,7 +189,7 @@ class Menu(Container):
if not isinstance(self, MenuBar): if not isinstance(self, MenuBar):
self.display = False self.display = False
self.post_message(Menu.StatusInfo(None, closed=True)) self.post_message(Menu.StatusInfo(None, closed=True))
def any_menus_open(self) -> bool: def any_menus_open(self) -> bool:
for item in self.items: for item in self.items:
if item.submenu and item.submenu.display: if item.submenu and item.submenu.display:
@ -233,7 +233,7 @@ class MenuItem(Button):
self.id = "rc_" + str(id) self.id = "rc_" + str(id)
else: else:
self.id = "menu_item_" + to_snake_case(name) self.id = "menu_item_" + to_snake_case(name)
def on_enter(self, event: events.Enter) -> None: def on_enter(self, event: events.Enter) -> None:
if isinstance(self.parent_menu, MenuBar): if isinstance(self.parent_menu, MenuBar):
# The message is only reset to the default help text on close, so don't change it while no menu is open. # The message is only reset to the default help text on close, so don't change it while no menu is open.

View File

@ -88,7 +88,7 @@ class MetaGlyphFont:
self.covered_characters = covered_characters self.covered_characters = covered_characters
"""The characters supported by this font.""" """The characters supported by this font."""
self.load() self.load()
def load(self): def load(self):
"""Load the font from the .flf FIGlet font file.""" """Load the font from the .flf FIGlet font file."""
# fig = Figlet(font=self.file_path) # gives FontNotFound error! # fig = Figlet(font=self.file_path) # gives FontNotFound error!
@ -270,7 +270,7 @@ class Tool(Enum):
def get_name(self) -> str: def get_name(self) -> str:
"""Get the localized name for this tool. """Get the localized name for this tool.
Not to be confused with tool.name, which is an identifier. Not to be confused with tool.name, which is an identifier.
""" """
return { return {
@ -367,7 +367,7 @@ class ToolsBox(Container):
button.tooltip = tool.get_name() button.tooltip = tool.get_name()
self.tool_by_button[button] = tool self.tool_by_button[button] = tool
yield button yield button
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
"""Called when a button is clicked.""" """Called when a button is clicked."""
@ -376,7 +376,7 @@ class ToolsBox(Container):
class CharInput(Input, inherit_bindings=False): class CharInput(Input, inherit_bindings=False):
"""Widget for entering a single character.""" """Widget for entering a single character."""
class CharSelected(Message): class CharSelected(Message):
"""Message sent when a character is selected.""" """Message sent when a character is selected."""
def __init__(self, char: str) -> None: def __init__(self, char: str) -> None:
@ -397,7 +397,7 @@ class CharInput(Input, inherit_bindings=False):
def validate_value(self, value: str) -> str: def validate_value(self, value: str) -> str:
"""Limit the value to a single character.""" """Limit the value to a single character."""
return value[-1] if value else " " return value[-1] if value else " "
# Previously this used watch_value, # Previously this used watch_value,
# and had a bug where the character would oscillate between multiple values # and had a bug where the character would oscillate between multiple values
# due to a feedback loop between watch_value and on_char_input_char_selected. # due to a feedback loop between watch_value and on_char_input_char_selected.
@ -423,7 +423,7 @@ class CharInput(Input, inherit_bindings=False):
def validate_cursor_position(self, cursor_position: int) -> int: def validate_cursor_position(self, cursor_position: int) -> int:
"""Force the cursor position to 0 so that it's over the character.""" """Force the cursor position to 0 so that it's over the character."""
return 0 return 0
def insert_text_at_cursor(self, text: str) -> None: def insert_text_at_cursor(self, text: str) -> None:
"""Override to limit the value to a single character.""" """Override to limit the value to a single character."""
self.value = text[-1] if text else " " self.value = text[-1] if text else " "
@ -538,7 +538,7 @@ class Selection:
"""Copy the image data from the document into the selection.""" """Copy the image data from the document into the selection."""
self.contained_image = AnsiArtDocument(self.region.width, self.region.height) self.contained_image = AnsiArtDocument(self.region.width, self.region.height)
self.contained_image.copy_region(source=document, source_region=self.region) self.contained_image.copy_region(source=document, source_region=self.region)
def copy_to_document(self, document: 'AnsiArtDocument') -> None: def copy_to_document(self, document: 'AnsiArtDocument') -> None:
"""Draw the selection onto the document.""" """Draw the selection onto the document."""
if not self.contained_image: if not self.contained_image:
@ -810,7 +810,7 @@ class AnsiArtDocument:
@staticmethod @staticmethod
def format_from_extension(file_path: str) -> str | None: def format_from_extension(file_path: str) -> str | None:
"""Get the format ID from the file extension of the given path. """Get the format ID from the file extension of the given path.
Most format IDs are similar to the extension, e.g. 'PNG' for '.png', Most format IDs are similar to the extension, e.g. 'PNG' for '.png',
but some are different, e.g. 'JPEG2000' for '.jp2'. but some are different, e.g. 'JPEG2000' for '.jp2'.
""" """
@ -865,7 +865,7 @@ class AnsiArtDocument:
return self.encode_image_format(format_id) return self.encode_image_format(format_id)
else: else:
raise FormatWriteNotSupported(localized_message=_("Cannot write files in %1 format.", format_id) + "\n\n" + _("To save your changes, use a different filename.")) raise FormatWriteNotSupported(localized_message=_("Cannot write files in %1 format.", format_id) + "\n\n" + _("To save your changes, use a different filename."))
def encode_image_format(self, pil_format_id: str) -> bytes: def encode_image_format(self, pil_format_id: str) -> bytes:
"""Encode the document as an image file.""" """Encode the document as an image file."""
size = (self.width, self.height) size = (self.width, self.height)
@ -956,12 +956,12 @@ class AnsiArtDocument:
def get_rich_console_markup(self) -> str: def get_rich_console_markup(self) -> str:
"""Get the Rich API markup representation of the document.""" """Get the Rich API markup representation of the document."""
return self.get_renderable().markup return self.get_renderable().markup
def get_html(self) -> str: def get_html(self) -> str:
"""Get the HTML representation of the document.""" """Get the HTML representation of the document."""
console = self.get_console() console = self.get_console()
return console.export_html(inline_styles=True, code_format=CUSTOM_CONSOLE_HTML_FORMAT) return console.export_html(inline_styles=True, code_format=CUSTOM_CONSOLE_HTML_FORMAT)
def get_svg(self) -> str: def get_svg(self) -> str:
"""Get the SVG representation of the document.""" """Get the SVG representation of the document."""
console = self.get_console() console = self.get_console()
@ -972,7 +972,7 @@ class AnsiArtDocument:
# unless I escaped all the braces, but that would be ugly! So I'm just using a different syntax.) # unless I escaped all the braces, but that would be ugly! So I'm just using a different syntax.)
# `html.escape` leaves control codes, which blows up ET.fromstring, so use base64 instead. # `html.escape` leaves control codes, which blows up ET.fromstring, so use base64 instead.
return svg.replace("%ANSI_GOES_HERE%", base64.b64encode(self.get_ansi().encode("utf-8")).decode("utf-8")) return svg.replace("%ANSI_GOES_HERE%", base64.b64encode(self.get_ansi().encode("utf-8")).decode("utf-8"))
def get_renderable(self) -> Text: def get_renderable(self) -> Text:
"""Get a Rich renderable for the document.""" """Get a Rich renderable for the document."""
joiner = Text("\n") joiner = Text("\n")
@ -1260,7 +1260,7 @@ class AnsiArtDocument:
document.bg[y].append(default_bg) document.bg[y].append(default_bg)
document.fg[y].append(default_fg) document.fg[y].append(default_fg)
return document return document
@staticmethod @staticmethod
def from_text(text: str, default_bg: str = "#ffffff", default_fg: str = "#000000") -> 'AnsiArtDocument': def from_text(text: str, default_bg: str = "#ffffff", default_fg: str = "#000000") -> 'AnsiArtDocument':
"""Creates a document from the given text, detecting if it uses ANSI control codes or not.""" """Creates a document from the given text, detecting if it uses ANSI control codes or not."""
@ -1272,7 +1272,7 @@ class AnsiArtDocument:
@staticmethod @staticmethod
def from_image_format(content: bytes) -> 'AnsiArtDocument': def from_image_format(content: bytes) -> 'AnsiArtDocument':
"""Creates a document from the given bytes, detecting the file format. """Creates a document from the given bytes, detecting the file format.
Raises UnidentifiedImageError if the format is not detected. Raises UnidentifiedImageError if the format is not detected.
""" """
image = Image.open(io.BytesIO(content)) image = Image.open(io.BytesIO(content))
@ -1288,7 +1288,7 @@ class AnsiArtDocument:
@staticmethod @staticmethod
def from_svg(svg: str, default_bg: str = "#ffffff", default_fg: str = "#000000") -> 'AnsiArtDocument': def from_svg(svg: str, default_bg: str = "#ffffff", default_fg: str = "#000000") -> 'AnsiArtDocument':
"""Creates a document from an SVG containing a character grid with rects for cell backgrounds. """Creates a document from an SVG containing a character grid with rects for cell backgrounds.
- If the SVG contains a special <ansi> element, this is used instead of anything else. - If the SVG contains a special <ansi> element, this is used instead of anything else.
Otherwise it falls back to flexible grid detection: Otherwise it falls back to flexible grid detection:
- rect elements can be in any order. - rect elements can be in any order.
@ -1355,11 +1355,11 @@ class AnsiArtDocument:
def rewrite_property(match: re.Match[str]) -> str: def rewrite_property(match: re.Match[str]) -> str:
property_name = match.group(1) property_name = match.group(1)
property_value = match.group(2) property_value = match.group(2)
if property_name in property_map: if property_name in property_map:
rewritten_name = property_map[property_name] rewritten_name = property_map[property_name]
return f"{rewritten_name}: {property_value}" return f"{rewritten_name}: {property_value}"
return match.group(0) # Return the original match if no rewrite is needed return match.group(0) # Return the original match if no rewrite is needed
for style_element in root.findall(".//{http://www.w3.org/2000/svg}style"): for style_element in root.findall(".//{http://www.w3.org/2000/svg}style"):
@ -1660,7 +1660,7 @@ class AnsiArtDocument:
except IndexError: except IndexError:
print("Warning: text element is out of bounds: " + ET.tostring(text, encoding="unicode")) print("Warning: text element is out of bounds: " + ET.tostring(text, encoding="unicode"))
continue continue
# For debugging, write the SVG with the ignored rects outlined, and coordinate markers added. # For debugging, write the SVG with the ignored rects outlined, and coordinate markers added.
if DEBUG_SVG_LOADING: if DEBUG_SVG_LOADING:
ET.ElementTree(root).write("debug.svg", encoding="unicode") ET.ElementTree(root).write("debug.svg", encoding="unicode")
@ -1670,7 +1670,7 @@ class AnsiArtDocument:
@staticmethod @staticmethod
def decode_based_on_file_extension(content: bytes, file_path: str, default_bg: str = "#ffffff", default_fg: str = "#000000") -> 'AnsiArtDocument': def decode_based_on_file_extension(content: bytes, file_path: str, default_bg: str = "#ffffff", default_fg: str = "#000000") -> 'AnsiArtDocument':
"""Creates a document from the given bytes, detecting the file format. """Creates a document from the given bytes, detecting the file format.
Raises FormatReadNotSupported if the file format is not supported for reading. Some are write-only. Raises FormatReadNotSupported if the file format is not supported for reading. Some are write-only.
Raises UnicodeDecodeError, which can be a very long message, so make sure to handle it! Raises UnicodeDecodeError, which can be a very long message, so make sure to handle it!
Raises UnidentifiedImageError if the format is not detected. Raises UnidentifiedImageError if the format is not detected.
@ -1706,7 +1706,7 @@ class Action:
TODO: In the future it would be more efficient to use a mask for the region update, TODO: In the future it would be more efficient to use a mask for the region update,
to store only modified pixels, and use RLE compression on the mask and image data. to store only modified pixels, and use RLE compression on the mask and image data.
NOTE: Not to be confused with Textual's `class Action(Event)`, or the type of law suit. NOTE: Not to be confused with Textual's `class Action(Event)`, or the type of law suit.
Indeed, Textual's actions are used significantly in this application, with action_* methods, Indeed, Textual's actions are used significantly in this application, with action_* methods,
but this class is not related. Perhaps I should rename this class to UndoOp, or HistoryOperation. but this class is not related. Perhaps I should rename this class to UndoOp, or HistoryOperation.
@ -1720,7 +1720,7 @@ class Action:
"""The region of the document that was modified.""" """The region of the document that was modified."""
self.is_full_update = False self.is_full_update = False
"""Indicates that this action resizes the document, and thus should not be undone with a region update. """Indicates that this action resizes the document, and thus should not be undone with a region update.
That is, unless in the future region updates support a mask and work in tandem with resizes. That is, unless in the future region updates support a mask and work in tandem with resizes.
""" """
self.sub_image_before: AnsiArtDocument|None = None self.sub_image_before: AnsiArtDocument|None = None
@ -1775,7 +1775,7 @@ class Canvas(Widget):
self.button = mouse_down_event.button self.button = mouse_down_event.button
self.ctrl = mouse_down_event.ctrl self.ctrl = mouse_down_event.ctrl
super().__init__() super().__init__()
class ToolUpdate(Message): class ToolUpdate(Message):
"""Message when dragging on the canvas.""" """Message when dragging on the canvas."""
@ -1837,7 +1837,7 @@ class Canvas(Widget):
self.pointer_active = True self.pointer_active = True
self.which_button = event.button self.which_button = event.button
self.capture_mouse(True) self.capture_mouse(True)
def fix_mouse_event(self, event: events.MouseEvent) -> None: def fix_mouse_event(self, event: events.MouseEvent) -> None:
"""Work around inconsistent widget-relative mouse coordinates by calculating from screen coordinates.""" """Work around inconsistent widget-relative mouse coordinates by calculating from screen coordinates."""
# Hack to fix mouse coordinates, not needed for mouse down, # Hack to fix mouse coordinates, not needed for mouse down,
@ -1884,7 +1884,7 @@ class Canvas(Widget):
self.fix_mouse_event(event) self.fix_mouse_event(event)
event.x //= self.magnification event.x //= self.magnification
event.y //= self.magnification event.y //= self.magnification
if self.pointer_active: if self.pointer_active:
self.post_message(self.ToolStop(event)) self.post_message(self.ToolStop(event))
self.pointer_active = False self.pointer_active = False
@ -1993,7 +1993,7 @@ class Canvas(Widget):
style = Style(color=inverse_color, bgcolor=inverse_bgcolor) style = Style(color=inverse_color, bgcolor=inverse_bgcolor)
segments.append(Segment(ch, style)) segments.append(Segment(ch, style))
return Strip(segments, self.size.width) return Strip(segments, self.size.width)
def refresh_scaled_region(self, region: Region) -> None: def refresh_scaled_region(self, region: Region) -> None:
"""Refresh a region of the widget, scaled by the magnification.""" """Refresh a region of the widget, scaled by the magnification."""
if self.magnification == 1: if self.magnification == 1:
@ -2006,7 +2006,7 @@ class Canvas(Widget):
(region.width + 2) * self.magnification, (region.width + 2) * self.magnification,
(region.height + 2) * self.magnification, (region.height + 2) * self.magnification,
)) ))
def watch_magnification(self) -> None: def watch_magnification(self) -> None:
"""Called when magnification changes.""" """Called when magnification changes."""
self.active_meta_glyph_font = largest_font_that_fits(self.magnification, self.magnification) self.active_meta_glyph_font = largest_font_that_fits(self.magnification, self.magnification)
@ -2210,7 +2210,7 @@ class PaintApp(App[None]):
"""The document being edited. Contains the selection, if any.""" """The document being edited. Contains the selection, if any."""
image_initialized = False image_initialized = False
"""Whether the image is ready. This flag exists to avoid type checking woes if I were to allow image to be None.""" """Whether the image is ready. This flag exists to avoid type checking woes if I were to allow image to be None."""
magnification = var(1) magnification = var(1)
"""Current magnification level.""" """Current magnification level."""
return_to_magnification = var(4) return_to_magnification = var(4)
@ -2236,12 +2236,12 @@ class PaintApp(App[None]):
"""The folder to save a temporary backup file to. If None, will save alongside the file being edited.""" """The folder to save a temporary backup file to. If None, will save alongside the file being edited."""
backup_checked_for: Optional[str] = None backup_checked_for: Optional[str] = None
"""The file path last checked for a backup save. """The file path last checked for a backup save.
This is tracked to prevent discarding Untitled.ans~ when loading a document on startup. This is tracked to prevent discarding Untitled.ans~ when loading a document on startup.
Indicates that the file path either was loaded (recovered) or was not found. Indicates that the file path either was loaded (recovered) or was not found.
Not set when failing to load a backup, since the file maybe shouldn't be discarded in that case. Not set when failing to load a backup, since the file maybe shouldn't be discarded in that case.
""" """
mouse_gesture_cancelled = False mouse_gesture_cancelled = False
"""For Undo/Redo, to interrupt the current action""" """For Undo/Redo, to interrupt the current action"""
mouse_at_start: Offset = Offset(0, 0) mouse_at_start: Offset = Offset(0, 0)
@ -2262,7 +2262,7 @@ class PaintApp(App[None]):
"""Used for Polygon tool to detect double-click""" """Used for Polygon tool to detect double-click"""
color_eraser_mode: bool = False color_eraser_mode: bool = False
"""Used for Eraser/Color Eraser tool, when using the right mouse button""" """Used for Eraser/Color Eraser tool, when using the right mouse button"""
background_tasks: set[asyncio.Task[None]] = set() background_tasks: set[asyncio.Task[None]] = set()
"""Stores references to Task objects so they don't get garbage collected.""" """Stores references to Task objects so they don't get garbage collected."""
@ -2278,7 +2278,7 @@ class PaintApp(App[None]):
def watch_show_tools_box(self, show_tools_box: bool) -> None: def watch_show_tools_box(self, show_tools_box: bool) -> None:
"""Called when show_tools_box changes.""" """Called when show_tools_box changes."""
self.query_one("#tools_box", ToolsBox).display = show_tools_box self.query_one("#tools_box", ToolsBox).display = show_tools_box
def watch_show_colors_box(self, show_colors_box: bool) -> None: def watch_show_colors_box(self, show_colors_box: bool) -> None:
"""Called when show_colors_box changes.""" """Called when show_colors_box changes."""
self.query_one("#colors_box", ColorsBox).display = show_colors_box self.query_one("#colors_box", ColorsBox).display = show_colors_box
@ -2359,7 +2359,7 @@ class PaintApp(App[None]):
return affected_region_base.union(affected_region) return affected_region_base.union(affected_region)
else: else:
return affected_region return affected_region
def stamp_char(self, x: int, y: int) -> None: def stamp_char(self, x: int, y: int) -> None:
"""Modifies the cell at the given coordinates, with special handling for different tools.""" """Modifies the cell at the given coordinates, with special handling for different tools."""
if x >= self.image.width or y >= self.image.height or x < 0 or y < 0: if x >= self.image.width or y >= self.image.height or x < 0 or y < 0:
@ -2417,7 +2417,7 @@ class PaintApp(App[None]):
self.image.ch[y][x] = char self.image.ch[y][x] = char
self.image.bg[y][x] = bg_color self.image.bg[y][x] = bg_color
self.image.fg[y][x] = fg_color self.image.fg[y][x] = fg_color
def erase_region(self, region: Region, mask: Optional[list[list[bool]]] = None) -> None: def erase_region(self, region: Region, mask: Optional[list[list[bool]]] = None) -> None:
"""Clears the given region.""" """Clears the given region."""
# Time to go undercover as an eraser. 🥸 # Time to go undercover as an eraser. 🥸
@ -2498,7 +2498,7 @@ class PaintApp(App[None]):
if self.selected_tool not in [Tool.polygon, Tool.curve]: if self.selected_tool not in [Tool.polygon, Tool.curve]:
return return
if self.selected_tool == Tool.polygon and len(self.tool_points) < 3: if self.selected_tool == Tool.polygon and len(self.tool_points) < 3:
return return
if self.selected_tool == Tool.curve and len(self.tool_points) < 2: if self.selected_tool == Tool.curve and len(self.tool_points) < 2:
@ -2513,7 +2513,7 @@ class PaintApp(App[None]):
affected_region = self.draw_current_polygon() affected_region = self.draw_current_polygon()
else: else:
affected_region = self.draw_current_curve() affected_region = self.draw_current_curve()
action.region = affected_region action.region = affected_region
action.region = action.region.intersection(Region(0, 0, self.image.width, self.image.height)) action.region = action.region.intersection(Region(0, 0, self.image.width, self.image.height))
action.update(self.image_at_start) action.update(self.image_at_start)
@ -2666,7 +2666,7 @@ class PaintApp(App[None]):
return True return True
except PermissionError: except PermissionError:
self.message_box(dialog_title, _("Access denied."), "ok") self.message_box(dialog_title, _("Access denied."), "ok")
except FileNotFoundError: except FileNotFoundError:
self.message_box(dialog_title, _("%1 contains an invalid path.", file_path), "ok") self.message_box(dialog_title, _("%1 contains an invalid path.", file_path), "ok")
except OSError as e: except OSError as e:
self.message_box(dialog_title, _("Failed to save document."), "ok", error=e) self.message_box(dialog_title, _("Failed to save document."), "ok", error=e)
@ -2676,7 +2676,7 @@ class PaintApp(App[None]):
def reload_after_save(self, content: bytes, file_path: str) -> bool: def reload_after_save(self, content: bytes, file_path: str) -> bool:
"""Reload the document from saved content, to show information loss from the file format. """Reload the document from saved content, to show information loss from the file format.
Unlike `open_from_file_path`, this method: Unlike `open_from_file_path`, this method:
- doesn't short circuit when the file path matches the current file path, crucially - doesn't short circuit when the file path matches the current file path, crucially
- skips backup management (discarding or checking for a backup) - skips backup management (discarding or checking for a backup)
@ -2705,7 +2705,7 @@ class PaintApp(App[None]):
def update_palette_from_format_id(self, format_id: str | None) -> None: def update_palette_from_format_id(self, format_id: str | None) -> None:
"""Update the palette based on the file format. """Update the palette based on the file format.
In the future, this should update from attributes set when loading the file, In the future, this should update from attributes set when loading the file,
such as whether it supports color, and if not, it could show pattern fills, such as whether it supports color, and if not, it could show pattern fills,
such as ... that's not a lot of patterns, and you could get those from the such as ... that's not a lot of patterns, and you could get those from the
@ -2722,7 +2722,7 @@ class PaintApp(App[None]):
async def save(self) -> bool: async def save(self) -> bool:
"""Save the image to a file. """Save the image to a file.
Note that this method will never return if the user cancels the Save As dialog. Note that this method will never return if the user cancels the Save As dialog.
""" """
self.stop_action_in_progress() self.stop_action_in_progress()
@ -2751,7 +2751,7 @@ class PaintApp(App[None]):
await self.save_as() await self.save_as()
# If the user cancels the Save As dialog, we'll never get here. # If the user cancels the Save As dialog, we'll never get here.
return True return True
def action_save_as(self) -> None: def action_save_as(self) -> None:
"""Show the save as dialog, without waiting for it to close.""" """Show the save as dialog, without waiting for it to close."""
# Action must not await the dialog closing, # Action must not await the dialog closing,
@ -2767,7 +2767,7 @@ class PaintApp(App[None]):
# You could make a selection while the dialog is open, for example. # You could make a selection while the dialog is open, for example.
self.stop_action_in_progress() self.stop_action_in_progress()
self.close_windows("SaveAsDialogWindow, OpenDialogWindow") self.close_windows("SaveAsDialogWindow, OpenDialogWindow")
saved_future: asyncio.Future[None] = asyncio.Future() saved_future: asyncio.Future[None] = asyncio.Future()
def handle_selected_file_path(file_path: str) -> None: def handle_selected_file_path(file_path: str) -> None:
@ -2860,7 +2860,7 @@ class PaintApp(App[None]):
self.confirm_overwrite(file_path, on_save_confirmed) self.confirm_overwrite(file_path, on_save_confirmed)
else: else:
on_save_confirmed() on_save_confirmed()
window = SaveAsDialogWindow( window = SaveAsDialogWindow(
title=_("Copy To"), title=_("Copy To"),
handle_selected_file_path=handle_selected_file_path, handle_selected_file_path=handle_selected_file_path,
@ -2944,7 +2944,7 @@ class PaintApp(App[None]):
def confirm_information_loss(self, format_id: str | None, callback: Callable[[bool], None]) -> None: def confirm_information_loss(self, format_id: str | None, callback: Callable[[bool], None]) -> None:
"""Confirms discarding information when saving as a particular format. Callback variant. Never calls back if unconfirmed. """Confirms discarding information when saving as a particular format. Callback variant. Never calls back if unconfirmed.
The callback argument is whether there's information loss AND the file is openable. The callback argument is whether there's information loss AND the file is openable.
This is used to determine whether the file should be reloaded to show the information loss. This is used to determine whether the file should be reloaded to show the information loss.
It can't be reloaded if it's not openable. It can't be reloaded if it's not openable.
@ -3020,14 +3020,14 @@ class PaintApp(App[None]):
"""Exit the program immediately, deleting the backup file.""" """Exit the program immediately, deleting the backup file."""
self.discard_backup() self.discard_backup()
self.exit() self.exit()
def action_exit(self) -> None: def action_exit(self) -> None:
"""Exit the program, prompting to save changes if necessary.""" """Exit the program, prompting to save changes if necessary."""
if self.is_document_modified(): if self.is_document_modified():
self.prompt_save_changes(self.file_path or _("Untitled"), self.discard_backup_and_exit) self.prompt_save_changes(self.file_path or _("Untitled"), self.discard_backup_and_exit)
else: else:
self.discard_backup_and_exit() self.discard_backup_and_exit()
def action_reload(self) -> None: def action_reload(self) -> None:
"""Reload the program, prompting to save changes if necessary.""" """Reload the program, prompting to save changes if necessary."""
# restart_program() calls discard_backup() # restart_program() calls discard_backup()
@ -3057,7 +3057,7 @@ class PaintApp(App[None]):
icon_widget = get_warning_icon() icon_widget = get_warning_icon()
# self.close_windows("#message_box") # self.close_windows("#message_box")
self.bell() self.bell()
def handle_button(button: Button) -> None: def handle_button(button: Button) -> None:
@ -3128,7 +3128,7 @@ class PaintApp(App[None]):
def go_ahead(): def go_ahead():
# Note: exceptions handled outside of this function (UnicodeDecodeError, UnidentifiedImageError, FormatReadNotSupported) # Note: exceptions handled outside of this function (UnicodeDecodeError, UnidentifiedImageError, FormatReadNotSupported)
new_image = AnsiArtDocument.decode_based_on_file_extension(content, file_path) new_image = AnsiArtDocument.decode_based_on_file_extension(content, file_path)
# action_new handles discarding the backup, and recovering from Untitled.ans~, by default # action_new handles discarding the backup, and recovering from Untitled.ans~, by default
# but we need to 1. handle the case where the backup is the file to be opened, # but we need to 1. handle the case where the backup is the file to be opened,
# and 2. recover from <file to be opened>.ans~ instead of Untitled.ans~ # and 2. recover from <file to be opened>.ans~ instead of Untitled.ans~
@ -3146,7 +3146,7 @@ class PaintApp(App[None]):
print("Error comparing files:", e) print("Error comparing files:", e)
if not opening_backup: if not opening_backup:
self.discard_backup() self.discard_backup()
self.action_new(force=True, manage_backup=False) self.action_new(force=True, manage_backup=False)
self.canvas.image = self.image = new_image self.canvas.image = self.image = new_image
self.canvas.refresh(layout=True) self.canvas.refresh(layout=True)
@ -3226,7 +3226,7 @@ class PaintApp(App[None]):
def action_new(self, *, force: bool = False, manage_backup: bool = True) -> None: def action_new(self, *, force: bool = False, manage_backup: bool = True) -> None:
"""Create a new image, discarding the backup file for the old file path, and undos/redos. """Create a new image, discarding the backup file for the old file path, and undos/redos.
This method is used as part of opening files as well, This method is used as part of opening files as well,
in which case force=True and recover=False, in which case force=True and recover=False,
because prompting and recovering are handled outside. because prompting and recovering are handled outside.
@ -3259,7 +3259,7 @@ class PaintApp(App[None]):
if manage_backup: if manage_backup:
self.recover_from_backup() self.recover_from_backup()
def action_open_character_selector(self) -> None: def action_open_character_selector(self) -> None:
"""Show dialog to select a character.""" """Show dialog to select a character."""
self.close_windows("#character_selector_dialog") self.close_windows("#character_selector_dialog")
@ -3439,7 +3439,7 @@ Columns: {len(palette) // 2}
self.message_box(_("Paint"), "Not implemented.", "ok") self.message_box(_("Paint"), "Not implemented.", "ok")
def action_send(self) -> None: def action_send(self) -> None:
self.message_box(_("Paint"), "Not implemented.", "ok") self.message_box(_("Paint"), "Not implemented.", "ok")
def action_set_as_wallpaper_tiled(self) -> None: def action_set_as_wallpaper_tiled(self) -> None:
"""Tile the image as the wallpaper.""" """Tile the image as the wallpaper."""
self.set_as_wallpaper(tiled=True) self.set_as_wallpaper(tiled=True)
@ -3538,7 +3538,7 @@ Columns: {len(palette) // 2}
def get_selected_content(self, file_path: str|None = None) -> bytes | None: def get_selected_content(self, file_path: str|None = None) -> bytes | None:
"""Returns the content of the selection, or underlying the selection if it hasn't been cut out yet. """Returns the content of the selection, or underlying the selection if it hasn't been cut out yet.
For a textbox, returns the selected text within the textbox. May include ANSI escape sequences, either way. For a textbox, returns the selected text within the textbox. May include ANSI escape sequences, either way.
Raises FormatWriteNotSupported if the file_path implies a format that can't be encoded. Raises FormatWriteNotSupported if the file_path implies a format that can't be encoded.
@ -3663,18 +3663,18 @@ Columns: {len(palette) // 2}
self.image.selection = Selection(Region(0, 0, self.image.width, self.image.height)) self.image.selection = Selection(Region(0, 0, self.image.width, self.image.height))
self.canvas.refresh() self.canvas.refresh()
self.selected_tool = Tool.select self.selected_tool = Tool.select
def action_text_toolbar(self) -> None: def action_text_toolbar(self) -> None:
self.message_box(_("Paint"), "Not implemented.", "ok") self.message_box(_("Paint"), "Not implemented.", "ok")
def action_normal_size(self) -> None: def action_normal_size(self) -> None:
"""Zoom to 1x.""" """Zoom to 1x."""
self.magnification = 1 self.magnification = 1
def action_large_size(self) -> None: def action_large_size(self) -> None:
"""Zoom to 4x.""" """Zoom to 4x."""
self.magnification = 4 self.magnification = 4
def action_custom_zoom(self) -> None: def action_custom_zoom(self) -> None:
"""Show dialog to set zoom level.""" """Show dialog to set zoom level."""
self.close_windows("#zoom_dialog") self.close_windows("#zoom_dialog")
@ -3850,7 +3850,7 @@ Columns: {len(palette) // 2}
self.image.fg[self.image.height - y - 1][x] = source.fg[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.bg[self.image.height - y - 1][x] = source.bg[y][x]
self.canvas.refresh() self.canvas.refresh()
def action_rotate_by_angle(self, angle: int) -> None: def action_rotate_by_angle(self, angle: int) -> None:
"""Rotate the image by the given angle, one of 90, 180, or 270.""" """Rotate the image by the given angle, one of 90, 180, or 270."""
action = Action(_("Rotate by angle"), Region(0, 0, self.image.width, self.image.height)) action = Action(_("Rotate by angle"), Region(0, 0, self.image.width, self.image.height))
@ -3863,7 +3863,7 @@ Columns: {len(palette) // 2}
if angle != 180: if angle != 180:
self.image.resize(self.image.height, self.image.width) self.image.resize(self.image.height, self.image.width)
for y in range(self.image.height): for y in range(self.image.height):
for x in range(self.image.width): for x in range(self.image.width):
if angle == 90: if angle == 90:
@ -4072,8 +4072,8 @@ Columns: {len(palette) // 2}
self.add_action(action) self.add_action(action)
self.image.invert() self.image.invert()
self.canvas.refresh() self.canvas.refresh()
def resize_document(self, width: int, height: int) -> None: def resize_document(self, width: int, height: int) -> None:
"""Resize the document, creating an undo state, and refresh the canvas.""" """Resize the document, creating an undo state, and refresh the canvas."""
self.cancel_preview() self.cancel_preview()
@ -4087,7 +4087,7 @@ Columns: {len(palette) // 2}
self.add_action(action) self.add_action(action)
self.image.resize(width, height, default_bg=self.selected_bg_color, default_fg=self.selected_fg_color) self.image.resize(width, height, default_bg=self.selected_bg_color, default_fg=self.selected_fg_color)
self.canvas.refresh(layout=True) self.canvas.refresh(layout=True)
def action_attributes(self) -> None: def action_attributes(self) -> None:
@ -4131,7 +4131,7 @@ Columns: {len(palette) // 2}
) )
) )
self.mount(window) self.mount(window)
def action_clear_image(self) -> None: def action_clear_image(self) -> None:
"""Clear the image, creating an undo state.""" """Clear the image, creating an undo state."""
# This could be simplified to use erase_region, but that would be marginally slower. # This could be simplified to use erase_region, but that would be marginally slower.
@ -4155,7 +4155,7 @@ Columns: {len(palette) // 2}
def action_draw_opaque(self) -> None: def action_draw_opaque(self) -> None:
"""Toggles opaque/transparent selection mode.""" """Toggles opaque/transparent selection mode."""
self.message_box(_("Paint"), "Not implemented.", "ok") self.message_box(_("Paint"), "Not implemented.", "ok")
def action_help_topics(self) -> None: def action_help_topics(self) -> None:
"""Show the Help Topics dialog.""" """Show the Help Topics dialog."""
self.close_windows("#help_dialog") self.close_windows("#help_dialog")
@ -4193,7 +4193,7 @@ Columns: {len(palette) // 2}
window.content.mount(Container(Static(help_text, markup=False), classes="help_text_container")) window.content.mount(Container(Static(help_text, markup=False), classes="help_text_container"))
window.content.mount(Button(_("OK"), classes="ok submit")) window.content.mount(Button(_("OK"), classes="ok submit"))
self.mount(window) self.mount(window)
def action_about_paint(self) -> None: def action_about_paint(self) -> None:
"""Show the About Paint dialog.""" """Show the About Paint dialog."""
self.close_windows("#about_paint_dialog") self.close_windows("#about_paint_dialog")
@ -4349,7 +4349,7 @@ Columns: {len(palette) // 2}
def magnifier_click(self, x: int, y: int) -> None: def magnifier_click(self, x: int, y: int) -> None:
"""Zooms in or out on the image.""" """Zooms in or out on the image."""
prev_magnification = self.magnification prev_magnification = self.magnification
prospective_magnification = self.get_prospective_magnification() prospective_magnification = self.get_prospective_magnification()
@ -4386,7 +4386,7 @@ Columns: {len(palette) // 2}
def extract_to_selection(self, erase_underlying: bool = True) -> None: def extract_to_selection(self, erase_underlying: bool = True) -> None:
"""Extracts image data underlying the selection from the document into the selection. """Extracts image data underlying the selection from the document into the selection.
This creates an undo state with the current tool's name, which should be Select or Free-Form Select. This creates an undo state with the current tool's name, which should be Select or Free-Form Select.
""" """
sel = self.image.selection sel = self.image.selection
@ -4497,7 +4497,7 @@ Columns: {len(palette) // 2}
self.image_at_start.copy_region(self.image) self.image_at_start.copy_region(self.image)
action = Action(self.selected_tool.get_name()) action = Action(self.selected_tool.get_name())
self.add_action(action) self.add_action(action)
affected_region = None affected_region = None
if self.selected_tool == Tool.pencil or self.selected_tool == Tool.brush: if self.selected_tool == Tool.pencil or self.selected_tool == Tool.brush:
affected_region = self.stamp_brush(event.x, event.y) affected_region = self.stamp_brush(event.x, event.y)
@ -4531,7 +4531,7 @@ Columns: {len(palette) // 2}
region = self.canvas.select_preview_region region = self.canvas.select_preview_region
self.canvas.select_preview_region = None self.canvas.select_preview_region = None
self.canvas.refresh_scaled_region(region) self.canvas.refresh_scaled_region(region)
# To avoid saving with a tool preview as part of the image data, # To avoid saving with a tool preview as part of the image data,
# or interrupting the user's flow by canceling the preview occasionally to auto-save a backup, # or interrupting the user's flow by canceling the preview occasionally to auto-save a backup,
# we postpone auto-saving the backup until the image is clean of any previews. # we postpone auto-saving the backup until the image is clean of any previews.
@ -4642,12 +4642,12 @@ Columns: {len(palette) // 2}
"""Merges the selection into the image, or deletes it if meld is False.""" """Merges the selection into the image, or deletes it if meld is False."""
if not self.image.selection: if not self.image.selection:
return return
if self.image.selection.textbox_mode: if self.image.selection.textbox_mode:
# The Text tool creates an undo state only when you switch tools # The Text tool creates an undo state only when you switch tools
# or click outside the textbox, melding the textbox into the image. # or click outside the textbox, melding the textbox into the image.
# If you're deleting the textbox, an undo state doesn't need to be created. # If you're deleting the textbox, an undo state doesn't need to be created.
# If you haven't typed anything into the textbox yet, it should be deleted # If you haven't typed anything into the textbox yet, it should be deleted
# to make it easier to start over in positioning the textbox. # to make it easier to start over in positioning the textbox.
# If you have typed something, it should be melded into the image, # If you have typed something, it should be melded into the image,
@ -4708,7 +4708,7 @@ Columns: {len(palette) // 2}
def on_canvas_tool_update(self, event: Canvas.ToolUpdate) -> None: def on_canvas_tool_update(self, event: Canvas.ToolUpdate) -> None:
"""Called when the user is drawing on the canvas. """Called when the user is drawing on the canvas.
Several tools do a preview of sorts here, even though it's not the ToolPreviewUpdate event. Several tools do a preview of sorts here, even though it's not the ToolPreviewUpdate event.
TODO: rename these events to describe when they occur, ascribe less semantics to them. TODO: rename these events to describe when they occur, ascribe less semantics to them.
""" """
@ -4741,7 +4741,7 @@ Columns: {len(palette) // 2}
if self.selected_tool in [Tool.fill, Tool.magnifier]: if self.selected_tool in [Tool.fill, Tool.magnifier]:
return return
if self.selected_tool in [Tool.select, Tool.free_form_select, Tool.text]: if self.selected_tool in [Tool.select, Tool.free_form_select, Tool.text]:
sel = self.image.selection sel = self.image.selection
if self.selecting_text: if self.selecting_text:
@ -4797,7 +4797,7 @@ Columns: {len(palette) // 2}
old_action.undo(self.image) old_action.undo(self.image)
action = Action(self.selected_tool.get_name(), affected_region) action = Action(self.selected_tool.get_name(), affected_region)
self.undos.append(action) self.undos.append(action)
if self.selected_tool in [Tool.pencil, Tool.brush, Tool.eraser, Tool.airbrush]: if self.selected_tool in [Tool.pencil, Tool.brush, Tool.eraser, Tool.airbrush]:
for x, y in bresenham_walk(self.mouse_previous.x, self.mouse_previous.y, event.x, event.y): for x, y in bresenham_walk(self.mouse_previous.x, self.mouse_previous.y, event.x, event.y):
affected_region = self.stamp_brush(x, y, affected_region) affected_region = self.stamp_brush(x, y, affected_region)
@ -4841,7 +4841,7 @@ Columns: {len(palette) // 2}
affected_region = self.stamp_brush(x, y, affected_region) affected_region = self.stamp_brush(x, y, affected_region)
else: else:
raise NotImplementedError raise NotImplementedError
# Update action region and image data # Update action region and image data
if action.region and affected_region: if action.region and affected_region:
action.region = action.region.union(affected_region) action.region = action.region.union(affected_region)
@ -4858,7 +4858,7 @@ Columns: {len(palette) // 2}
assert old_action is not None, "old_action should have been set if replace_action is True" assert old_action is not None, "old_action should have been set if replace_action is True"
affected_region = affected_region.union(old_action.region) affected_region = affected_region.union(old_action.region)
self.canvas.refresh_scaled_region(affected_region) self.canvas.refresh_scaled_region(affected_region)
self.mouse_previous = Offset(event.x, event.y) self.mouse_previous = Offset(event.x, event.y)
def on_canvas_tool_stop(self, event: Canvas.ToolStop) -> None: def on_canvas_tool_stop(self, event: Canvas.ToolStop) -> None:
@ -4885,7 +4885,7 @@ Columns: {len(palette) // 2}
# Done selecting text # Done selecting text
self.selecting_text = False self.selecting_text = False
return return
assert self.mouse_at_start is not None, "mouse_at_start should be set on mouse down" assert self.mouse_at_start is not None, "mouse_at_start should be set on mouse down"
# Note that self.mouse_at_start is not set to None on mouse up, # Note that self.mouse_at_start is not set to None on mouse up,
# so it can't be used to check if the mouse is down. # so it can't be used to check if the mouse is down.
@ -4927,7 +4927,7 @@ Columns: {len(palette) // 2}
self.make_preview(self.draw_current_curve) self.make_preview(self.draw_current_curve)
elif self.selected_tool == Tool.polygon: elif self.selected_tool == Tool.polygon:
# Maybe finish drawing a polygon # Maybe finish drawing a polygon
# Check if the distance between the first and last point is small enough, # Check if the distance between the first and last point is small enough,
# or if the user double-clicked. # or if the user double-clicked.
close_gap_threshold_cells = 2 close_gap_threshold_cells = 2
@ -5106,7 +5106,7 @@ Columns: {len(palette) // 2}
# Detect file drop # Detect file drop
def _extract_filepaths(text: str) -> list[str]: def _extract_filepaths(text: str) -> list[str]:
"""Extracts escaped filepaths from text. """Extracts escaped filepaths from text.
Taken from https://github.com/agmmnn/textual-filedrop/blob/55a288df65d1397b959d55ef429e5282a0bb21ff/textual_filedrop/_filedrop.py#L17-L36 Taken from https://github.com/agmmnn/textual-filedrop/blob/55a288df65d1397b959d55ef429e5282a0bb21ff/textual_filedrop/_filedrop.py#L17-L36
""" """
split_filepaths = [] split_filepaths = []
@ -5128,7 +5128,7 @@ Columns: {len(palette) // 2}
# for file in files: # for file in files:
# filepaths.append(os.path.join(root, file)) # filepaths.append(os.path.join(root, file))
return filepaths return filepaths
try: try:
filepaths = _extract_filepaths(event.text) filepaths = _extract_filepaths(event.text)
if filepaths: if filepaths:
@ -5137,7 +5137,7 @@ Columns: {len(palette) // 2}
return return
except ValueError: except ValueError:
pass pass
# Text pasting is only supported with Ctrl+V or Edit > Paste, handled separately. # Text pasting is only supported with Ctrl+V or Edit > Paste, handled separately.
return return
@ -5162,7 +5162,7 @@ Columns: {len(palette) // 2}
self.selected_tool = event.tool self.selected_tool = event.tool
if self.selected_tool not in [Tool.magnifier, Tool.pick_color]: if self.selected_tool not in [Tool.magnifier, Tool.pick_color]:
self.return_to_tool = self.selected_tool self.return_to_tool = self.selected_tool
def on_char_input_char_selected(self, event: CharInput.CharSelected) -> None: def on_char_input_char_selected(self, event: CharInput.CharSelected) -> None:
"""Called when a character is entered in the character input.""" """Called when a character is entered in the character input."""
self.selected_char = event.char self.selected_char = event.char
@ -5280,7 +5280,7 @@ HeaderIcon.icon = "[rgb(0,0,0) on rgb(255,255,255)]...[/][rgb(255,255,255)]\\\\[
# Prevent wrapping, for a CSS effect, cropping to hide the shading "~" of the page fold when the page fold isn't visible. # Prevent wrapping, for a CSS effect, cropping to hide the shading "~" of the page fold when the page fold isn't visible.
HeaderIcon.icon = Text.from_markup(HeaderIcon.icon, overflow="crop") HeaderIcon.icon = Text.from_markup(HeaderIcon.icon, overflow="crop")
# `textual run --dev src.textual_paint.paint` will search for a # `textual run --dev src.textual_paint.paint` will search for a
# global variable named `app`, and fallback to # global variable named `app`, and fallback to
# anything that is an instance of `App`, or # anything that is an instance of `App`, or
# a subclass of `App`. # a subclass of `App`.

View File

@ -21,7 +21,7 @@ def get_desktop_environment() -> str:
if desktop_session is not None: # easier to match if we doesn't have to deal with character cases if desktop_session is not None: # easier to match if we doesn't have to deal with character cases
desktop_session = desktop_session.lower() desktop_session = desktop_session.lower()
if desktop_session in [ if desktop_session in [
"gnome", "unity", "cinnamon", "mate", "xfce4", "lxde", "fluxbox", "gnome", "unity", "cinnamon", "mate", "xfce4", "lxde", "fluxbox",
"blackbox", "openbox", "icewm", "jwm", "afterstep", "trinity", "kde" "blackbox", "openbox", "icewm", "jwm", "afterstep", "trinity", "kde"
]: ]:
return desktop_session return desktop_session
@ -33,11 +33,11 @@ def get_desktop_environment() -> str:
elif desktop_session.startswith("ubuntustudio"): elif desktop_session.startswith("ubuntustudio"):
return "kde" return "kde"
elif desktop_session.startswith("ubuntu"): elif desktop_session.startswith("ubuntu"):
return "gnome" return "gnome"
elif desktop_session.startswith("lubuntu"): elif desktop_session.startswith("lubuntu"):
return "lxde" return "lxde"
elif desktop_session.startswith("kubuntu"): elif desktop_session.startswith("kubuntu"):
return "kde" return "kde"
elif desktop_session.startswith("razor"): # e.g. razorkwin elif desktop_session.startswith("razor"): # e.g. razorkwin
return "razor-qt" return "razor-qt"
elif desktop_session.startswith("wmaker"): # e.g. wmaker-common elif desktop_session.startswith("wmaker"): # e.g. wmaker-common
@ -141,7 +141,7 @@ def set_wallpaper(file_loc: str, first_run: bool = True):
import configparser import configparser
desktop_conf = configparser.ConfigParser() desktop_conf = configparser.ConfigParser()
# Development version # Development version
desktop_conf_file = os.path.join(get_config_dir("razor"), "desktop.conf") desktop_conf_file = os.path.join(get_config_dir("razor"), "desktop.conf")
if os.path.isfile(desktop_conf_file): if os.path.isfile(desktop_conf_file):
config_option = R"screens\1\desktops\1\wallpaper" config_option = R"screens\1\desktops\1\wallpaper"
else: else:
@ -157,11 +157,11 @@ def set_wallpaper(file_loc: str, first_run: bool = True):
pass pass
else: else:
# TODO: reload desktop when possible # TODO: reload desktop when possible
pass pass
elif desktop_env in ["fluxbox", "jwm", "openbox", "afterstep"]: elif desktop_env in ["fluxbox", "jwm", "openbox", "afterstep"]:
# http://fluxbox-wiki.org/index.php/Howto_set_the_background # http://fluxbox-wiki.org/index.php/Howto_set_the_background
# used fbsetbg on jwm too since I am too lazy to edit the XML configuration # used fbsetbg on jwm too since I am too lazy to edit the XML configuration
# now where fbsetbg does the job excellent anyway. # now where fbsetbg does the job excellent anyway.
# and I have not figured out how else it can be set on Openbox and AfterSTep # and I have not figured out how else it can be set on Openbox and AfterSTep
# but fbsetbg works excellent here too. # but fbsetbg works excellent here too.
try: try:
@ -229,9 +229,9 @@ def set_wallpaper(file_loc: str, first_run: bool = True):
def get_config_dir(app_name: str) -> str: def get_config_dir(app_name: str) -> str:
if "XDG_CONFIG_HOME" in os.environ: if "XDG_CONFIG_HOME" in os.environ:
config_home = os.environ["XDG_CONFIG_HOME"] config_home = os.environ["XDG_CONFIG_HOME"]
elif "APPDATA" in os.environ: # On Windows elif "APPDATA" in os.environ: # On Windows
config_home = os.environ["APPDATA"] config_home = os.environ["APPDATA"]
else: else:
try: try:
from xdg import BaseDirectory # type: ignore from xdg import BaseDirectory # type: ignore

View File

@ -157,7 +157,7 @@ class Window(Container):
def constrain_to_screen(self) -> None: def constrain_to_screen(self) -> None:
"""Constrain window to screen, so that the title bar is always visible. """Constrain window to screen, so that the title bar is always visible.
This method must take into account the fact that the window is centered with `align: center middle;` This method must take into account the fact that the window is centered with `align: center middle;`
TODO: Call this on screen resize. TODO: Call this on screen resize.
""" """
@ -203,7 +203,7 @@ class Window(Container):
def on_focus(self, event: events.Focus) -> None: def on_focus(self, event: events.Focus) -> None:
"""Called when the window is focused.""" """Called when the window is focused."""
self.focus() self.focus()
def focus(self, scroll_visible: bool = True) -> Self: def focus(self, scroll_visible: bool = True) -> Self:
"""Focus the window. Note that scroll_visible may scroll a descendant into view, but never the window into view within the screen.""" """Focus the window. Note that scroll_visible may scroll a descendant into view, but never the window into view within the screen."""
# Focus last focused widget if re-focusing # Focus last focused widget if re-focusing