include ratuil in asciifarm project

This commit is contained in:
troido 2024-03-22 20:48:47 +01:00
parent 5f78982da9
commit 2084d6466b
29 changed files with 1629 additions and 0 deletions

0
ratuil/__init__.py Normal file
View File

128
ratuil/boxstyle.py Normal file
View File

@ -0,0 +1,128 @@
from enum import Enum
def clamp(x, lower, upper):
return min(max(x, lower), upper)
class Relativity(Enum):
ABSOLUTE = 0
RELATIVE = 1
VERY_RELATIVE = 2
class Value:
def __init__(self, val=0, relative=Relativity.ABSOLUTE):
self.val = val
self.relative = relative
def to_actual_value(self, available_size, remaining_size=None):
if remaining_size is None:
remaining_size = available_size
value = self.val
if self.relative == Relativity.VERY_RELATIVE:
value *= remaining_size
elif self.relative == Relativity.RELATIVE:
value *= available_size
return int(value)
@classmethod
def parse(self, text):
if text is None:
return None
text = str(text) # in case someone would enter a number
text = "".join(text.split()) # remove whitespace
if not text:
return None
relative = Relativity.ABSOLUTE
modifier = 1
if text[-1] == "/":
relative = Relativity.RELATIVE
text = text[:-1]
if text[-1] == "/":
relative = Relativity.VERY_RELATIVE
text = text[:-1]
elif text[-1] == "%":
relative = Relativity.RELATIVE
text = text[:-1]
modifier = 0.01
if text[-1] == "%":
relative = Relativity.VERY_RELATIVE
text = text[:-1]
if not text:
return None
if '.' in text:
val = float(text)
else:
val = int(text)
return Value(val * modifier, relative)
class BoxStyle():
LEFT = "left"
RIGHT = "right"
TOP = "top"
BOTTOM = "bottom"
def __init__(self, width=None, height=None, offset_x=None, offset_y=None, align_right=False, align_bottom=False, granularity=1, min_width=None, min_height=None, max_width=None, max_height=None):
self.width = width or Value(1, Relativity.RELATIVE)
self.height = height or Value(1, Relativity.RELATIVE)
self.min_width = min_width or Value(0)
self.min_height = min_height or Value(0)
self.max_width = max_width or Value(1, Relativity.RELATIVE)
self.max_height = max_height or Value(1, Relativity.RELATIVE)
self.offset_x = offset_x or Value(0)
self.offset_y = offset_y or Value(0)
self.granularity = granularity
self.align_right = align_right
self.align_bottom = align_bottom
def get_width(self, available, remaining=None):
return clamp(
self.width.to_actual_value(available, remaining),
self.min_width.to_actual_value(available, remaining),
self.max_width.to_actual_value(available, remaining)
)
def get_height(self, available, remaining=None):
return clamp(
self.height.to_actual_value(available, remaining),
self.min_height.to_actual_value(available, remaining),
self.max_height.to_actual_value(available, remaining)
)
def get_offset_x(self, available, remaining=None):
return self.offset_x.to_actual_value(available, remaining)
def get_offset_y(self, available, remaining=None):
return self.offset_y.to_actual_value(available, remaining)
@classmethod
def from_attrs(cls, attrs):
width = Value.parse(attrs.get("width"))
height = Value.parse(attrs.get("height"))
min_width = Value.parse(attrs.get("min-width"))
min_height = Value.parse(attrs.get("min-height"))
max_width = Value.parse(attrs.get("max-width"))
max_height = Value.parse(attrs.get("max-height"))
offset_x = Value.parse(attrs.get("offset-x"))
offset_y = Value.parse(attrs.get("offset-y"))
granularity = int(attrs.get("granularity", "1"))
align_right = ("right" in attrs.get("align", "").casefold() or "right" in attrs.get("hor-align", "").casefold())
align_bottom = ("bottom" in attrs.get("align", "").casefold() or "bottom" in attrs.get("vert-align", "").casefold())
return cls(
width = width,
height = height,
offset_x = offset_x,
offset_y = offset_y,
align_right = align_right,
align_bottom = align_bottom,
granularity = granularity,
min_width = min_width,
min_height = min_height,
max_width = max_width,
max_height = max_height
)

207
ratuil/bufferedscreen.py Normal file
View File

@ -0,0 +1,207 @@
import sys
import io
from .constants import INT_INFINITY
from .screen import Screen
from .pad import Pad
from .drawtarget import DrawTarget
from .strwidth import charwidth
class BufferedScreen(DrawTarget):
def __init__(self, out=sys.stdout, *args, **kwargs):
self.screen = RememberingScreen(out, *args, **kwargs)
self.buff = Pad(self.screen.width, self.screen.height)
@property
def width(self):
return self.screen.width
@property
def height(self):
return self.screen.height
def clear(self):
self.screen.clear()
self.buff = Pad(self.screen.width, self.screen.height)
def reset(self):
self.screen.reset()
self.clear()
def update(self):
self.screen.draw_pad(self.buff)
self.screen.update()
def write(self, x, y, text, style=None):
self.buff.write(x, y, text, style)
def draw_pad(self, pad, dest_x=0, dest_y=0, width=INT_INFINITY, height=INT_INFINITY, src_x=0, src_y=0):
self.buff.draw_pad(pad, dest_x, dest_y, width, height, src_x, src_y)
class RememberingScreen(DrawTarget):
def __init__(self, out=sys.stdout, *args, **kwargs):
self.out = out
self.screen = Screen(io.StringIO(), *args, **kwargs)
self.on_screen = Pad(self.screen.width, self.screen.height)
self.style = None
@property
def width(self):
return self.screen.width
@property
def height(self):
return self.screen.height
def clear(self):
self.on_screen = Pad(self.screen.width, self.screen.height)
self.screen.clear()
def reset(self):
self.screen.update_size()
self.clear()
def update(self):
self.out.write(self.screen.out.getvalue())
self.screen.out = io.StringIO()
self.out.flush()
def write(self, x, y, text, style=None):
text = crop(text, self.screen.width-x)
self.on_screen.write(x, y, text, style)
#self.screen.write(x, y, text, style)
self.screen.move(x, y)
self.screen.style(style, self.style)
self.style = style
self.screen.addstr(text)
def draw_pad_direct(self, *args, **kwargs):
self.on_screen.draw_pad(*args, **kwargs)
self.screen.draw_pad(*args, **kwargs)
def draw_pad(self, pad, dest_x=0, dest_y=0, width=INT_INFINITY, height=INT_INFINITY, src_x=0, src_y=0):
# Optimizes on the amount of characters to write to the terminal, which is more crucial in applications running over a network connection (like ssh)
# This will only draw the changed characters
width = min(width, self.screen.width - dest_x, pad.width - src_x)
height = min(height, self.screen.height - dest_y, pad.height - src_y)
BEGIN = "BEGIN" # before anything on the line has been done
RUNNING = "RUNNING" # while changing current characters
POSTRUN = "POSTRUN" # after changing some characters. Unsure whether to jump to next place or just continue
POSTPOSTRUN = "POSTPOSTRUN" # same, but now the style has been changed
BETWEEN = "BETWEEN" # run finished, but not worth to continue. Looking for the next changes
for y in range(height):
#runs = []
#current_run = None
running = False
last_run = None
post_run = ""
postpost_run = ""
post_style = None
extra = 0
skip = 0
state = BEGIN
#self.style = None
#cursor_x = None
for x, (buff_cell) in enumerate(pad.get_line(src_x, src_y + y, width)):#zip(
#self.on_screen.get_line(dest_x, dest_y + y, width),
#pad.get_line(src_x, src_y + y, width))):
scr_cell = self.on_screen.get(dest_x + x, dest_y + y)
if scr_cell is None:
scr_cell = (None, None)
scr_style, scr_char = scr_cell
if buff_cell is None:
if state == BEGIN:
continue
if state == RUNNING:
cursor_x = x
skip += 1
state = BETWEEN
continue
buff_style, buff_char = buff_cell
while True:
if state == BEGIN or state == BETWEEN:
if scr_cell == buff_cell:
skip += 1
break
# start the first run
if state == BEGIN:
skip = 0
self.screen.move(dest_x + x, dest_y + y)
#else:
#self.screen.skip(skip)#x-cursor_x)
#skip = 0
state = RUNNING
if state == RUNNING:
if scr_cell != buff_cell:
self.screen.skip(skip)
skip = 0
# continuing the same run
self.screen.style(buff_style, self.style)
self.style = buff_style
self.screen.addstr(buff_char)
self.on_screen.set_char(x, y, buff_char, buff_style)
w = charwidth(buff_char)
if w == 2:
skip -= 1
#self.on_screen.set_char(x + 2, y, None)
self.on_screen.set_char(x + 1, y, None)
#self.on_screen.set_char(x, y, None)
break
#cursor_x = x #+ char_width(buf_char) - 1
state = BETWEEN#POSTRUN
extra = 0
post_run = ""
postpost_run = ""
#if state == POSTRUN:
#if buff_cell != scr_cell:
#self.screen.skip(skip)
#skip = 0
#self.screen.addstr(post_run)
#state = RUNNING
#continue
#elif extra >= 4:
#skip += extra
#state = BETWEEN
#break
#elif buff_style == self.style:
#extra += 1
#post_run += buff_char
#break
#else:
#new_style = buff_style
#state = POSTPOSTRUN
#if state == POSTPOSTRUN:
#if buff_style != new_style:
#state = BETWEEN
#break
#if buff_cell != scr_cell:
#self.screen.addstr(post_run)
#self.screen.style(new_style, self.style)
#self.screen.addstr(postpost_run)
#self.style = new_style
#state = RUNNING
#elif extra >= 4:
#skip += extra
#state = BETWEEN
#break
#else:
#extra += 1
#postpost_run += buff_char
#break
#self.on_screen.draw_pad(pad, dest_x, dest_y, width, height, src_x, src_y)

5
ratuil/constants.py Normal file
View File

@ -0,0 +1,5 @@
INT_INFINITY = 2**64

26
ratuil/drawtarget.py Normal file
View File

@ -0,0 +1,26 @@
from .constants import INT_INFINITY
class DrawTarget:
# is actually more of an interface / trait than a useful class
width = None
height = None
def clear(self):
raise NotImplementedError()
def write(self, x, y, text, style=None):
raise NotImplementedError()
def write_styled(self, x, y, styledtext, extrastyle=None):
if isinstance(styledtext, str):
self.write(x, y, styledtext, extrastyle)
return
for text, style in styledtext:
self.write(x, y, text, style.add(extrastyle))
x += len(text)
def draw_pad(self, src, dest_x=0, dest_y=0, width=INT_INFINITY, height=INT_INFINITY, src_x=0, src_y=0):
raise NotImplementedError()

73
ratuil/inputs.py Normal file
View File

@ -0,0 +1,73 @@
import sys
BACKSPACE = "backspace"
ENTER = "enter"
DELETE = "delete"
ESCAPE = "escape"
UP = "up"
DOWN = "down"
LEFT = "left"
RIGHT = "right"
PAGEUP = "pageup"
PAGEDOWN = "pagedown"
HOME = "home"
END = "end"
BEFORE_LETTERS = ord('A') - 1
def name_char(char):
n = ord(char)
if n > 31 and n != 127:
return char
if n == 8 or n == 127:
return BACKSPACE
if n == 10 or n == 13:
return ENTER
if n > 0 and n <= 26:
return "^" + chr(n + BEFORE_LETTERS)
return "chr({})".format(n)
def get_key(stream=sys.stdin, combine_escape=True, do_interrupt=False):
char = stream.read(1)
if do_interrupt and ord(char) == 3:
raise KeyboardInterrupt
if ord(char) == 27:
if not combine_escape:
return ESCAPE
nextchar = stream.read(1)
while ord(nextchar) == 27: # avoid deep recursion
nextchar = stream.read(1)
if nextchar != "[":
return "\\e" + name_char(nextchar)
last = stream.read(1)
rest = last
while last in "1234567890;=?":
last = stream.read(1)
rest += last
if rest == "A":
return UP
elif rest == "B":
return DOWN
elif rest == "C":
return RIGHT
elif rest == "D":
return LEFT
elif rest == "H":
return HOME
elif rest == "F":
return END
elif rest == "3~":
return DELETE
elif rest == "5~":
return PAGEUP
elif rest == "6~":
return PAGEDOWN
else:
return "\\e[" + rest
else:
return name_char(char)

88
ratuil/layout.py Normal file
View File

@ -0,0 +1,88 @@
import os.path
from .screenelement import ScreenElement
from .widgets.textbox import TextBox
from .widgets.hbox import HBox
from .widgets.vbox import VBox
from .widgets.listing import Listing
from .widgets.border import Border
from .widgets.log import Log
from .widgets.textinput import TextInput
from .widgets.field import Field
from .widgets.bar import Bar
from .widgets.switchbox import SwitchBox
from .widgets.box import Box
from .widgets.fill import Fill
from .widgets.empty import Empty
from .widgets.overlay import Overlay
import xml.etree.ElementTree as ET
widgets = {
"textbox": TextBox,
"hbox": HBox,
"vbox": VBox,
"listing": Listing,
"border": Border,
"log": Log,
"textinput": TextInput,
"field": Field,
"bar": Bar,
"switchbox": SwitchBox,
"box": Box,
"fill": Fill,
"empty": Empty,
"overlay": Overlay
}
class Layout:
def __init__(self, tree, basepath=""):
self.tree = tree
self.id_elements = {}
self.changed = True
self.target = None
self.layout = self.build_layout(self.tree)
self._target_size = None
def build_layout(self, etree):
children = [self.build_layout(child) for child in etree]
widget = widgets[etree.tag].from_xml(children, etree.attrib, etree.text)
se = ScreenElement(widget, etree.attrib)
if se.id is not None:
self.id_elements[se.id] = se
return se
def set_target(self, target):
self.target = target
self.resize()
def resize(self):
self.layout.resize(self.target)
self._target_size = (self.target.width, self.target.height)
self.changed = True
def update(self, force=False):
if self._target_size != (self.target.width, self.target.height):
self.resize()
if self.changed:
force = True
self.changed = False
self.layout.update(force)
def get(self, id):
return self.id_elements.get(id).widget
@classmethod
def from_xml_str(cls, string, basepath=""):
return cls(ET.fromstring(string), basepath)
@classmethod
def from_xml_file(cls, fname, basepath=None):
if basepath is None:
basepath = os.path.dirname(fname)
return cls(ET.parse(fname).getroot(), basepath)

66
ratuil/pad.py Normal file
View File

@ -0,0 +1,66 @@
from .constants import INT_INFINITY
from .drawtarget import DrawTarget
from .strwidth import charwidth
class Pad(DrawTarget):
def __init__(self, width, height):
self.width = width
self.height = height
self.clear()
def resize(self, width, height):
self.width = width
self.height = height
self.clear()
def fill(self, value):
self.data = [value for i in range(self.width*self.height)]
def clear(self):
self.fill(None)
def write(self, x, y, text, style=None):
if y >= self.height:
return
for char in text:
w = charwidth(char)
if x + w > self.width:
break
self.set_char(x, y, char, style)
if w == 2:
self.delete(x + 1, y)
x += w
def set_char(self, x, y, char, style=None):
self.data[x + y * self.width] = (style, char)
def delete(self, x, y):
self.data[x + y * self.width] = None
def get(self, x, y):
if y >= self.height or x >= self.width:
return None
return self.data[x + y * self.width]
def get_line(self, x, y, length=None):
if length is None:
length = self.width - x
if x >= self.width:
return []
start = x + y * self.width
return self.data[start:start+length]
def draw_pad(self, src, dest_x=0, dest_y=0, width=INT_INFINITY, height=INT_INFINITY, src_x=0, src_y=0):
dest = self
width = min(width, dest.width - dest_x, src.width - src_x)
height = min(height, dest.height - dest_y, src.height - src_y)
for y in range(height):
for x, cell in enumerate(src.get_line(src_x, src_y + y, width)):
if cell is not None:
style, char = cell
self.write(dest_x + x, dest_y + y, char, style)

141
ratuil/screen.py Normal file
View File

@ -0,0 +1,141 @@
import sys
import shutil
from .constants import INT_INFINITY
from .drawtarget import DrawTarget
from .textstyle import TextStyle
from .strwidth import charwidth
class Attr:
RESET = "0"
BOLD = "1"
UNDERSCORE = "4"
BLINK = "5"
REVERSE = "7"
CONCEALED = "8"
FG_DEFAULT = "39"
BG_DEFAULT = "49"
FG_COLORS = [str(i) for i in list(range(30, 38)) + list(range(90, 98))]
BG_COLORS = [str(i) for i in list(range(40, 48)) + list(range(100, 108))]
ATTRS = {
TextStyle.BOLD: BOLD,
TextStyle.REVERSE: REVERSE,
TextStyle.UNDERSCORE: UNDERSCORE,
TextStyle.BLINK: BLINK
}
class Screen(DrawTarget):
def __init__(self, out=sys.stdout, always_reset=False, blink_bright_background=False):
self.out = out
self.width = 0
self.height = 0
self.blink_bright_background = blink_bright_background # use the blink attribute for bright backgrounds
self.always_reset = always_reset or blink_bright_background # always reset if the style is different than the previous one
self.update_size()
def update_size(self):
size = shutil.get_terminal_size()
self.width = size.columns
self.height = size.lines
def move(self, x, y):
self.out.write("\033[{};{}f".format(y+1, x+1))
def addstr(self, text):
self.out.write(text)
def style(self, style, previous=None):
if style is None:
style = TextStyle.default
if style == previous:
return
parts = []
reset = False
if style.fg is None or style.bg is None or previous is None or previous != style and self.always_reset:
parts.append(Attr.RESET)
reset = True
else :
for attr, enabled in style.attr.items():
if not enabled and previous.attr[attr]:
parts.append(Attr.RESET)
reset = True
if style.fg is not None and (reset or style.fg != previous.fg):
parts.append(Attr.FG_COLORS[style.fg])
if style.bg is not None and (reset or style.bg != previous.bg):
parts.append(Attr.BG_COLORS[style.bg])
if style.bg > 7 and self.blink_bright_background:
parts.append(Attr.BLINK)
for attr, enabled in style.attr.items():
if enabled and (reset or not previous.attr[attr]):
parts.append(Attr.ATTRS[attr])
ansistyle = "\033[" + ";".join(parts) + "m"
self.out.write(ansistyle)
def write(self, x, y, text, style=None):
self.move(x, y)
self.style(style)
self.addstr(text)
def clear(self):
self.out.write("\033[0m\033[2J")
def clear_line(self):
self.out.write("\033[K")
def skip(self, amount=1):
if amount == 0:
return
if amount == 1:
stramount = ""
else:
stramount = str(abs(amount))
self.out.write("\033[{}{}".format(stramount, ("C" if amount >= 0 else "D")))
def draw_pad(self, pad, scr_x=0, scr_y=0, width=INT_INFINITY, height=INT_INFINITY, pad_x=0, pad_y=0):
screen = self
width = min(width, screen.width - scr_x, pad.width - pad_x)
height = min(height, screen.height - scr_y, pad.height - pad_y)
last_style = None
for y in range(height):
screen.move(scr_x, scr_y+y)
skip = 0
line_y = pad_y + y
for cell in pad.get_line(pad_x, line_y, width):
if cell is None:
skip += 1
continue
if skip != 0:
screen.skip(skip)
skip = 0
style, char = cell
screen.style(style, last_style)
last_style = style
screen.addstr(char)
skip += 1 - charwidth(char)
def hide_cursor(self):
self.out.write("\033[?25l")
def show_cursor(self):
self.out.write("\033[?25h")
def finalize(self):
self.style(None)
self.move(0, self.height - 1)
self.show_cursor()
self.out.flush()
def update(self):
self.out.flush()
Screen.default = Screen()

28
ratuil/screenelement.py Normal file
View File

@ -0,0 +1,28 @@
from .boxstyle import BoxStyle
class ScreenElement:
def __init__(self, widget, attr):
self.widget = widget
self.style = BoxStyle.from_attrs(attr)
self.id = attr.get("id")
self.key = attr.get("key")
self.hidden = bool(attr.get("hidden", False))
def hide(self):
self.hidden = True
def show(self):
self.hidden = False
def resize(self, target):
if target is not None and (target.width <= 0 or target.height <= 0):
target = None
self.target = target
self.widget.resize(target)
def update(self, force):
if self.target and not self.hidden:
return self.widget.update(self.target, force)
return False

68
ratuil/strwidth.py Normal file
View File

@ -0,0 +1,68 @@
import unicodedata
# taken from textwrap
_whitespace = '\t\n\x0b\x0c\r '
def charwidth(char):
""" The width of a single character. Ambiguous width is considered 1"""
cat = unicodedata.category(char)
if cat == "Mn":
return 0
eaw = unicodedata.east_asian_width(char)
if eaw == "Na" or eaw == "H":
return 1
if eaw == "F" or eaw == "W":
return 2
if eaw == "A":
return 1
if eaw == "N":
return 1
raise Exception("unknown east easian width for character {}: {}".format(ord(char), char))
def strwidth(text):
""" The total width of a string """
return sum(charwidth(ch) for ch in text)
def width(text):
return stringwidth(text)
def width_index(text, width):
""" The largest index i for which the strwidth(text[:i]) <= width """
l = 0
for i, char in enumerate(text):
w = charwidth(char)
if l + w > width:
return i
l += w
return len(text)
def crop(text, width):
return text[:width_index(text, width)]
def wrap(text, width, separators=None):
lines = []
for line in text.splitlines():
while True:
cutoff = width_index(line, width)
if cutoff >= len(line):
lines.append(line)
break
if separators is not None:
last_sep = max(line.rfind(c, 0, cutoff+1) for c in separators)
if last_sep > 0:
cutoff = last_sep
lines.append(line[:cutoff])
if separators is not None:
while line[cutoff] in separators:
cutoff += 1
line = line[cutoff:]
return lines
def wrap_words(text, width):
return wrap(text, width, separators=_whitespace)

147
ratuil/textstyle.py Normal file
View File

@ -0,0 +1,147 @@
class Attr:
RESET = "0"
BOLD = "1"
UNDERSCORE = "4"
BLINK = "5"
REVERSE = "7"
CONCEALED = "8"
#FG_BLACK = "30"
#FG_RED = "31"
#FG_GREEN = "32"
#FG_YELLOW = "33"
#FG_BLUE = "34"
#FG_MAGENTA = "35"
#FG_CYAN = "36"
#FG_WHITE = "37"
#BG_BLACK = "40"
#BG_RED = "41"
#BG_GREEN = "42"
#BG_YELLOW = "43"
#BG_BLUE = "44"
#BG_MAGENTA = "45"
#BG_CYAN = "46"
#BG_WHITE = "47"
#FG_BRIGHT_BLACK = "90"
#FG_BRIGHT_RED = "91"
#FG_BRIGHT_GREEN = "92"
#FG_BRIGHT_YELLOW = "93"
#FG_BRIGHT_BLUE = "94"
#FG_BRIGHT_MAGENTA = "95"
#FG_BRIGHT_CYAN = "96"
#FG_BRIGHT_WHITE = "97"
#BG_BRIGHT_BLACK = "100"
#BG_BRIGHT_RED = "101"
#BG_BRIGHT_GREEN = "102"
#BG_BRIGHT_YELLOW = "103"
#BG_BRIGHT_BLUE = "104"
#BG_BRIGHT_MAGENTA = "105"
#BG_BRIGHT_CYAN = "106"
#BG_BRIGHT_WHITE = "107"
FG_DEFAULT = "39"
BG_DEFAULT = "49"
FG_COLORS = [str(i) for i in list(range(30, 38)) + list(range(90, 98))]
BG_COLORS = [str(i) for i in list(range(40, 48)) + list(range(100, 108))]
class TextStyle:
BLACK = 0
RED = 1
GREEN = 2
YELLOW = 3
BLUE = 4
MAGENTA = 5
CYAN = 6
WHITE = 7
BRIGHT_BLACK = 8
BRIGHT_RED = 9
BRIGHT_GREEN = 10
BRIGHT_YELLOW = 11
BRIGHT_BLUE = 12
BRIGHT_MAGENTA = 13
BRIGHT_CYAN = 14
BRIGHT_WHITE = 15
COLORS = list(range(16))
BOLD = "bold"
REVERSE = "reverse"
UNDERSCORE = "underscore"
BLINK = "blink"
ATTRIBUTES = [BOLD, REVERSE, UNDERSCORE]
def __init__(self, fg=None, bg=None, bold=False, reverse=False, underscore=False):
self.fg = fg
self.bg = bg
self.attr = {
self.BOLD: bold,
self.REVERSE: reverse,
self.UNDERSCORE: underscore
}
self.attr_set = frozenset(key for key, value in self.attr.items() if value)
def __eq__(self, other):
return isinstance(other, TextStyle) and other.fg == self.fg and other.bg == self.bg and self.attr_set == other.attr_set
def __repr__(self):
if self == self.default:
return "TextStyle()"
return "TextStyle({}, {}, {})".format(self.fg, self.bg, ", ".join(self.attr.values()))
def add(self, other):
if other is None:
other = TextStyle()
fg = self.fg
if other.fg is not None:
fg = other.fg
bg = self.bg
if other.bg is not None:
bg = other.bg
attrs = dict(self.attr)
for key, val in other.attr.items():
if val:
attrs[key] = val
return TextStyle(fg, bg, **attrs)
@property
def bold(self):
return self.attr[self.BOLD]
@property
def underscore(self):
return self.attr[self.UNDERSCORE]
@property
def reverse(self):
return self.attr[self.REVERSE]
@classmethod
def from_str(cls, text):
if text is None:
return TextStyle.default
fg = None
bg = None
attrs = {}
parts = text.split(";")
for part in parts:
attr, _sep, value = part.partition(":")
attr = attr.strip().casefold()
value = value.strip()
if attr == "fg" and int(value) in TextStyle.COLORS:
fg = int(value)
if attr == "bg" and int(value) in TextStyle.COLORS:
bg = int(value)
if attr in TextStyle.ATTRIBUTES:
attrs[attr] = True
return cls(fg, bg, **attrs)
TextStyle.default = TextStyle()

View File

@ -0,0 +1,34 @@
class Widget:
_changed = True
def change(self):
self._changed = True
def is_changed(self):
return self._changed
def unchange(self):
self._changed = False
def resize(self, screen):
self.change()
def update(self, target, force=False):
""" draw the widget onto target.
if force is false and the widget did not change since the previous update, don't do anything
return whether anything was drawn
"""
if self.is_changed() or force:
self.draw(target)
self.unchange()
return True
return False
@classmethod
def from_xml(cls, children, attr, text):
raise NotImplementedError

40
ratuil/widgets/bar.py Normal file
View File

@ -0,0 +1,40 @@
from . import Widget
from .. textstyle import TextStyle
from ..strwidth import strwidth
class Bar(Widget):
""" A bar (healthbar/mana bar/nutrition bar etc) that can be filled, empty, or something in between)"""
def __init__(self, attr):
self.full_char = attr.get("full-char", "#")
self.empty_char = attr.get("empty-char", " ")
assert strwidth(self.full_char) == strwidth(self.empty_char) == 1
self.full_style = TextStyle.from_str(attr.get("full-style", ""))
self.empty_style = TextStyle.from_str(attr.get("empty-style", ""))
self.total = int(attr.get("total", "-1"))
self.filled = int(attr.get("filled", "0"))
def set_total(self, total):
self.total = total
self.change()
def set_filled(self, filled):
self.filled = filled
self.change()
def draw(self, target):
target.clear()
width = target.width
height = target.height
bar_end = round(self.filled / self.total * width) if self.total > 0 else 0
for y in range(target.height):
target.write(0, y, self.full_char * bar_end , self.full_style)
target.write(bar_end, y, self.empty_char[0]*(width - bar_end), self.empty_style)
@classmethod
def from_xml(cls, children, attr, text):
return cls(attr)

54
ratuil/widgets/border.py Normal file
View File

@ -0,0 +1,54 @@
from . import Widget
from ..window import Window
from ..textstyle import TextStyle
from ..strwidth import strwidth
class Border(Widget):
def __init__(self, child, attr):
self.child = child
self.vertchar = "|"
self.horchar = "-"
self.cornerchar = "+"
self.style = TextStyle.from_str(attr.get("style"))
char = attr.get("char")
if char is not None:
self.vertchar = char
self.horchar = char
self.cornerchar = char
self.vertchar = attr.get("vertchar", self.vertchar)
self.horchar = attr.get("horchar", self.horchar)
self.cornerchar = attr.get("cornerchar", self.cornerchar)
assert strwidth(self.horchar) == 1
assert strwidth(self.vertchar) == 1
assert strwidth(self.cornerchar) == 1
def resize(self, target):
if target is None:
self.child.resize(None)
else:
win = Window(target, 1, 1, target.width - 2, target.height - 2)
self.child.resize(win)
self.change()
def update(self, target, force=False):
if self.is_changed() or force:
self.draw(target)
force = True
self.unchange()
return self.child.update(force) or force
def draw(self, target):
target.write(0, 0, self.cornerchar + self.horchar * (target.width - 2) + self.cornerchar, self.style)
target.write(0, target.height - 1, self.cornerchar + self.horchar * (target.width - 2) + self.cornerchar, self.style)
for y in range(1, target.height - 1):
target.write(0, y, self.vertchar, self.style)
target.write(target.width-1, y, self.vertchar, self.style)
@classmethod
def from_xml(cls, children, attr, text):
assert len(children) == 1
return cls(children[0], attr)

22
ratuil/widgets/box.py Normal file
View File

@ -0,0 +1,22 @@
from . import Widget
class Box(Widget):
# doesn't do anything
# This is useful when you want to separate attributes
def __init__(self, child):
self.child = child
def resize(self, target):
self.child.resize(target)
def update(self, target, force=False):
return self.child.update(force)
@classmethod
def from_xml(cls, children, attr, text):
assert len(children) == 1
return cls(children[0])

17
ratuil/widgets/empty.py Normal file
View File

@ -0,0 +1,17 @@
from . import Widget
class Empty(Widget):
# just some empty transparent space
def resize(self, target):
pass
def update(self, target, force=False):
return False
@classmethod
def from_xml(cls, children, attr, text):
return Empty()

57
ratuil/widgets/field.py Normal file
View File

@ -0,0 +1,57 @@
from . import Widget
from ..pad import Pad
class Field(Widget):
def __init__(self, width=0, height=0, char_size=1):
self.width = width
self.height = height
self.char_size = char_size
self.pad = Pad(self.width * self.char_size, self.height)
self.center = (0, 0)
self.redraw = False
def set_char_size(self, char_size):
self.char_size = char_size
self.pad = Pad(self.width * self.char_size, self.height)
def set_size(self, width, height):
self.width = width
self.height = height
self.pad.resize(width * self.char_size, height)
self.redraw = True
self.change()
def change_cell(self, x, y, char, style=None):
if x < 0 or y < 0 or x >= self.width or y >= self.height:
return
self.pad.write(x * self.char_size, y, char, style)
self.change()
def set_center(self, x, y):
self.center = (x, y)
self.change()
def _round_width(self, x):
return x // self.char_size * self.char_size
def draw(self, target):
center_x, center_y = self.center
target.draw_pad(
self.pad,
src_x = max(0, min(
self._round_width(self.pad.width - target.width),
self._round_width(center_x * self.char_size - target.width // 2)
)),
src_y = max(0, min(self.pad.height - target.height, center_y - target.height // 2)),
width = self._round_width(target.width),
dest_x = max(0, (target.width - self.pad.width) // 2),
dest_y = max(0, (target.height - self.pad.height) // 2)
)
@classmethod
def from_xml(cls, children, attr, text):
return cls(char_size=int(attr.get("char-size", 1)))

29
ratuil/widgets/fill.py Normal file
View File

@ -0,0 +1,29 @@
from . import Widget
from ..textstyle import TextStyle
from ..strwidth import crop, strwidth
import math
class Fill(Widget):
def __init__(self, char, style=None):
self.set_filling(char, style)
def set_filling(self, char, style=None):
assert strwidth(char) > 0
self.char = char
self.style = style
self.change()
def draw(self, target):
target.clear()
line = crop(self.char * math.ceil(target.width / strwidth(self.char)), target.width)
for y in range(target.height):
target.write(0, y, line, self.style)
@classmethod
def from_xml(cls, children, attr, text):
return cls(text.strip() if text is not None else attr.get("char", "#"), TextStyle.from_str(attr.get("style")))

26
ratuil/widgets/hbox.py Normal file
View File

@ -0,0 +1,26 @@
from .splitbox import SplitBox
from ..window import Window
class HBox(SplitBox):
def resize(self, target):
if target is None:
for child in self.children:
child.resize(None)
return
start = 0
end = target.width
for child in self.children:
if start >= end:
child.resize(None)
continue
width = end - start
width = min(width, child.style.get_width(target.width, width))
if child.style.align_right:
win = Window(target, end - width, 0, width, target.height)
end -= width
else:
win = Window(target, start, 0, width, target.height)
start += width
child.resize(win)

51
ratuil/widgets/listing.py Normal file
View File

@ -0,0 +1,51 @@
from . import Widget
from ..strwidth import strwidth, crop
class Listing(Widget):
def __init__(self, selected=0, selector_char="*", items=None):
if items is not None:
self.items = list(items)#[line.strip() for line in etree.text.splitlines() if line.strip()]
else:
self.items = []
self.selector = selected
self.selector_char = selector_char
def set_items(self, items):
self.items = items
self.change()
def select(self, index):
self.selector = index
self.change()
def draw(self, target):
target.clear()
width = target.width
height = target.height
start = min(self.selector - height//2, len(self.items) - height)
start = max(start, 0)
end = start + height
for i, item in enumerate(self.items[start:end]):
if i + start == self.selector:
target.write(0, i, self.selector_char)
target.write(strwidth(self.selector_char), i, item)
if end < len(self.items):
target.write(width-1, height-1, "+")
if start > 0:
target.write(width-1, 0, "-")
@classmethod
def from_xml(cls, children, attr, text):
kwargs = {}
if text is not None:
kwargs["items"] = [line.strip() for line in text.splitlines() if line.strip()]
if "select" in attr:
kwargs["selected"] = int(attr["select"])
if "selector" in attr:
kwargs["selector_char"] = attr["selector"]
return cls(**kwargs)

60
ratuil/widgets/log.py Normal file
View File

@ -0,0 +1,60 @@
from . import Widget
from ..strwidth import wrap
class Log(Widget):
def __init__(self, messages=None):
self.messages = list(messages or [])
self.scrolled_back = 0
def add_message(self, message, style=None):
self.messages.append((message, style))
if self.scrolled_back:
self.scrolled_back += 1
self.change()
def scroll(self, amount, relative=True):
if relative:
self.scrolled_back += amount
else:
self.scrolled_back = amount
self.scrolled_back = max(self.scrolled_back, 0)
self.change()
def draw(self, target):
width = target.width
height = target.height
lines = []
messages = self.messages
for message, style in messages:
for line in wrap(message, width):
lines.append((line, style))
self.scrolled_back = max(min(self.scrolled_back, len(lines)-height), 0)
moreDown = False
if self.scrolled_back > 0:
lines = lines[:-self.scrolled_back]
moreDown = True
moreUp = False
if len(lines) > height:
moreUp = True
lines = lines[len(lines)-height:]
elif len(lines) < height:
lines = (height-len(lines)) * [("",)] + lines
target.clear()
for i, line in enumerate(lines):
target.write(0, i, *line)
if moreUp:
target.write(width-1, 0, '-')
if moreDown:
target.write(width-1, height-1, '+')
@classmethod
def from_xml(cls, children, attr, text):
if text is not None:
messages = [(line.strip(), None) for line in text.splitlines() if line.strip()]
return cls(messages)
else:
return cls()

57
ratuil/widgets/overlay.py Normal file
View File

@ -0,0 +1,57 @@
from . import Widget
from ..window import Window
class Overlay(Widget):
def __init__(self, children):
self.children = children
def _get_child(self, index):
if isinstance(index, int):
return self.children(index)
if isinstance(index, str):
key = index.casefold()
for i, child in enumerate(self.children):
if child.key == key:
return child
def hide(self, index):
self._get_child(index).hide()
self.change()
def show(self, index):
self._get_child(index).show()
self.change()
def resize(self, target):
for child in self.children:
x = child.style.get_offset_x(target.width)
y = child.style.get_offset_y(target.height)
width = min(child.style.get_width(target.width), target.width - x)
height = min(child.style.get_height(target.height), target.height - y)
if child.style.align_right:
x = target.width - x - width
if child.style.align_bottom:
y = target.height - y - height
win = Window(target, x, y, width, height)
child.resize(win)
def update(self, target, force):
if self.is_changed():
force = True
self.unchange()
children = [child for child in self.children if not child.hidden]
if not children:
return False
#child[0].update(force)
for child in self.children:
# if any child is changed, all next children get forced updates
force = child.update(force) or force
#self.children[self.selected].update(force)
return force
@classmethod
def from_xml(cls, children, attr, text):
return cls(children)

View File

@ -0,0 +1,20 @@
from . import Widget
class SplitBox(Widget):
def __init__(self, children):
self.children = children
def resize(self, target):
raise NotImplementedError
def update(self, target, force=False):
changed = False
for child in self.children:
changed = child.update(force) or changed
return changed
@classmethod
def from_xml(cls, children, attr, text):
return cls(children)

View File

@ -0,0 +1,33 @@
from . import Widget
class SwitchBox(Widget):
def __init__(self, children, selected=0):
self.children = children
self.select(selected)
def select(self, selected):
if isinstance(selected, str):
key = selected.casefold()
for i, child in enumerate(self.children):
if child.key == key:
selected = i
break
self.selected = selected
self.change()
def resize(self, target):
for child in self.children:
child.resize(target)
def update(self, target, force):
if self.is_changed():
force = True
self.unchange()
return self.children[self.selected].update(force) or force
@classmethod
def from_xml(cls, children, attr, text):
return cls(children, attr.get("selected", int(attr.get("selected-val", 0))))

50
ratuil/widgets/textbox.py Normal file
View File

@ -0,0 +1,50 @@
from . import Widget
from ..strwidth import crop, wrap, wrap_words
from ..textstyle import TextStyle
from collections import defaultdict
class TextBox(Widget):
def __init__(self, text="", wrap=None, use_format=False, style=None):
self.lines = []
if wrap is None or wrap == "":
wrap = "crop"
assert wrap in {"crop", "words", "chars"}
self.wrap = wrap
self.use_format = use_format
self.set_text(text, None)
def set_text(self, text, style=None):
self.text = text
self.style = style
if self.use_format:
self.format(defaultdict(str))
else:
self.lines = text.splitlines()
self.change()
def format(self, values):
self.lines = self.text.format_map(values).splitlines()
self.change()
def draw(self, target):
target.clear()
lines = []
if self.wrap == "crop":
lines = [crop(line, target.width) for line in self.lines][:target.height]
elif self.wrap == "chars":
for line in self.lines:
lines.extend(wrap(line, target.width))
elif self.wrap == "words":
for line in self.lines:
lines.extend(wrap_words(line, target.width))
for y, line in enumerate(lines[:target.height]):
target.write(0, y, line, self.style)
@classmethod
def from_xml(cls, children, attr, text):
wrap = attr.get("wrap")
use_format = bool(attr.get("format"))
return cls((text or "").strip(), wrap, use_format, TextStyle.from_str(attr.get("style", "")))

View File

@ -0,0 +1,42 @@
from . import Widget
from ..textstyle import TextStyle
from ..strwidth import strwidth, width_index
class TextInput(Widget):
def __init__(self):
self.text = ""
self.cursor = None
def set_text(self, text, cursor=None):
self.text = text
if cursor is not None:
assert cursor >= 0
self.cursor = cursor
self.change()
def draw(self, target):
target.clear()
if self.cursor is None:
target.write(0, 0, self.text)
else:
text = self.text
cursor_pos = strwidth(self.text[:self.cursor])
textwidth = strwidth(self.text)
offset = max(0, cursor_pos - target.width * 0.9)
chars_offset = width_index(text, offset)
offset_text = self.text[chars_offset:]
target.write(0, 0, offset_text)
if self.cursor < len(self.text):
c = self.text[self.cursor]
else:
c = ' '
target.write(cursor_pos - strwidth(self.text[:chars_offset]), 0, c, TextStyle(reverse=True))
@classmethod
def from_xml(cls, children, attr, text):
return cls()

29
ratuil/widgets/vbox.py Normal file
View File

@ -0,0 +1,29 @@
from .splitbox import SplitBox
from ..window import Window
class VBox(SplitBox):
def resize(self, target):
if target is None:
for child in self.children:
child.resize(None)
return
start = 0
end = target.height
for child in self.children:
if start >= end:
child.resize(None)
continue
height = end - start
height = min(height, child.style.get_height(target.height, height))
if height <= 0:
child.resize(None)
continue
if child.style.align_bottom:
win = Window(target, 0, end - height, target.width, height)
end -= height
else:
win = Window(target, 0, start, target.width, height)
start += height
child.resize(win)

31
ratuil/window.py Normal file
View File

@ -0,0 +1,31 @@
from .constants import INT_INFINITY
from .drawtarget import DrawTarget
from .strwidth import crop
class Window(DrawTarget):
def __init__(self, target, x=0, y=0, width=None, height=None):
self.target = target
self.x = x
self.y = y
self.width = width if width is not None else target.width - x
self.height = height if height is not None else target.height - y
def write(self, x, y, text, style=None):
if x < 0 or y < 0 or y >= self.height:
raise IndexError("Trying to write outside window")
text = crop(text, self.width - x)
self.target.write(self.x + x, self.y + y, text, style)
def clear(self):
for y in range(self.height):
self.write(0, y, " " * self.width)
def draw_pad(self, pad, dest_x=0, dest_y=0, width=INT_INFINITY, height=INT_INFINITY, src_x=0, src_y=0):
if dest_x < 0 or dest_y < 0:
raise IndexError("Trying to draw pad outside window")
width = min(width, self.width - dest_x)
height = min(height, self.height - dest_y)
self.target.draw_pad(pad, self.x + dest_x, self.y + dest_y, width, height, src_x, src_y)