Add --window flag with textgrid support; separate fill3 into standalone repo
This commit is contained in:
parent
412fc6a9a5
commit
c4adf649b4
9 changed files with 36 additions and 896 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
__pycache__/
|
||||||
|
*.orig
|
||||||
|
*.egg-info/
|
||||||
|
commits.txt
|
||||||
|
|
@ -32,6 +32,8 @@ import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
sys.path = [p for p in sys.path if p]
|
||||||
|
|
||||||
import docopt
|
import docopt
|
||||||
import fill3
|
import fill3
|
||||||
import fill3.terminal as terminal
|
import fill3.terminal as terminal
|
||||||
|
|
@ -69,6 +71,7 @@ Options:
|
||||||
highlighting. Defaults to "native".
|
highlighting. Defaults to "native".
|
||||||
-c TYPE, --compression=TYPE The type of compression used in the cache:
|
-c TYPE, --compression=TYPE The type of compression used in the cache:
|
||||||
gzip, lzma, bz2, or none. Defaults to gzip.
|
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.
|
--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,
|
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()
|
loop = asyncio.get_running_loop()
|
||||||
if worker_count is None:
|
if worker_count is None:
|
||||||
worker_count = max(multiprocessing.cpu_count() - 1, 1)
|
worker_count = max(multiprocessing.cpu_count() - 1, 1)
|
||||||
|
|
@ -1056,6 +1059,9 @@ async def main(title, root_path, worker_count=None, editor_command=None, theme=N
|
||||||
loop.create_task(worker_.future)
|
loop.create_task(worker_.future)
|
||||||
try:
|
try:
|
||||||
if sys.stdout.isatty() or "unittest" in sys.modules:
|
if sys.stdout.isatty() or "unittest" in sys.modules:
|
||||||
|
if use_window:
|
||||||
|
await fill3.textgrid_tui(title, screen)
|
||||||
|
else:
|
||||||
await fill3.tui(title, screen)
|
await fill3.tui(title, screen)
|
||||||
log.log_message("Program stopped.")
|
log.log_message("Program stopped.")
|
||||||
else:
|
else:
|
||||||
|
|
@ -1239,7 +1245,8 @@ def check_arguments():
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
editor_command = (arguments["--editor"] or os.environ.get("EDITOR", None)
|
editor_command = (arguments["--editor"] or os.environ.get("EDITOR", None)
|
||||||
or os.environ.get("VISUAL", 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():
|
def inotify_watches_exceeded():
|
||||||
|
|
@ -1250,12 +1257,14 @@ def inotify_watches_exceeded():
|
||||||
|
|
||||||
|
|
||||||
def entry_point():
|
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)
|
manage_cache(root_path)
|
||||||
title = "eris: " + os.path.basename(root_path)
|
title = "eris: " + os.path.basename(root_path)
|
||||||
with chdir(root_path): # FIX: Don't change directory if possible.
|
with chdir(root_path): # FIX: Don't change directory if possible.
|
||||||
try:
|
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:
|
except pyinotify.WatchManagerError:
|
||||||
inotify_watches_exceeded()
|
inotify_watches_exceeded()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
../LICENSE
|
|
||||||
|
|
@ -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<pre>" + "<br>".join(lines) + "</pre>")
|
|
||||||
|
|
||||||
|
|
||||||
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()))
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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 }
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -11,22 +11,24 @@ authors = [
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"docopt-ng==0.9.0",
|
"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",
|
"lscolors @ git+https://forgejo.tail556789.ts.net/ahamilton/eris@v2025.07.11#subdirectory=lscolors",
|
||||||
"pexpect==4.9.0",
|
"pexpect==4.9.0",
|
||||||
"pillow==11.2.1",
|
"pillow==11.2.1",
|
||||||
"pygments==2.19.1",
|
"pygments==2.19.1",
|
||||||
"pyinotify-elephant-fork==0.0.1",
|
"pyinotify-elephant-fork==0.0.1",
|
||||||
"termstr @ git+https://forgejo.tail556789.ts.net/ahamilton/eris@v2025.07.11#subdirectory=termstr",
|
"termstr @ git+https://forgejo.tail556789.ts.net/ahamilton/eris@v2025.07.11#subdirectory=termstr",
|
||||||
|
"textgrid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
fill3 = { workspace = true }
|
fill3 = { git = "https://forgejo.tail556789.ts.net/ahamilton/fill3.git" }
|
||||||
lscolors = { workspace = true }
|
lscolors = { workspace = true }
|
||||||
termstr = { workspace = true }
|
termstr = { workspace = true }
|
||||||
|
textgrid = { git = "https://forgejo.tail556789.ts.net/ahamilton/textgrid-rust.git" }
|
||||||
|
|
||||||
[tool.uv.workspace]
|
[tool.uv.workspace]
|
||||||
members = ["termstr", "fill3", "lscolors"]
|
members = ["termstr", "lscolors"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
eris = "eris.__main__:entry_point"
|
eris = "eris.__main__:entry_point"
|
||||||
|
|
|
||||||
22
uv.lock
generated
22
uv.lock
generated
|
|
@ -1,11 +1,10 @@
|
||||||
version = 1
|
version = 1
|
||||||
revision = 2
|
revision = 3
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
[manifest]
|
[manifest]
|
||||||
members = [
|
members = [
|
||||||
"eris",
|
"eris",
|
||||||
"fill3",
|
|
||||||
"lscolors",
|
"lscolors",
|
||||||
"termstr",
|
"termstr",
|
||||||
]
|
]
|
||||||
|
|
@ -38,31 +37,31 @@ dependencies = [
|
||||||
{ name = "pygments" },
|
{ name = "pygments" },
|
||||||
{ name = "pyinotify-elephant-fork" },
|
{ name = "pyinotify-elephant-fork" },
|
||||||
{ name = "termstr" },
|
{ name = "termstr" },
|
||||||
|
{ name = "textgrid" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "docopt-ng", specifier = "==0.9.0" },
|
{ 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 = "lscolors", editable = "lscolors" },
|
||||||
{ name = "pexpect", specifier = "==4.9.0" },
|
{ name = "pexpect", specifier = "==4.9.0" },
|
||||||
{ name = "pillow", specifier = "==11.2.1" },
|
{ name = "pillow", specifier = "==11.2.1" },
|
||||||
{ name = "pygments", specifier = "==2.19.1" },
|
{ name = "pygments", specifier = "==2.19.1" },
|
||||||
{ name = "pyinotify-elephant-fork", specifier = "==0.0.1" },
|
{ name = "pyinotify-elephant-fork", specifier = "==0.0.1" },
|
||||||
{ name = "termstr", editable = "termstr" },
|
{ name = "termstr", editable = "termstr" },
|
||||||
|
{ name = "textgrid", git = "https://forgejo.tail556789.ts.net/ahamilton/textgrid-rust.git" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fill3"
|
name = "fill3"
|
||||||
version = "2025.7.11"
|
version = "2025.6.21"
|
||||||
source = { editable = "fill3" }
|
source = { git = "https://forgejo.tail556789.ts.net/ahamilton/fill3.git#ee5b95f68639c5d026c745bae2f53dc0f5ec1dad" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "termstr" },
|
{ name = "termstr" },
|
||||||
|
{ name = "textgrid" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
|
||||||
requires-dist = [{ name = "termstr", editable = "termstr" }]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lscolors"
|
name = "lscolors"
|
||||||
version = "2025.7.11"
|
version = "2025.7.11"
|
||||||
|
|
@ -171,7 +170,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/d6/b4/b44fccc7040b01449
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "termstr"
|
name = "termstr"
|
||||||
version = "2025.7.11"
|
version = "2025.6.21"
|
||||||
source = { editable = "termstr" }
|
source = { editable = "termstr" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cwcwidth" },
|
{ name = "cwcwidth" },
|
||||||
|
|
@ -179,3 +178,8 @@ dependencies = [
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [{ name = "cwcwidth", specifier = "==0.1.5" }]
|
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" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue