initial commit
This commit is contained in:
commit
8ead8acd98
8 changed files with 1069 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
build/
|
||||
*.egg-info/
|
||||
1
LICENSE
Symbolic link
1
LICENSE
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../LICENSE
|
||||
646
fill3/__init__.py
Executable file
646
fill3/__init__.py
Executable 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
3
fill3/__main__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from fill3 import _run
|
||||
|
||||
_run()
|
||||
114
fill3/terminal.py
Normal file
114
fill3/terminal.py
Normal 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
26
pyproject.toml
Normal 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
157
tests/fill3_test.py
Executable 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
117
uv.lock
generated
Normal 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" },
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue