diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b007de --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.orig +*.egg-info/ +commits.txt diff --git a/eris/__main__.py b/eris/__main__.py index 2190c89..a7150f2 100755 --- a/eris/__main__.py +++ b/eris/__main__.py @@ -32,6 +32,8 @@ import subprocess import sys import time +sys.path = [p for p in sys.path if p] + import docopt import fill3 import fill3.terminal as terminal @@ -69,6 +71,7 @@ Options: highlighting. Defaults to "native". -c TYPE, --compression=TYPE The type of compression used in the cache: gzip, lzma, bz2, or none. Defaults to gzip. + --window Use a native GPU window instead of the terminal. --config=FILE_NAME Use a custom config file instead of tools.toml. """ @@ -1028,7 +1031,7 @@ def on_filesystem_event(event, summary, root_path): async def main(title, root_path, worker_count=None, editor_command=None, theme=None, - compression=None): + compression=None, use_window=False): loop = asyncio.get_running_loop() if worker_count is None: worker_count = max(multiprocessing.cpu_count() - 1, 1) @@ -1056,7 +1059,10 @@ async def main(title, root_path, worker_count=None, editor_command=None, theme=N loop.create_task(worker_.future) try: if sys.stdout.isatty() or "unittest" in sys.modules: - await fill3.tui(title, screen) + if use_window: + await fill3.textgrid_tui(title, screen) + else: + await fill3.tui(title, screen) log.log_message("Program stopped.") else: shutdown_event = asyncio.Event() @@ -1239,7 +1245,8 @@ def check_arguments(): sys.exit(1) editor_command = (arguments["--editor"] or os.environ.get("EDITOR", None) or os.environ.get("VISUAL", None)) - return root_path, worker_count, editor_command, arguments["--theme"], arguments["--compression"] + return (root_path, worker_count, editor_command, arguments["--theme"], + arguments["--compression"], arguments["--window"]) def inotify_watches_exceeded(): @@ -1250,12 +1257,14 @@ def inotify_watches_exceeded(): def entry_point(): - root_path, worker_count, editor_command, theme, compression = check_arguments() + (root_path, worker_count, editor_command, theme, compression, + use_window) = check_arguments() manage_cache(root_path) title = "eris: " + os.path.basename(root_path) with chdir(root_path): # FIX: Don't change directory if possible. try: - asyncio.run(main(title, root_path, worker_count, editor_command, theme, compression)) + asyncio.run(main(title, root_path, worker_count, editor_command, + theme, compression, use_window=use_window)) except pyinotify.WatchManagerError: inotify_watches_exceeded() diff --git a/fill3/LICENSE b/fill3/LICENSE deleted file mode 120000 index ea5b606..0000000 --- a/fill3/LICENSE +++ /dev/null @@ -1 +0,0 @@ -../LICENSE \ No newline at end of file diff --git a/fill3/fill3/__init__.py b/fill3/fill3/__init__.py deleted file mode 100755 index 3452145..0000000 --- a/fill3/fill3/__init__.py +++ /dev/null @@ -1,588 +0,0 @@ -#!/usr/bin/python3.11 -# -*- coding: utf-8 -*- - - -import asyncio -import contextlib -import enum -import itertools -import os -import re -import signal -import sys - -import fill3.terminal as terminal -import termstr - - -__version__ = "v2025.07.11" - - -########################## -# Widgets - -def appearance_is_valid(appearance): - return (all(isinstance(line, (str, termstr.TermStr)) and len(line) > 0 for line in appearance) - and len(set(len(line) for line in appearance)) < 2) - - -def appearance_resize(appearance, dimensions, pad_char=" "): - width, height = dimensions - result = [line[:width].ljust(width, pad_char) for line in appearance[:height]] - if len(result) < height: - result.extend([pad_char * width] * (height - len(result))) - return result - - -def appearance_dimensions(appearance): - try: - return len(appearance[0]), len(appearance) - except IndexError: - return 0, 0 - - -def join_horizontal(appearances): - heights = set(len(appearance) for appearance in appearances) - assert len(heights) == 1, heights - return [termstr.join("", parts) for parts in zip(*appearances)] - - -def even_widths(column_widgets, width): - column_count = len(column_widgets) - widths = [] - for index, column_widget in enumerate(column_widgets): - start_pos = round(float(width) / column_count * index) - end_pos = round(float(width) / column_count * (index+1)) - widths.append(end_pos - start_pos) - return widths - - -def appearance_as_html(appearance): - lines = [] - all_styles = set() - for line in appearance: - html, styles = termstr.TermStr(line).as_html() - all_styles.update(styles) - lines.append(html) - return ("\n".join(style.as_html() for style in all_styles) + - "\n
" + "
".join(lines) + "
") - - -class Row: - - def __init__(self, widgets, widths_func=even_widths): - self.widgets = widgets - self.widths_func = widths_func - - def appearance_for(self, dimensions): - width, height = dimensions - widths = self.widths_func(self.widgets, width) - assert sum(widths) == width, (sum(widths), width) - return join_horizontal([column_widget.appearance_for((item_width, height)) - for column_widget, item_width in zip(self.widgets, widths)]) - - def appearance(self): - appearances = [column_widget.appearance() for column_widget in self.widgets] - dimensions = [appearance_dimensions(appearance) for appearance in appearances] - max_height = max(height for width, height in dimensions) - return join_horizontal([appearance_resize(appearance, (width, max_height)) - for appearance, (width, height) in zip(appearances, dimensions)]) - - -def even_partition(row_widgets, height): - row_count = len(row_widgets) - heights = [] - for index, row_widget in enumerate(row_widgets): - start_pos = round(float(height) / row_count * index) - end_pos = round(float(height) / row_count * (index+1)) - heights.append(end_pos - start_pos) - return heights - - -def join_vertical(appearances): - result = [] - for appearance in appearances: - result.extend(appearance) - return result - - -class Column: - - def __init__(self, widgets, partition_func=even_partition, background_char=" "): - self.widgets = widgets - self.partition_func = partition_func - self.background_char = background_char - - def appearance_for(self, dimensions): - width, height = dimensions - if len(self.widgets) == 0: # FIX: Really allow zero widgets? - return [self.background_char * width] * height - heights = self.partition_func(self.widgets, height) - assert sum(heights) == height, (sum(heights), height) - return join_vertical([row_widget.appearance_for((width, item_height)) - for row_widget, item_height in zip(self.widgets, heights)]) - - @staticmethod - def _appearance_list(widgets): - if widgets == []: - return [] - appearances = [row_widget.appearance() for row_widget in widgets] - dimensions = [appearance_dimensions(appearance) for appearance in appearances] - max_width = max(width for width, height in dimensions) - padded_appearances = [appearance_resize(appearance, (max_width, height)) - for appearance, (width, height) in zip(appearances, dimensions)] - result = [] - for appearance in padded_appearances: - result.extend(appearance) - return result - - def appearance_interval(self, interval): - start_y, end_y = interval - return self._appearance_list(self.widgets[start_y:end_y]) - - def appearance(self): - return self._appearance_list(self.widgets) - - -class Filler: - - def __init__(self, widget): - self.widget = widget - - def appearance_for(self, dimensions): - return appearance_resize(self.widget.appearance(), dimensions) - - -class ScrollBar: - - _PARTIAL_CHARS = (["█", "▇", "▆", "▅", "▄", "▃", "▂", "▁"], - [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"]) - DEFAULT_BAR_COLOR = termstr.Color.grey_100 - DEFAULT_BACKGROUND_COLOR = termstr.Color.grey_30 - - def __init__(self, is_horizontal, interval=(0, 0), bar_color=None, background_color=None): - self._is_horizontal = is_horizontal - self.interval = interval - bar_color = bar_color or ScrollBar.DEFAULT_BAR_COLOR - background_color = background_color or ScrollBar.DEFAULT_BACKGROUND_COLOR - self._bar_char = termstr.TermStr("█", fg_color=bar_color) - self._background_char = termstr.TermStr(" ", bg_color=background_color) - if self._is_horizontal: - bar_color, background_color = background_color, bar_color - self._partial_chars = [ - (termstr.TermStr(char, fg_color=bar_color, bg_color=background_color), - termstr.TermStr(char, fg_color=background_color, bg_color=bar_color)) - for char in self._PARTIAL_CHARS[self._is_horizontal]] - - def appearance_for(self, dimensions): - width, height = dimensions - assert width == 1 or height == 1, (width, height) - length = width if self._is_horizontal else height - assert all(0 <= fraction <= 1 for fraction in self.interval), self.interval - (start_index, start_remainder), (end_index, end_remainder) = \ - [divmod(fraction * length * 8, 8) for fraction in self.interval] - start_index, end_index = int(start_index), int(end_index) - start_remainder, end_remainder = int(start_remainder), int(end_remainder) - if start_index == end_index: - end_index, end_remainder = start_index + 1, start_remainder - elif end_index == start_index + 1: - end_remainder = max(start_remainder, end_remainder) - bar = (self._background_char * start_index + self._partial_chars[start_remainder][0] + - self._bar_char * (end_index - start_index - 1) + - self._partial_chars[end_remainder][1] + - self._background_char * (length - end_index - 1))[:length] - return [bar] if self._is_horizontal else [char for char in bar] - - -class Alignment(enum.Enum): - left = enum.auto() - right = enum.auto() - top = enum.auto() - bottom = enum.auto() - center = enum.auto() - - -class Portal: - - def __init__(self, widget, position=(0, 0), background_char=" ", - is_scroll_limited=False, x_alignment=Alignment.left, y_alignment=Alignment.top): - self.widget = widget - self.position = position - self.background_char = background_char - self.is_scroll_limited = is_scroll_limited - self.x_alignment = x_alignment - self.y_alignment = y_alignment - self.last_dimensions = 0, 0 - - def _scroll_half_pages(self, dx, dy): - x, y = self.position - width, height = self.last_dimensions - self.position = (max(x + dx * (width // 2), 0), max(y + dy * (height // 2), 0)) - - def scroll_up(self): - self._scroll_half_pages(0, -1) - - def scroll_down(self): - self._scroll_half_pages(0, 1) - - def scroll_left(self): - self._scroll_half_pages(-1, 0) - - def scroll_right(self): - self._scroll_half_pages(1, 0) - - def limit_scroll(self, portal_dimensions, appearance_dimensions): - portal_width, portal_height = portal_dimensions - widget_width, widget_height = appearance_dimensions - x, y = self.position - if widget_width <= portal_width: - match self.x_alignment: - case Alignment.left: - x = 0 - case Alignment.center: - x = (widget_width - portal_width) // 2 - case Alignment.right: - x = widget_width - portal_width - case _: - raise NotImplementedError - elif x < 0: - x = 0 - elif x > (widget_width - portal_width): - x = widget_width - portal_width - if widget_height <= portal_height: - match self.y_alignment: - case Alignment.top: - y = 0 - case Alignment.center: - y = (widget_height - portal_height) // 2 - case Alignment.bottom: - y = widget_height - portal_height - case _: - raise NotImplementedError - elif y < 0: - y = 0 - elif y > (widget_height - portal_height): - y = widget_height - portal_height - self.position = x, y - - def appearance_for(self, portal_dimensions): - width, height = portal_dimensions - try: - widget_dimensions = self.widget.appearance_dimensions() - if self.is_scroll_limited: - self.limit_scroll(portal_dimensions, widget_dimensions) - x, y = self.position - widget_appearance = self.widget.appearance_interval((max(y, 0), y + height)) - except AttributeError: - widget_appearance = self.widget.appearance() - widget_dimensions = appearance_dimensions(self.widget.appearance()) - if self.is_scroll_limited: - self.limit_scroll(portal_dimensions, widget_dimensions) - x, y = self.position - widget_appearance = widget_appearance[max(y, 0):y + height] - self.last_dimensions = portal_dimensions - top = [self.background_char * width] * -y if y < 0 else [] - bottom = appearance_resize([row[max(x, 0):x + width] for row in widget_appearance], - (width + min(x, 0), height + min(y, 0)), - self.background_char) - if x < 0: - padding = self.background_char * -x - bottom = [padding + line for line in bottom] - return top + bottom - - -class View: - - def __init__(self, portal, horizontal_scrollbar, vertical_scrollbar, hide_scrollbars=True, - is_left_scrollbar=False): - self.portal = portal - self.horizontal_scrollbar = horizontal_scrollbar - self.vertical_scrollbar = vertical_scrollbar - self.hide_scrollbars = hide_scrollbars - self.is_left_scrollbar = is_left_scrollbar - - @classmethod - def from_widget(cls, widget): - return cls(Portal(widget), ScrollBar(is_horizontal=True), ScrollBar(is_horizontal=False)) - - @property - def position(self): - return self.portal.position - - @position.setter - def position(self, position): - self.portal.position = position - - @property - def widget(self): - return self.portal.widget - - @widget.setter - def widget(self, widget): - self.portal.widget = widget - - def appearance_for(self, dimensions): - width, height = dimensions - try: - full_width, full_height = self.portal.widget.appearance_dimensions() - except AttributeError: - full_appearance = self.portal.widget.appearance() - full_width, full_height = appearance_dimensions(full_appearance) - if full_width == 0 or full_height == 0: - return self.portal.appearance_for(dimensions) - x, y = self.portal.position - hide_scrollbar_vertical = self.hide_scrollbars and (full_height <= height or y < 0) - hide_scrollbar_horizontal = self.hide_scrollbars and (full_width <= width or x < 0) - if not hide_scrollbar_horizontal: - full_width = max(full_width, x + width) - self.horizontal_scrollbar.interval = (x / full_width, (x + width) / full_width) - height -= 1 - if not hide_scrollbar_vertical: - full_height = max(full_height, y + height) - self.vertical_scrollbar.interval = (y / full_height, (y + height) / full_height) - width -= 1 - portal_appearance = self.portal.appearance_for((width, height)) - if hide_scrollbar_vertical: - result = portal_appearance - else: - scrollbar_v_appearance = self.vertical_scrollbar.appearance_for((1, height)) - parts = ([scrollbar_v_appearance, portal_appearance] if self.is_left_scrollbar - else [portal_appearance, scrollbar_v_appearance]) - result = join_horizontal(parts) - if not hide_scrollbar_horizontal: - scrollbar_h_appearance = self.horizontal_scrollbar.appearance_for((width, 1)) - spacer = "" if hide_scrollbar_vertical else " " - result.append(spacer + scrollbar_h_appearance[0] if self.is_left_scrollbar - else scrollbar_h_appearance[0] + spacer) - return result - - -def str_to_appearance(text, pad_char=" "): - lines = text.splitlines() - if len(lines) == 0: - return [] - max_width = max(len(line) for line in lines) - height = len(lines) - return appearance_resize(lines, (max_width, height), pad_char) - - -class Text: - - def __init__(self, text, pad_char=" "): - self.text = str_to_appearance(text, pad_char) - - def appearance(self): - return self.text - - def appearance_for(self, dimensions): - return appearance_resize(self.appearance(), dimensions) - - -class Table: - - def __init__(self, table, pad_char=" "): - self._widgets = table - self._pad_char = pad_char - - def appearance(self): - if self._widgets == []: - return [] - appearances = [[cell.appearance() for cell in row] for row in self._widgets] - row_heights = [0] * len(self._widgets) - column_widths = [0] * len(self._widgets[0]) - for y, row in enumerate(appearances): - for x, appearance in enumerate(row): - width, height = appearance_dimensions(appearance) - row_heights[y] = max(row_heights[y], height) - column_widths[x] = max(column_widths[x], width) - return join_vertical([join_horizontal( - [appearance_resize(appearance, (column_widths[x], row_heights[y]), - pad_char=self._pad_char) - for x, appearance in enumerate(row)]) - for y, row in enumerate(appearances)]) - - -class Border: - - THIN = ["─", "─", "│", "│", "┌", "└", "┘", "┐"] - THICK = ["━", "━", "┃", "┃", "┏", "┗", "┛", "┓"] - ROUNDED = ["─", "─", "│", "│", "╭", "╰", "╯", "╮"] - DOUBLE = ["═", "═", "║", "║", "╔", "╚", "╝", "╗"] - - def __init__(self, widget, title=None, characters=THIN): - self.widget = widget - self.title = title - self.set_style(characters) - - def set_style(self, characters): - (self.top, self.bottom, self.left, self.right, self.top_left, - self.bottom_left, self.bottom_right, self.top_right) = characters - - def _add_border(self, body_content): - content_width, content_height = appearance_dimensions(body_content) - if self.title is None: - title_bar = self.top * content_width - else: - padded_title = " " + ("…" + self.title[-(content_width-3):] - if len(self.title) > content_width - 2 - else self.title) + " " - try: - title_bar = padded_title.center(content_width, self.top) - except TypeError: - padded_title = termstr.TermStr(padded_title) - title_bar = padded_title.center(content_width, self.top) - result = [self.top_left + title_bar + self.top_right] - result.extend(self.left + line + self.right for line in body_content) - result.append(self.bottom_left + self.bottom * content_width + self.bottom_right) - return result - - def appearance(self): - return self._add_border(self.widget.appearance()) - - def appearance_for(self, dimensions): - width, height = dimensions - return self._add_border(self.widget.appearance_for((width-2, height-2))) - - -class Placeholder: - - def __init__(self, widget=None): - self.widget = widget - - def appearance(self): - return self.widget.appearance() - - def appearance_for(self, dimensions): - return self.widget.appearance_for(dimensions) - - -class Fixed: - - def __init__(self, appearance): - self.appearance_ = appearance - self.dimensions = appearance_dimensions(appearance) - - def appearance(self): - return self.appearance_ - - def appearance_dimensions(self): - return self.dimensions - - -########################## -# Infrastructure - -_EXCEPTION = None -_LAST_APPEARANCE = [] - - -def handle_exception(func): - def wrapper(*args): - try: - return func(*args) - except Exception as exc: - global _EXCEPTION - _EXCEPTION = exc - SHUTDOWN_EVENT.set() - return wrapper - - -@handle_exception -def draw_screen(widget): - global _LAST_APPEARANCE - appearance = widget.appearance_for(os.get_terminal_size()) - print(terminal.move(0, 0), *appearance, sep="", end="", flush=True) - _LAST_APPEARANCE = appearance - - -@handle_exception -def patch_screen(widget): - global _LAST_APPEARANCE - appearance = widget.appearance_for(os.get_terminal_size()) - zip_func = (itertools.zip_longest if len(appearance) > len(_LAST_APPEARANCE) else zip) - changed_lines = (str(terminal.move(0, row_index)) + line for row_index, (line, old_line) - in enumerate(zip_func(appearance, _LAST_APPEARANCE)) if line != old_line) - print(*changed_lines, sep="", end="", flush=True) - _LAST_APPEARANCE = appearance - - -async def update_screen(screen_widget): - while True: - patch_screen(screen_widget) - await asyncio.sleep(0.01) # Limit the update rate - await APPEARANCE_CHANGED_EVENT.wait() - APPEARANCE_CHANGED_EVENT.clear() - - -_INPUT_REGEX = re.compile("\x1b[^\x00-\x1f]+|[\x00-\x1f]|[^\x00-\x1f]+") - - -@handle_exception -def on_terminal_input(screen_widget): - for part in _INPUT_REGEX.findall(sys.stdin.read()): - if part.startswith(terminal.MOUSE): - screen_widget.on_mouse_input(part[3:]) - else: - screen_widget.on_keyboard_input(part) - - -@contextlib.contextmanager -def signal_handler(loop, signal_, func): - loop.add_signal_handler(signal_, func) - try: - yield - finally: - loop.remove_signal_handler(signal_) - - -async def tui(title, screen_widget, input_handler=on_terminal_input): - global APPEARANCE_CHANGED_EVENT - global SHUTDOWN_EVENT - APPEARANCE_CHANGED_EVENT = asyncio.Event() - SHUTDOWN_EVENT = asyncio.Event() - loop = asyncio.get_running_loop() - with (signal_handler(loop, signal.SIGWINCH, lambda: draw_screen(screen_widget)), - signal_handler(loop, signal.SIGINT, SHUTDOWN_EVENT.set), - signal_handler(loop, signal.SIGTERM, SHUTDOWN_EVENT.set), terminal.title(title), - terminal.alternate_buffer(), terminal.raw(), terminal.mouse_tracking()): - update_task = asyncio.create_task(update_screen(screen_widget)) - try: - loop.add_reader(sys.stdin, input_handler, screen_widget) - try: - await SHUTDOWN_EVENT.wait() - finally: - loop.remove_reader(sys.stdin) - finally: - update_task.cancel() - if _EXCEPTION is not None: - raise _EXCEPTION - - -########################## -# Example application - -class _Screen: - - def __init__(self): - self.content = Filler(Text("Hello World")) - - def appearance_for(self, dimensions): - return self.content.appearance_for(dimensions) - - def on_keyboard_input(self, term_code): - if term_code in ["q", terminal.ESC, terminal.CTRL_C]: - SHUTDOWN_EVENT.set() - elif term_code == "e": - raise AssertionError # Program should shutdown and show exception. - else: - self.content = Filler(Text(repr(term_code))) - APPEARANCE_CHANGED_EVENT.set() - - def on_mouse_input(self, term_code): - mouse_code = terminal.decode_mouse_input(term_code) - self.content = Filler(Text(repr(term_code) + " " + repr(mouse_code))) - APPEARANCE_CHANGED_EVENT.set() - - -if __name__ == "__main__": - asyncio.run(tui("Hello World", _Screen())) diff --git a/fill3/fill3/terminal.py b/fill3/fill3/terminal.py deleted file mode 100644 index 934c725..0000000 --- a/fill3/fill3/terminal.py +++ /dev/null @@ -1,114 +0,0 @@ - - -import contextlib -import string -import sys -import termios - - -ESC = "\x1b" -UP = ESC + "[A" -DOWN = ESC + "[B" -RIGHT = ESC + "[C" -LEFT = ESC + "[D" -PAGE_UP = ESC + "[5~" -PAGE_DOWN = ESC + "[6~" -HOME = ESC + "[H" -END = ESC + "[F" -CTRL_UP = ESC + "[1;5A" -CTRL_DOWN = ESC + "[1;5B" -CTRL_LEFT = ESC + "[1;5D" -CTRL_RIGHT = ESC + "[1;5C" -CTRL_SPACE = "\x00" -CTRL_UNDERSCORE = "\x1f" -ENTER = "\n" -TAB = "\t" -INSERT = ESC + "[2~" -DELETE = ESC + "[3~" -BACKSPACE = "\x7f" -ALT_BACKSPACE = ESC + BACKSPACE -ALT_CARROT = ESC + "^" -ALT_SEMICOLON = ESC + ";" -ALT_UP = ESC + "[1;3A" -ALT_DOWN = ESC + "[1;3B" -ALT_LEFT = ESC + "[1;3D" -ALT_RIGHT = ESC + "[1;3C" -globals().update({f"ALT_{letter}": ESC + letter for letter in string.ascii_letters}) -globals().update({f"CTRL_{letter}": chr(index + 1) - for index, letter in enumerate(string.ascii_uppercase)}) - - -def move(x, y): - return f"{ESC}[{y + 1:d};{x + 1:d}H" - - -@contextlib.contextmanager -def title(title): - sys.stdout.write(ESC + "7") # save - sys.stdout.write(f"\033]0;{title}\007") # set title - try: - yield - finally: - sys.stdout.write(ESC + "8") # restore - - -@contextlib.contextmanager -def mouse_tracking(): - sys.stdout.write(ESC + "[?1000h" + ESC + "[?1002h") # tracking on - try: - yield - finally: - sys.stdout.write(ESC + "[?1002l" + ESC + "[?1000l") # tracking off - - -@contextlib.contextmanager -def alternate_buffer(): - sys.stdout.write(ESC + "[?1049h") # switch to alternate buffer - try: - yield - finally: - sys.stdout.write(ESC + "[?1049l") # restore normal buffer - - -@contextlib.contextmanager -def raw(): - old_termios_settings = termios.tcgetattr(sys.stdin) - new_settings = termios.tcgetattr(sys.stdin) - new_settings[0] = new_settings[0] & ~termios.IXON # ctrl-s doesn't freeze output - # Don't print keys. Read byte by byte. Ctrl-c and ctrl-z aren't special. - new_settings[3] = new_settings[3] & ~termios.ECHO & ~termios.ICANON & ~termios.ISIG - new_settings[6][termios.VMIN] = 0 # Read unblocks immediately on input - termios.tcsetattr(sys.stdin, termios.TCSADRAIN, new_settings) - sys.stdout.write(ESC + "[?1l") # Ensure normal cursor key codes - try: - yield - finally: - termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_termios_settings) - - -MOUSE = ESC + "[M" -MOUSE_RELEASE = 0 -MOUSE_DRAG = 1 -MOUSE_CLICK = 2 -MOUSE_PRESS = 3 -MOUSE_WHEEL_UP = 4 -MOUSE_WHEEL_DOWN = 5 - - -def decode_mouse_input(term_code): - keys = [ord(byte) for byte in term_code] - b = keys[0] - 32 - x, y = (keys[1] - 33) % 256, (keys[2] - 33) % 256 - button = ((b & 64) / 64 * 3) + (b & 3) + 1 - if b & 3 == 3: - action = MOUSE_RELEASE - button = 0 - elif b & 2048: - action = MOUSE_RELEASE - elif b & 32: - action = MOUSE_DRAG - elif b & 1536: - action = MOUSE_CLICK - else: - action = MOUSE_PRESS - return action, button, x, y diff --git a/fill3/pyproject.toml b/fill3/pyproject.toml deleted file mode 100644 index f792d5d..0000000 --- a/fill3/pyproject.toml +++ /dev/null @@ -1,19 +0,0 @@ -tool.uv.package = true - -[project] -name = "fill3" -version = "v2025.07.11" -description = "Fill3 provides basic widgets for a tui." -authors = [ - { name = "Andrew Hamilton", email = "and_hamilton@yahoo.com" }, -] -requires-python = ">=3.11" -dependencies = [ - "termstr", -] - -[project.urls] -Homepage = "https://github.com/ahamilton/eris" - -[tool.uv.sources] -termstr = { workspace = true } diff --git a/fill3/tests/fill3_test.py b/fill3/tests/fill3_test.py deleted file mode 100755 index e6ddec0..0000000 --- a/fill3/tests/fill3_test.py +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env python3.11 -# -*- coding: utf-8 -*- - - -import unittest - -import fill3 -import fill3.terminal as terminal -import termstr - - -class WidgetTests(unittest.TestCase): - - TEXT_A = fill3.Text("A") - TEXT_B = fill3.Text("B") - - def assert_string(self, appearance, expected_string): - self.assertEqual(str(termstr.join("\n", appearance)), expected_string) - - def test_rows_widget(self): - rows = fill3.Row([self.TEXT_A, self.TEXT_B]) - self.assert_string(rows.appearance(), "AB") - rows = fill3.Row([fill3.Filler(self.TEXT_A), fill3.Filler(self.TEXT_B)]) - self.assert_string(rows.appearance_for((4, 1)), "A B ") - - def test_columns_widget(self): - columns = fill3.Column([self.TEXT_A, self.TEXT_B]) - self.assert_string(columns.appearance(), "A\n" - "B") - - def test_text_widget(self): - self.assert_string(self.TEXT_A.appearance(), "A") - text = "foo\nbar" - self.assert_string(fill3.Text(text).appearance(), "foo\n" - "bar") - - def test_portal_widget(self): - row = fill3.Row([fill3.Text("foo"), fill3.Text("bar")]) - portal = fill3.Portal(row, (1, 0)) - self.assert_string(portal.appearance_for((5, 1)), "oobar") - portal.position = (0, 10) - self.assert_string(portal.appearance_for((1, 1)), " ") - portal.position = (1, 0) - self.assert_string(portal.appearance_for((6, 2)), "oobar \n ") - portal.position = (-1, -1) - self.assert_string(portal.appearance_for((2, 2)), " \n f") - self.assert_string(portal.appearance_for((8, 3)), " \n foobar \n ") - portal.position = (5, 0) - self.assert_string(portal.appearance_for((2, 1)), "r ") - # limit scroll - portal.position = (0, 0) - portal.limit_scroll((1, 1), (1, 1)) - self.assertEqual(portal.position, (0, 0)) - portal.position = (-1, 0) - portal.limit_scroll((1, 1), (1, 1)) - self.assertEqual(portal.position, (0, 0)) - portal.position = (1, 0) - portal.limit_scroll((6, 1), (1, 1)) - self.assertEqual(portal.position, (0, 0)) - portal.x_alignment = fill3.Alignment.right - portal.limit_scroll((6, 1), (1, 1)) - self.assertEqual(portal.position, (-5, 0)) - - def test_border_widget(self): - contents = fill3.Filler(self.TEXT_A) - self.assert_string(fill3.Border(contents).appearance_for((3, 3)), "┌─┐\n" - "│A│\n" - "└─┘") - for empty_contents in [fill3.Filler(fill3.Text("")), fill3.Column([])]: - self.assert_string(fill3.Border(empty_contents).appearance_for((2, 2)), "┌┐\n" - "└┘") - self.assert_string(fill3.Border(fill3.Column([])).appearance(), "┌┐\n" - "└┘") - self.assert_string(fill3.Border(empty_contents).appearance_for((3, 3)), "┌─┐\n" - "│ │\n" - "└─┘") - text = fill3.Text("abcdef") - self.assert_string(fill3.Border(text, title="AB").appearance_for((8, 3)), "┌─ AB ─┐\n" - "│abcdef│\n" - "└──────┘") - self.assert_string(fill3.Border(text, title="ABC").appearance_for((6, 3)), "┌ …C ┐\n" - "│abcd│\n" - "└────┘") - - def test_placeholder_widget(self): - placeholder = fill3.Placeholder(self.TEXT_A) - self.assert_string(placeholder.appearance(), "A") - placeholder.widget = self.TEXT_B - self.assert_string(placeholder.appearance(), "B") - - def assert_string2(self, appearance, expected_string): - self.assertEqual(("\n".join(line.data for line in appearance), - "".join("i" if style.fg_color == fill3.ScrollBar.DEFAULT_BACKGROUND_COLOR else " " - for line in appearance for style in line.style)), expected_string) - - def test_scroll_bar(self): - scroll_bar = fill3.ScrollBar(is_horizontal=True) - self.assertEqual(scroll_bar.interval, (0, 0)) - self.assert_string2(scroll_bar.appearance_for((1, 1)), (" ", "i")) - scroll_bar.interval = (0, 0.5) - self.assert_string2(scroll_bar.appearance_for((2, 1)), (" ", "i ")) - scroll_bar.interval = (0, 0.1) - self.assert_string2(scroll_bar.appearance_for((2, 1)), (" ", "i ")) - scroll_bar.interval = (0.25, 0.75) - self.assert_string2(scroll_bar.appearance_for((4, 1)), (" █ ", " i ")) - scroll_bar.interval = (0, 0.75) - self.assert_string2(scroll_bar.appearance_for((2, 1)), (" ▌", "i ")) - scroll_bar = fill3.ScrollBar(is_horizontal=False) - self.assertEqual(scroll_bar.interval, (0, 0)) - self.assert_string2(scroll_bar.appearance_for((1, 1)), ("█", " ")) - scroll_bar.interval = (0, 0.5) - self.assert_string2(scroll_bar.appearance_for((1, 2)), ("█\n" - "█", " i")) - scroll_bar.interval = (0, 0.1) - self.assert_string2(scroll_bar.appearance_for((1, 2)), ("█\n" - "█", " i")) - scroll_bar.interval = (0.25, 0.75) - self.assert_string2(scroll_bar.appearance_for((1, 4)), (" \n" - "█\n" - "█\n" - "█", " i")) - scroll_bar.interval = (0, 0.75) - self.assert_string2(scroll_bar.appearance_for((1, 2)), ("█\n" - "▄", " i")) - - def test_table_widget(self): - table = fill3.Table([]) - self.assert_string(table.appearance(), "") - table = fill3.Table([[self.TEXT_A]]) - self.assert_string(table.appearance(), "A") - table = fill3.Table([[self.TEXT_A, self.TEXT_B]]) - self.assert_string(table.appearance(), "AB") - table = fill3.Table([[self.TEXT_A, self.TEXT_B], - [self.TEXT_B, self.TEXT_A]]) - self.assert_string(table.appearance(), "AB\n" - "BA") - label_foo = fill3.Text("FOO") - table = fill3.Table([[label_foo, self.TEXT_B], - [self.TEXT_B, self.TEXT_A]]) - self.assert_string(table.appearance(), "FOOB\n" - "B A") - - -class TerminalInputTestCase(unittest.TestCase): - - def test_input_regex(self): - self.assertEqual(fill3._INPUT_REGEX.findall(""), []) - self.assertEqual(fill3._INPUT_REGEX.findall("a"), ["a"]) - self.assertEqual(fill3._INPUT_REGEX.findall("ab"), ["ab"]) - self.assertEqual(fill3._INPUT_REGEX.findall("a\nb"), ["a", "\n", "b"]) - self.assertEqual(fill3._INPUT_REGEX.findall("a\tb"), ["a", "\t", "b"]) - self.assertEqual(fill3._INPUT_REGEX.findall(terminal.UP * 2), [terminal.UP] * 2) - self.assertEqual(fill3._INPUT_REGEX.findall(terminal.CTRL_C * 2), [terminal.CTRL_C] * 2) - - -if __name__ == "__main__": - unittest.main() diff --git a/pyproject.toml b/pyproject.toml index b459638..1e9a8d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,22 +11,24 @@ authors = [ requires-python = ">=3.11" dependencies = [ "docopt-ng==0.9.0", - "fill3 @ git+https://forgejo.tail556789.ts.net/ahamilton/eris@v2025.07.11#subdirectory=fill3", + "fill3", "lscolors @ git+https://forgejo.tail556789.ts.net/ahamilton/eris@v2025.07.11#subdirectory=lscolors", "pexpect==4.9.0", "pillow==11.2.1", "pygments==2.19.1", "pyinotify-elephant-fork==0.0.1", "termstr @ git+https://forgejo.tail556789.ts.net/ahamilton/eris@v2025.07.11#subdirectory=termstr", + "textgrid", ] [tool.uv.sources] -fill3 = { workspace = true } +fill3 = { git = "https://forgejo.tail556789.ts.net/ahamilton/fill3.git" } lscolors = { workspace = true } termstr = { workspace = true } +textgrid = { git = "https://forgejo.tail556789.ts.net/ahamilton/textgrid-rust.git" } [tool.uv.workspace] -members = ["termstr", "fill3", "lscolors"] +members = ["termstr", "lscolors"] [project.scripts] eris = "eris.__main__:entry_point" diff --git a/uv.lock b/uv.lock index d548f02..b5d290d 100644 --- a/uv.lock +++ b/uv.lock @@ -1,11 +1,10 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11" [manifest] members = [ "eris", - "fill3", "lscolors", "termstr", ] @@ -38,31 +37,31 @@ dependencies = [ { name = "pygments" }, { name = "pyinotify-elephant-fork" }, { name = "termstr" }, + { name = "textgrid" }, ] [package.metadata] requires-dist = [ { name = "docopt-ng", specifier = "==0.9.0" }, - { name = "fill3", editable = "fill3" }, + { name = "fill3", git = "https://forgejo.tail556789.ts.net/ahamilton/fill3.git" }, { name = "lscolors", editable = "lscolors" }, { name = "pexpect", specifier = "==4.9.0" }, { name = "pillow", specifier = "==11.2.1" }, { name = "pygments", specifier = "==2.19.1" }, { name = "pyinotify-elephant-fork", specifier = "==0.0.1" }, { name = "termstr", editable = "termstr" }, + { name = "textgrid", git = "https://forgejo.tail556789.ts.net/ahamilton/textgrid-rust.git" }, ] [[package]] name = "fill3" -version = "2025.7.11" -source = { editable = "fill3" } +version = "2025.6.21" +source = { git = "https://forgejo.tail556789.ts.net/ahamilton/fill3.git#ee5b95f68639c5d026c745bae2f53dc0f5ec1dad" } dependencies = [ { name = "termstr" }, + { name = "textgrid" }, ] -[package.metadata] -requires-dist = [{ name = "termstr", editable = "termstr" }] - [[package]] name = "lscolors" version = "2025.7.11" @@ -171,7 +170,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/d6/b4/b44fccc7040b01449 [[package]] name = "termstr" -version = "2025.7.11" +version = "2025.6.21" source = { editable = "termstr" } dependencies = [ { name = "cwcwidth" }, @@ -179,3 +178,8 @@ dependencies = [ [package.metadata] requires-dist = [{ name = "cwcwidth", specifier = "==0.1.5" }] + +[[package]] +name = "textgrid" +version = "0.1.0" +source = { git = "https://forgejo.tail556789.ts.net/ahamilton/textgrid-rust.git#f04f3a8ae9b52f5407d07eee3ce1ffa559edeb63" }