initial commit

This commit is contained in:
Andrew Hamilton 2026-07-20 22:33:15 +10:00
commit 8ead8acd98
8 changed files with 1069 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
.venv/
__pycache__/
.pytest_cache/
build/
*.egg-info/

1
LICENSE Symbolic link
View file

@ -0,0 +1 @@
../LICENSE

646
fill3/__init__.py Executable file
View file

@ -0,0 +1,646 @@
#!/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
import textgrid
__version__ = "v2025.06.21"
##########################
# 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
##########################
# TextGrid frontend
async def _update_screen_textgrid(screen_widget, grid):
try:
while True:
grid.update(screen_widget.appearance_for(grid.get_dimensions()))
await asyncio.sleep(0.01)
await APPEARANCE_CHANGED_EVENT.wait()
APPEARANCE_CHANGED_EVENT.clear()
except Exception as exc:
global _EXCEPTION
_EXCEPTION = exc
SHUTDOWN_EVENT.set()
@handle_exception
def _dispatch_key(screen_widget, key):
if key.startswith(terminal.MOUSE):
screen_widget.on_mouse_input(key[3:])
else:
screen_widget.on_keyboard_input(key)
async def textgrid_tui(title, screen_widget, font_path=None, color_font_path=None,
cols=80, rows=24):
global APPEARANCE_CHANGED_EVENT, SHUTDOWN_EVENT
APPEARANCE_CHANGED_EVENT = asyncio.Event()
SHUTDOWN_EVENT = asyncio.Event()
loop = asyncio.get_running_loop()
def on_key(key):
loop.call_soon_threadsafe(_dispatch_key, screen_widget, key)
with (signal_handler(loop, signal.SIGINT, SHUTDOWN_EVENT.set),
signal_handler(loop, signal.SIGTERM, SHUTDOWN_EVENT.set)):
def on_close():
loop.call_soon_threadsafe(SHUTDOWN_EVENT.set)
grid = textgrid.TextGrid(rows, cols, title,
font_path=font_path, color_font_path=color_font_path,
on_key=on_key, on_close=on_close,
appearance_changed=APPEARANCE_CHANGED_EVENT,
event_loop=loop)
update_task = asyncio.create_task(_update_screen_textgrid(screen_widget, grid))
try:
await SHUTDOWN_EVENT.wait()
finally:
update_task.cancel()
grid.close()
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()
def _run():
if len(sys.argv) > 1 and sys.argv[1] == "window":
asyncio.run(textgrid_tui("Hello World", _Screen()))
else:
asyncio.run(tui("Hello World", _Screen()))
if __name__ == "__main__":
_run()

3
fill3/__main__.py Normal file
View file

@ -0,0 +1,3 @@
from fill3 import _run
_run()

114
fill3/terminal.py Normal file
View file

@ -0,0 +1,114 @@
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

26
pyproject.toml Normal file
View file

@ -0,0 +1,26 @@
tool.uv.package = true
[project]
name = "fill3"
version = "v2025.06.21"
description = "Fill3 provides basic widgets for a tui."
authors = [
{ name = "Andrew Hamilton", email = "and_hamilton@yahoo.com" },
]
requires-python = ">=3.11"
dependencies = [
"termstr",
"textgrid",
]
[project.urls]
Homepage = "https://github.com/ahamilton/eris"
[dependency-groups]
dev = [
"pytest>=9.1.1",
]
[tool.uv.sources]
textgrid = { git = "https://forgejo.tail556789.ts.net/ahamilton/textgrid-rust.git" }
termstr = { git = "https://forgejo.tail556789.ts.net/ahamilton/eris.git", subdirectory = "termstr" }

157
tests/fill3_test.py Executable file
View file

@ -0,0 +1,157 @@
#!/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()

117
uv.lock generated Normal file
View file

@ -0,0 +1,117 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "cwcwidth"
version = "0.1.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/38/17/aadd0c6190dca91aa27c4d7e84d69d30fdf4966e7764247cdc395f8fe7d9/cwcwidth-0.1.5.tar.gz", hash = "sha256:2c840e7d85f6de45c45986b416d79312c91882e1121b78d4c347e49c4238c09d", size = 11071, upload-time = "2021-10-10T16:46:56.825Z" }
[[package]]
name = "fill3"
version = "2025.6.21"
source = { editable = "." }
dependencies = [
{ name = "termstr" },
{ name = "textgrid" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "termstr", directory = "../../eris/termstr" },
{ name = "textgrid", directory = "../textgrid-rust" },
]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=9.1.1" }]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "termstr"
version = "2025.6.21"
source = { directory = "../../eris/termstr" }
dependencies = [
{ name = "cwcwidth" },
]
[package.metadata]
requires-dist = [{ name = "cwcwidth", specifier = "==0.1.5" }]
[[package]]
name = "textgrid"
version = "0.1.0"
source = { directory = "../textgrid-rust" }
[package.metadata]
[package.metadata.requires-dev]
dev = [
{ name = "cwcwidth", specifier = "==0.1.5" },
{ name = "termstr", directory = "../termstr" },
]