471 lines
16 KiB
Python
471 lines
16 KiB
Python
|
|
# -*- coding: utf-8 -*-
|
|
|
|
# Copyright (C) 2015-2016 Andrew Hamilton. All rights reserved.
|
|
# Licensed under the Artistic License 2.0.
|
|
|
|
import collections
|
|
import contextlib
|
|
import itertools
|
|
import os
|
|
import signal
|
|
import sys
|
|
import threading
|
|
|
|
import urwid
|
|
import urwid.raw_display
|
|
|
|
import terminal
|
|
import termstr
|
|
|
|
|
|
def appearance_is_valid(appearance):
|
|
"""An appearance is a list of strings of equal length.
|
|
|
|
An empty list is valid. Empty strings are not allowed."""
|
|
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(seperator, parts):
|
|
"""Returns a string if all the parts and the seperator are plain strings.
|
|
|
|
In other words it returns a TermStr if anything is a TermStr."""
|
|
if parts == []:
|
|
return ""
|
|
try:
|
|
return seperator.join(parts)
|
|
except TypeError:
|
|
return termstr.TermStr(seperator).join(parts)
|
|
|
|
|
|
def join_horizontal(appearances):
|
|
heights = set(len(appearance) for appearance in appearances)
|
|
assert len(heights) == 1, heights
|
|
return [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 = int(round(float(width) / column_count * index))
|
|
end_pos = int(round(float(width) / column_count * (index+1)))
|
|
widths.append(end_pos - start_pos)
|
|
return widths
|
|
|
|
|
|
class Row(collections.UserList):
|
|
|
|
def __init__(self, widgets, widths_func=even_widths):
|
|
collections.UserList.__init__(self, widgets)
|
|
self.widgets = self.data
|
|
self.widths_func = widths_func
|
|
|
|
def appearance(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((item_width, height))
|
|
for column_widget, item_width
|
|
in zip(self.widgets, widths)])
|
|
|
|
def appearance_min(self):
|
|
appearances = [column_widget.appearance_min()
|
|
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 = int(round(float(height) / row_count * index))
|
|
end_pos = int(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(collections.UserList):
|
|
|
|
def __init__(self, widgets, partition_func=even_partition,
|
|
background_char=" "):
|
|
collections.UserList.__init__(self, widgets)
|
|
self.widgets = self.data
|
|
self.partition_func = partition_func
|
|
self.background_char = background_char
|
|
|
|
def appearance(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((width, item_height))
|
|
for row_widget, item_height
|
|
in zip(self.widgets, heights)])
|
|
|
|
def _appearance_list(self, widgets):
|
|
if widgets == []:
|
|
return []
|
|
appearances = [row_widget.appearance_min() 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_min(self):
|
|
return self._appearance_list(self.widgets)
|
|
|
|
|
|
class Filler:
|
|
|
|
def __init__(self, widget):
|
|
self.widget = widget
|
|
|
|
def appearance(self, dimensions):
|
|
return appearance_resize(self.widget.appearance_min(), dimensions)
|
|
|
|
|
|
class ScrollBar:
|
|
|
|
_GREY_BACKGROUND_STYLE = termstr.CharStyle(bg_color=termstr.Color.grey_100)
|
|
_GREY_BLOCK = termstr.TermStr(" ", _GREY_BACKGROUND_STYLE)
|
|
|
|
def __init__(self, is_horizontal, interval=(0, 0), bar_char=_GREY_BLOCK,
|
|
background_char=" "):
|
|
self._is_horizontal = is_horizontal
|
|
self.interval = interval
|
|
self.bar_char = bar_char
|
|
self.background_char = background_char
|
|
|
|
def appearance(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, end_index = [int(fraction * length)
|
|
for fraction in self.interval]
|
|
if start_index == end_index and end_index < length:
|
|
end_index += 1
|
|
bar = (self.background_char * start_index +
|
|
self.bar_char * (end_index - start_index) +
|
|
self.background_char * (length - end_index))
|
|
return [bar] if self._is_horizontal else [char for char in bar]
|
|
|
|
|
|
class Portal:
|
|
|
|
def __init__(self, widget, position=(0, 0), background_char=" "):
|
|
self.widget = widget
|
|
self.position = position
|
|
self.background_char = background_char
|
|
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 appearance(self, dimensions):
|
|
width, height = dimensions
|
|
x, y = self.position
|
|
try:
|
|
appearance = self.widget.appearance_interval((y, y+height))
|
|
except AttributeError:
|
|
appearance = self.widget.appearance_min()[y:y+height]
|
|
self.last_dimensions = dimensions
|
|
return appearance_resize([row[x:x+width] for row in appearance],
|
|
dimensions, self.background_char)
|
|
|
|
|
|
class View:
|
|
|
|
def __init__(self, portal, horizontal_scrollbar, vertical_scrollbar,
|
|
hide_scrollbars=True):
|
|
self.portal = portal
|
|
self.horizontal_scrollbar = horizontal_scrollbar
|
|
self.vertical_scrollbar = vertical_scrollbar
|
|
self.hide_scrollbars = hide_scrollbars
|
|
|
|
@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(self, dimensions):
|
|
width, height = dimensions
|
|
try:
|
|
full_width, full_height = (self.portal.widget.
|
|
appearance_dimensions())
|
|
except AttributeError:
|
|
full_appearance = self.portal.widget.appearance_min()
|
|
full_width, full_height = appearance_dimensions(full_appearance)
|
|
if full_width == 0 or full_height == 0:
|
|
return self.portal.appearance(dimensions)
|
|
x, y = self.portal.position
|
|
hide_scrollbar_vertical = (self.hide_scrollbars and
|
|
full_height <= height and y == 0)
|
|
hide_scrollbar_horizontal = (self.hide_scrollbars and
|
|
full_width <= width and 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((width, height))
|
|
if hide_scrollbar_vertical:
|
|
result = portal_appearance
|
|
else:
|
|
scrollbar_v_appearance = self.vertical_scrollbar.appearance(
|
|
(1, height))
|
|
result = join_horizontal([portal_appearance,
|
|
scrollbar_v_appearance])
|
|
if not hide_scrollbar_horizontal:
|
|
scrollbar_h_appearance = self.horizontal_scrollbar.appearance(
|
|
(width, 1))
|
|
result.append(scrollbar_h_appearance[0] +
|
|
("" if hide_scrollbar_vertical else " "))
|
|
return result
|
|
|
|
|
|
class Text:
|
|
|
|
def __init__(self, text, pad_char=" "):
|
|
lines = text.splitlines()
|
|
if len(lines) == 0:
|
|
self.text = []
|
|
elif len(lines) == 1:
|
|
self.text = [text]
|
|
else:
|
|
max_width = max(len(line) for line in lines)
|
|
height = len(lines)
|
|
self.text = appearance_resize(lines, (max_width, height), pad_char)
|
|
|
|
def appearance_min(self):
|
|
return self.text
|
|
|
|
def appearance(self, dimensions):
|
|
return appearance_resize(self.appearance_min(), dimensions)
|
|
|
|
|
|
class Table:
|
|
|
|
def __init__(self, table, pad_char=" "):
|
|
self._widgets = table
|
|
self._pad_char = pad_char
|
|
|
|
def appearance_min(self):
|
|
if self._widgets == []:
|
|
return []
|
|
appearances = [[cell.appearance_min() 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 = ["═", "═", "║", "║", "╔", "╚", "╝", "╗"]
|
|
HEAVY_INNER = ["▄", "▀", "▐", "▌", "▗", "▝", "▘", "▖"]
|
|
HEAVY_OUTER = ["▀", "▄", "▌", "▐", "▛", "▙", "▟", "▜"]
|
|
INNER = ["▁", "▔", "▕", "▏", " ", " ", " ", " "]
|
|
|
|
def __init__(self, widget, title=None, characters=THIN):
|
|
self.widget = widget
|
|
self.title = title
|
|
(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]
|
|
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_min(self):
|
|
return self._add_border(self.widget.appearance_min())
|
|
|
|
def appearance(self, dimensions):
|
|
width, height = dimensions
|
|
return self._add_border(self.widget.appearance((width-2, height-2)))
|
|
|
|
|
|
class Placeholder:
|
|
|
|
def __init__(self, widget=None):
|
|
self.widget = widget
|
|
|
|
def appearance_min(self):
|
|
return self.widget.appearance_min()
|
|
|
|
def appearance(self, dimensions):
|
|
return self.widget.appearance(dimensions)
|
|
|
|
|
|
def draw_screen(widget):
|
|
appearance = widget.appearance(os.get_terminal_size())
|
|
print(terminal.move(0, 0), *appearance, sep="", end="", flush=True)
|
|
|
|
|
|
_last_appearance = []
|
|
|
|
|
|
def patch_screen(widget):
|
|
global _last_appearance
|
|
appearance = widget.appearance(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
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _urwid_screen():
|
|
screen = urwid.raw_display.Screen()
|
|
screen.set_mouse_tracking(True)
|
|
screen.start()
|
|
try:
|
|
yield screen
|
|
finally:
|
|
screen.stop()
|
|
|
|
|
|
_UPDATE_THREAD_STOPPED = threading.Event()
|
|
|
|
|
|
def _update_screen(main_widget, appearance_changed_event):
|
|
while True:
|
|
appearance_changed_event.wait()
|
|
appearance_changed_event.clear()
|
|
if _UPDATE_THREAD_STOPPED.is_set():
|
|
break
|
|
patch_screen(main_widget)
|
|
|
|
|
|
def main(loop, appearance_changed_event, screen, exit_loop=None):
|
|
appearance_changed_event.set()
|
|
update_display_thread = threading.Thread(
|
|
target=_update_screen, args=(screen, appearance_changed_event),
|
|
daemon=True)
|
|
|
|
def exit_loop_():
|
|
loop.stop()
|
|
if exit_loop is None:
|
|
exit_loop = exit_loop_
|
|
loop.add_signal_handler(signal.SIGWINCH, appearance_changed_event.set)
|
|
loop.add_signal_handler(signal.SIGINT, exit_loop)
|
|
loop.add_signal_handler(signal.SIGTERM, exit_loop)
|
|
with terminal.hidden_cursor():
|
|
with _urwid_screen() as urwid_screen:
|
|
|
|
def on_input(urwid_screen):
|
|
for event in urwid_screen.get_input():
|
|
screen.on_input_event(event)
|
|
loop.add_reader(sys.stdin, on_input, urwid_screen)
|
|
update_display_thread.start()
|
|
try:
|
|
loop.run_forever()
|
|
finally:
|
|
_UPDATE_THREAD_STOPPED.set()
|
|
appearance_changed_event.set()
|
|
update_display_thread.join()
|
|
# loop.close()
|