From 2084d6466ba27e45fc2344c303af1c0062b9911b Mon Sep 17 00:00:00 2001 From: troido Date: Fri, 22 Mar 2024 20:48:47 +0100 Subject: [PATCH] include ratuil in asciifarm project --- ratuil/__init__.py | 0 ratuil/boxstyle.py | 128 ++++++++++++++++++++++ ratuil/bufferedscreen.py | 207 ++++++++++++++++++++++++++++++++++++ ratuil/constants.py | 5 + ratuil/drawtarget.py | 26 +++++ ratuil/inputs.py | 73 +++++++++++++ ratuil/layout.py | 88 +++++++++++++++ ratuil/pad.py | 66 ++++++++++++ ratuil/screen.py | 141 ++++++++++++++++++++++++ ratuil/screenelement.py | 28 +++++ ratuil/strwidth.py | 68 ++++++++++++ ratuil/textstyle.py | 147 +++++++++++++++++++++++++ ratuil/widgets/__init__.py | 34 ++++++ ratuil/widgets/bar.py | 40 +++++++ ratuil/widgets/border.py | 54 ++++++++++ ratuil/widgets/box.py | 22 ++++ ratuil/widgets/empty.py | 17 +++ ratuil/widgets/field.py | 57 ++++++++++ ratuil/widgets/fill.py | 29 +++++ ratuil/widgets/hbox.py | 26 +++++ ratuil/widgets/listing.py | 51 +++++++++ ratuil/widgets/log.py | 60 +++++++++++ ratuil/widgets/overlay.py | 57 ++++++++++ ratuil/widgets/splitbox.py | 20 ++++ ratuil/widgets/switchbox.py | 33 ++++++ ratuil/widgets/textbox.py | 50 +++++++++ ratuil/widgets/textinput.py | 42 ++++++++ ratuil/widgets/vbox.py | 29 +++++ ratuil/window.py | 31 ++++++ 29 files changed, 1629 insertions(+) create mode 100644 ratuil/__init__.py create mode 100644 ratuil/boxstyle.py create mode 100644 ratuil/bufferedscreen.py create mode 100644 ratuil/constants.py create mode 100644 ratuil/drawtarget.py create mode 100644 ratuil/inputs.py create mode 100644 ratuil/layout.py create mode 100644 ratuil/pad.py create mode 100644 ratuil/screen.py create mode 100644 ratuil/screenelement.py create mode 100644 ratuil/strwidth.py create mode 100644 ratuil/textstyle.py create mode 100644 ratuil/widgets/__init__.py create mode 100644 ratuil/widgets/bar.py create mode 100644 ratuil/widgets/border.py create mode 100644 ratuil/widgets/box.py create mode 100644 ratuil/widgets/empty.py create mode 100644 ratuil/widgets/field.py create mode 100644 ratuil/widgets/fill.py create mode 100644 ratuil/widgets/hbox.py create mode 100644 ratuil/widgets/listing.py create mode 100644 ratuil/widgets/log.py create mode 100644 ratuil/widgets/overlay.py create mode 100644 ratuil/widgets/splitbox.py create mode 100644 ratuil/widgets/switchbox.py create mode 100644 ratuil/widgets/textbox.py create mode 100644 ratuil/widgets/textinput.py create mode 100644 ratuil/widgets/vbox.py create mode 100644 ratuil/window.py diff --git a/ratuil/__init__.py b/ratuil/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ratuil/boxstyle.py b/ratuil/boxstyle.py new file mode 100644 index 0000000..9d220ff --- /dev/null +++ b/ratuil/boxstyle.py @@ -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 + ) diff --git a/ratuil/bufferedscreen.py b/ratuil/bufferedscreen.py new file mode 100644 index 0000000..50d7e9e --- /dev/null +++ b/ratuil/bufferedscreen.py @@ -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) diff --git a/ratuil/constants.py b/ratuil/constants.py new file mode 100644 index 0000000..4d3d65a --- /dev/null +++ b/ratuil/constants.py @@ -0,0 +1,5 @@ + + +INT_INFINITY = 2**64 + + diff --git a/ratuil/drawtarget.py b/ratuil/drawtarget.py new file mode 100644 index 0000000..6079035 --- /dev/null +++ b/ratuil/drawtarget.py @@ -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() diff --git a/ratuil/inputs.py b/ratuil/inputs.py new file mode 100644 index 0000000..f2a995a --- /dev/null +++ b/ratuil/inputs.py @@ -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) + + diff --git a/ratuil/layout.py b/ratuil/layout.py new file mode 100644 index 0000000..22d19b1 --- /dev/null +++ b/ratuil/layout.py @@ -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) + diff --git a/ratuil/pad.py b/ratuil/pad.py new file mode 100644 index 0000000..bbe7ddd --- /dev/null +++ b/ratuil/pad.py @@ -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) diff --git a/ratuil/screen.py b/ratuil/screen.py new file mode 100644 index 0000000..d2a080b --- /dev/null +++ b/ratuil/screen.py @@ -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() diff --git a/ratuil/screenelement.py b/ratuil/screenelement.py new file mode 100644 index 0000000..a43602b --- /dev/null +++ b/ratuil/screenelement.py @@ -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 diff --git a/ratuil/strwidth.py b/ratuil/strwidth.py new file mode 100644 index 0000000..316936f --- /dev/null +++ b/ratuil/strwidth.py @@ -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) + diff --git a/ratuil/textstyle.py b/ratuil/textstyle.py new file mode 100644 index 0000000..b5eeace --- /dev/null +++ b/ratuil/textstyle.py @@ -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() diff --git a/ratuil/widgets/__init__.py b/ratuil/widgets/__init__.py new file mode 100644 index 0000000..790dd54 --- /dev/null +++ b/ratuil/widgets/__init__.py @@ -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 diff --git a/ratuil/widgets/bar.py b/ratuil/widgets/bar.py new file mode 100644 index 0000000..b248856 --- /dev/null +++ b/ratuil/widgets/bar.py @@ -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) + diff --git a/ratuil/widgets/border.py b/ratuil/widgets/border.py new file mode 100644 index 0000000..fb98717 --- /dev/null +++ b/ratuil/widgets/border.py @@ -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) + diff --git a/ratuil/widgets/box.py b/ratuil/widgets/box.py new file mode 100644 index 0000000..196bd1d --- /dev/null +++ b/ratuil/widgets/box.py @@ -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]) + diff --git a/ratuil/widgets/empty.py b/ratuil/widgets/empty.py new file mode 100644 index 0000000..b549651 --- /dev/null +++ b/ratuil/widgets/empty.py @@ -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() + diff --git a/ratuil/widgets/field.py b/ratuil/widgets/field.py new file mode 100644 index 0000000..a575a19 --- /dev/null +++ b/ratuil/widgets/field.py @@ -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))) diff --git a/ratuil/widgets/fill.py b/ratuil/widgets/fill.py new file mode 100644 index 0000000..252431d --- /dev/null +++ b/ratuil/widgets/fill.py @@ -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"))) + + + diff --git a/ratuil/widgets/hbox.py b/ratuil/widgets/hbox.py new file mode 100644 index 0000000..b10ba72 --- /dev/null +++ b/ratuil/widgets/hbox.py @@ -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) diff --git a/ratuil/widgets/listing.py b/ratuil/widgets/listing.py new file mode 100644 index 0000000..af6dc91 --- /dev/null +++ b/ratuil/widgets/listing.py @@ -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) + + diff --git a/ratuil/widgets/log.py b/ratuil/widgets/log.py new file mode 100644 index 0000000..c79f706 --- /dev/null +++ b/ratuil/widgets/log.py @@ -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() diff --git a/ratuil/widgets/overlay.py b/ratuil/widgets/overlay.py new file mode 100644 index 0000000..553464b --- /dev/null +++ b/ratuil/widgets/overlay.py @@ -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) diff --git a/ratuil/widgets/splitbox.py b/ratuil/widgets/splitbox.py new file mode 100644 index 0000000..c14baa8 --- /dev/null +++ b/ratuil/widgets/splitbox.py @@ -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) diff --git a/ratuil/widgets/switchbox.py b/ratuil/widgets/switchbox.py new file mode 100644 index 0000000..369480e --- /dev/null +++ b/ratuil/widgets/switchbox.py @@ -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)))) diff --git a/ratuil/widgets/textbox.py b/ratuil/widgets/textbox.py new file mode 100644 index 0000000..3eedcbc --- /dev/null +++ b/ratuil/widgets/textbox.py @@ -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", ""))) diff --git a/ratuil/widgets/textinput.py b/ratuil/widgets/textinput.py new file mode 100644 index 0000000..02b1c22 --- /dev/null +++ b/ratuil/widgets/textinput.py @@ -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() diff --git a/ratuil/widgets/vbox.py b/ratuil/widgets/vbox.py new file mode 100644 index 0000000..229adb2 --- /dev/null +++ b/ratuil/widgets/vbox.py @@ -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) diff --git a/ratuil/window.py b/ratuil/window.py new file mode 100644 index 0000000..237c501 --- /dev/null +++ b/ratuil/window.py @@ -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) +