mirror of
https://github.com/ihabunek/toot.git
synced 2024-11-03 04:17:21 -05:00
Show images
This commit is contained in:
parent
219225ba8a
commit
6d0edaf16f
@ -2,4 +2,5 @@ requests>=2.13,<3.0
|
||||
beautifulsoup4>=4.5.0,<5.0
|
||||
wcwidth>=0.1.7
|
||||
urwid>=2.0.0,<3.0
|
||||
|
||||
pillow>=9.5.0
|
||||
term-image==0.6.1
|
||||
|
2
setup.py
2
setup.py
@ -39,6 +39,8 @@ setup(
|
||||
"wcwidth>=0.1.7",
|
||||
"urwid>=2.0.0,<3.0",
|
||||
"tomlkit>=0.10.0,<1.0"
|
||||
"pillow>=9.5.0",
|
||||
"term-image==0.6.1",
|
||||
],
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
|
@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import urwid
|
||||
import requests
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
@ -16,6 +17,10 @@ from .poll import Poll
|
||||
from .timeline import Timeline
|
||||
from .utils import get_max_toot_chars, parse_content_links, show_media, copy_to_clipboard
|
||||
|
||||
from PIL import Image
|
||||
from term_image.widget import UrwidImageScreen
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
urwid.set_encoding('UTF-8')
|
||||
@ -91,6 +96,7 @@ class TUI(urwid.Frame):
|
||||
loop = urwid.MainLoop(
|
||||
tui,
|
||||
palette=MONO_PALETTE if args.no_color else PALETTE,
|
||||
screen=UrwidImageScreen(), # like urwid.raw_display.Screen, but clears Kitty + iTerm2 images on startup
|
||||
event_loop=urwid.AsyncioEventLoop(),
|
||||
unhandled_input=tui.unhandled_input,
|
||||
screen=screen,
|
||||
@ -649,6 +655,30 @@ class TUI(urwid.Frame):
|
||||
|
||||
return self.run_in_thread(_delete, done_callback=_done)
|
||||
|
||||
def async_load_image(self, timeline, status, path, placeholder_index):
|
||||
def _load():
|
||||
# don't bother loading images for statuses we are not viewing now
|
||||
if timeline.get_focused_status().id != status.id:
|
||||
return
|
||||
|
||||
if not hasattr(timeline, "images"):
|
||||
timeline.images = dict()
|
||||
try:
|
||||
img = Image.open(requests.get(path, stream=True).raw)
|
||||
if img.format == 'PNG' and img.mode != 'RGBA':
|
||||
img = img.convert("RGBA")
|
||||
timeline.images[str(hash(path))] = img
|
||||
except: # noqa E722
|
||||
pass # ignore errors; if we can't load an image, just show blank
|
||||
|
||||
def _done(loop):
|
||||
# don't bother loading images for statuses we are not viewing now
|
||||
if timeline.get_focused_status().id != status.id:
|
||||
return
|
||||
timeline.update_status_image(status, path, placeholder_index)
|
||||
|
||||
return self.run_in_thread(_load, done_callback=_done)
|
||||
|
||||
def copy_status(self, status):
|
||||
# TODO: copy a better version of status content
|
||||
# including URLs
|
||||
|
@ -1,13 +1,17 @@
|
||||
import json
|
||||
import requests
|
||||
import traceback
|
||||
import urwid
|
||||
import webbrowser
|
||||
|
||||
from toot import __version__
|
||||
from toot.utils import format_content
|
||||
from .utils import highlight_hashtags, highlight_keys
|
||||
from .widgets import Button, EditBox, SelectableText
|
||||
from .utils import highlight_hashtags, highlight_keys, add_corners
|
||||
from .widgets import Button, EditBox, SelectableText, EmojiText
|
||||
from toot import api
|
||||
from PIL import Image
|
||||
from term_image.image import AutoImage
|
||||
from term_image.widget import UrwidImage
|
||||
|
||||
|
||||
class StatusSource(urwid.Padding):
|
||||
@ -254,6 +258,45 @@ class Account(urwid.ListBox):
|
||||
walker = urwid.SimpleListWalker(actions)
|
||||
super().__init__(walker)
|
||||
|
||||
def account_header(self, account):
|
||||
if account['avatar'] and not account["avatar"].endswith("missing.png"):
|
||||
img = Image.open(requests.get(account['avatar'], stream=True).raw)
|
||||
|
||||
if img.format == 'PNG' and img.mode != 'RGBA':
|
||||
img = img.convert("RGBA")
|
||||
aimg = urwid.BoxAdapter(
|
||||
UrwidImage(
|
||||
AutoImage(
|
||||
add_corners(img, 10)), upscale=True),
|
||||
10)
|
||||
else:
|
||||
aimg = urwid.BoxAdapter(urwid.SolidFill(" "), 10)
|
||||
|
||||
if account['header'] and not account["header"].endswith("missing.png"):
|
||||
img = Image.open(requests.get(account['header'], stream=True).raw)
|
||||
|
||||
if img.format == 'PNG' and img.mode != 'RGBA':
|
||||
img = img.convert("RGBA")
|
||||
himg = (urwid.BoxAdapter(
|
||||
UrwidImage(
|
||||
AutoImage(
|
||||
add_corners(img, 10)
|
||||
), upscale=True),
|
||||
10)
|
||||
)
|
||||
else:
|
||||
himg = urwid.BoxAdapter(urwid.SolidFill(" "), 10)
|
||||
|
||||
atxt = urwid.Pile([urwid.Divider(),
|
||||
urwid.AttrMap(
|
||||
EmojiText(account["display_name"], account["emojis"]),
|
||||
"account"),
|
||||
(urwid.Text(("highlight", "@" + self.account['acct'])))])
|
||||
columns = urwid.Columns([aimg, ("weight", 9999, himg)], dividechars=2, min_width=20)
|
||||
|
||||
header = urwid.Pile([columns, urwid.Divider(), atxt])
|
||||
return header
|
||||
|
||||
def generate_contents(self, account, relationship=None, last_action=None):
|
||||
if self.last_action and not self.last_action.startswith("Confirm"):
|
||||
yield Button(f"Confirm {self.last_action}", on_press=take_action, user_data=self)
|
||||
@ -275,16 +318,13 @@ class Account(urwid.ListBox):
|
||||
|
||||
yield urwid.Divider("─")
|
||||
yield urwid.Divider()
|
||||
yield urwid.Text([("account", f"@{account['acct']}"), f" {account['display_name']}"])
|
||||
|
||||
yield self.account_header(account)
|
||||
|
||||
if account["note"]:
|
||||
yield urwid.Divider()
|
||||
for line in format_content(account["note"]):
|
||||
yield urwid.Text(highlight_hashtags(line, followed_tags=set()))
|
||||
|
||||
yield urwid.Divider()
|
||||
yield urwid.Text(["ID: ", ("highlight", f"{account['id']}")])
|
||||
yield urwid.Text(["Since: ", ("highlight", f"{account['created_at'][:10]}")])
|
||||
yield urwid.Divider()
|
||||
|
||||
if account["bot"]:
|
||||
|
@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import math
|
||||
import sys
|
||||
import urwid
|
||||
import webbrowser
|
||||
@ -6,6 +7,7 @@ import webbrowser
|
||||
from typing import List, Optional
|
||||
|
||||
from toot.tui import app
|
||||
from toot.tui.utils import can_render_pixels, add_corners
|
||||
from toot.utils import format_content
|
||||
from toot.utils.datetime import parse_datetime, time_ago
|
||||
from toot.utils.language import language_name
|
||||
@ -13,15 +15,20 @@ from toot.utils.language import language_name
|
||||
from .entities import Status
|
||||
from .scroll import Scrollable, ScrollBar
|
||||
from .utils import highlight_hashtags, highlight_keys
|
||||
from .widgets import SelectableText, SelectableColumns
|
||||
from .widgets import SelectableText, SelectableColumns, EmojiText
|
||||
|
||||
from term_image.image import AutoImage
|
||||
from term_image.widget import UrwidImage
|
||||
|
||||
logger = logging.getLogger("toot")
|
||||
screen = urwid.raw_display.Screen()
|
||||
|
||||
|
||||
class Timeline(urwid.Columns):
|
||||
"""
|
||||
Displays a list of statuses to the left, and status details on the right.
|
||||
"""
|
||||
|
||||
signals = [
|
||||
"close", # Close thread
|
||||
"focus", # Focus changed
|
||||
@ -42,6 +49,7 @@ class Timeline(urwid.Columns):
|
||||
self.is_thread = is_thread
|
||||
self.statuses = statuses
|
||||
self.status_list = self.build_status_list(statuses, focus=focus)
|
||||
self.can_render_pixels = can_render_pixels()
|
||||
|
||||
try:
|
||||
focused_status = statuses[focus]
|
||||
@ -275,7 +283,7 @@ class Timeline(urwid.Columns):
|
||||
|
||||
def get_status_index(self, id):
|
||||
# TODO: This is suboptimal, consider a better way
|
||||
for n, status in enumerate(self.statuses):
|
||||
for n, status in enumerate(self.statuses.copy()):
|
||||
if status.id == id:
|
||||
return n
|
||||
raise ValueError("Status with ID {} not found".format(id))
|
||||
@ -299,6 +307,26 @@ class Timeline(urwid.Columns):
|
||||
if index == self.status_list.body.focus:
|
||||
self.draw_status_details(status)
|
||||
|
||||
def update_status_image(self, status, path, placeholder_index):
|
||||
"""Replace image placeholder with image widget and redraw"""
|
||||
index = self.get_status_index(status.id)
|
||||
assert self.statuses[index].id == status.id # Sanity check
|
||||
|
||||
# get the image and replace the placeholder with a graphics widget
|
||||
if hasattr(self, "images"):
|
||||
img = self.images.get(str(hash(path)))
|
||||
if img:
|
||||
try:
|
||||
render_img = add_corners(img, 10) if self.can_render_pixels else img
|
||||
status.placeholders[placeholder_index]._set_original_widget(
|
||||
UrwidImage(
|
||||
AutoImage(render_img),
|
||||
"<", upscale=True),
|
||||
) # "<" means left-justify the image
|
||||
except IndexError:
|
||||
# ignore IndexErrors.
|
||||
pass
|
||||
|
||||
def remove_status(self, status):
|
||||
index = self.get_status_index(status.id)
|
||||
assert self.statuses[index].id == status.id # Sanity check
|
||||
@ -311,25 +339,101 @@ class Timeline(urwid.Columns):
|
||||
class StatusDetails(urwid.Pile):
|
||||
def __init__(self, timeline: Timeline, status: Optional[Status]):
|
||||
self.status = status
|
||||
self.followed_tags = timeline.tui.followed_tags
|
||||
self.followed_accounts = timeline.tui.followed_accounts
|
||||
self.timeline = timeline
|
||||
if self.status:
|
||||
self.status.placeholders = []
|
||||
|
||||
reblogged_by = status.author if status and status.reblog else None
|
||||
widget_list = list(self.content_generator(status.original, reblogged_by)
|
||||
if status else ())
|
||||
return super().__init__(widget_list)
|
||||
|
||||
def image_widget(self, path, rows=None, aspect=None) -> urwid.Widget:
|
||||
"""Returns a widget capable of displaying the image
|
||||
|
||||
path is required; URL to image
|
||||
rows, if specfied, sets a fixed number of rows. Or:
|
||||
aspect, if specified, calculates rows based on pane width
|
||||
and the aspect ratio provided"""
|
||||
|
||||
if not rows:
|
||||
if not aspect:
|
||||
aspect = 3 / 2 # reasonable default
|
||||
|
||||
screen_rows = screen.get_cols_rows()[1]
|
||||
if self.timeline.can_render_pixels:
|
||||
# for pixel-rendered images,
|
||||
# image rows should be 33% of the available screen
|
||||
# but in no case fewer than 10
|
||||
rows = max(10, math.floor(screen_rows * .33))
|
||||
else:
|
||||
# for cell-rendered images,
|
||||
# use the max available columns
|
||||
# and calculate rows based on the image
|
||||
# aspect ratio
|
||||
cols = math.floor(0.55 * screen.get_cols_rows()[0])
|
||||
rows = math.ceil((cols / 2) / aspect)
|
||||
# if the calculated rows are more than will
|
||||
# fit on one screen, reduce to one screen of rows
|
||||
rows = min(screen_rows - 6, rows)
|
||||
|
||||
# but in no case fewer than 10 rows
|
||||
rows = max(rows, 10)
|
||||
|
||||
img = None
|
||||
if hasattr(self.timeline, "images"):
|
||||
img = self.timeline.images.get(str(hash(path)))
|
||||
if img:
|
||||
render_img = add_corners(img, 10) if self.timeline.can_render_pixels else img
|
||||
return (urwid.BoxAdapter(
|
||||
UrwidImage(
|
||||
AutoImage(render_img),
|
||||
"<", upscale=True),
|
||||
rows))
|
||||
else:
|
||||
placeholder = urwid.BoxAdapter(urwid.SolidFill(fill_char=" "), rows)
|
||||
self.status.placeholders.append(placeholder)
|
||||
self.timeline.tui.async_load_image(self.timeline, self.status, path, len(self.status.placeholders) - 1)
|
||||
return placeholder
|
||||
|
||||
def author_header(self, reblogged_by):
|
||||
avatar_url = self.status.original.data["account"]["avatar"]
|
||||
|
||||
if avatar_url:
|
||||
aimg = self.image_widget(avatar_url, 2)
|
||||
else:
|
||||
aimg = urwid.BoxAdapter(urwid.SolidFill(fill_char=" "), 2)
|
||||
|
||||
account_color = (
|
||||
"highlight"
|
||||
if self.status.original.author.account in self.timeline.tui.followed_accounts
|
||||
else "account"
|
||||
)
|
||||
|
||||
atxt = urwid.Pile([("pack",
|
||||
urwid.AttrMap(
|
||||
EmojiText(self.status.original.author.display_name,
|
||||
self.status.original.data["account"]["emojis"]),
|
||||
"account")),
|
||||
("pack", urwid.Text((account_color, self.status.original.author.account)))])
|
||||
|
||||
columns = urwid.Columns([aimg, ("weight", 9999, atxt)], dividechars=1, min_width=5)
|
||||
return columns
|
||||
|
||||
def content_generator(self, status, reblogged_by):
|
||||
if reblogged_by:
|
||||
text = "♺ {} boosted".format(reblogged_by.display_name or reblogged_by.username)
|
||||
yield ("pack", urwid.Text(("dim", text)))
|
||||
reblogger_name = (reblogged_by.display_name
|
||||
if reblogged_by.display_name
|
||||
else reblogged_by.username)
|
||||
text = f"♺ {reblogger_name} boosted"
|
||||
yield urwid.AttrMap(
|
||||
EmojiText(text, status.data["account"]["emojis"], make_gray=True),
|
||||
"dim"
|
||||
)
|
||||
yield ("pack", urwid.AttrMap(urwid.Divider("-"), "dim"))
|
||||
|
||||
if status.author.display_name:
|
||||
yield ("pack", urwid.Text(("status_detail_author", status.author.display_name)))
|
||||
yield self.author_header(reblogged_by)
|
||||
|
||||
account_color = "highlight" if status.author.account in self.followed_accounts else "dim"
|
||||
yield ("pack", urwid.Text((account_color, status.author.account)))
|
||||
yield ("pack", urwid.Divider())
|
||||
|
||||
if status.data["spoiler_text"]:
|
||||
@ -342,7 +446,7 @@ class StatusDetails(urwid.Pile):
|
||||
else:
|
||||
content = status.original.translation if status.original.show_translation else status.data["content"]
|
||||
for line in format_content(content):
|
||||
yield ("pack", urwid.Text(highlight_hashtags(line, self.followed_tags)))
|
||||
yield ("pack", urwid.Text(highlight_hashtags(line, self.timeline.tui.followed_tags)))
|
||||
|
||||
media = status.data["media_attachments"]
|
||||
if media:
|
||||
@ -351,6 +455,24 @@ class StatusDetails(urwid.Pile):
|
||||
yield ("pack", urwid.Text([("bold", "Media attachment"), " (", m["type"], ")"]))
|
||||
if m["description"]:
|
||||
yield ("pack", urwid.Text(m["description"]))
|
||||
if m["url"]:
|
||||
if m["url"].lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp')):
|
||||
yield urwid.Text("")
|
||||
try:
|
||||
aspect = float(m["meta"]["original"]["aspect"])
|
||||
except Exception:
|
||||
aspect = None
|
||||
yield self.image_widget(m["url"], aspect=aspect)
|
||||
yield urwid.Divider()
|
||||
# video media may include a preview URL, show that as a fallback
|
||||
elif m["preview_url"].lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp')):
|
||||
yield urwid.Text("")
|
||||
try:
|
||||
aspect = float(m["meta"]["small"]["aspect"])
|
||||
except Exception:
|
||||
aspect = None
|
||||
yield self.image_widget(m["preview_url"], aspect=aspect)
|
||||
yield urwid.Divider()
|
||||
yield ("pack", urwid.Text(("link", m["url"])))
|
||||
|
||||
poll = status.original.data.get("poll")
|
||||
@ -411,8 +533,18 @@ class StatusDetails(urwid.Pile):
|
||||
if card["description"]:
|
||||
yield urwid.Text(card["description"].strip())
|
||||
yield urwid.Text("")
|
||||
|
||||
yield urwid.Text(("link", card["url"]))
|
||||
|
||||
if card["image"]:
|
||||
if card["image"].lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp')):
|
||||
yield urwid.Text("")
|
||||
try:
|
||||
aspect = int(card["width"]) / int(card["height"])
|
||||
except Exception:
|
||||
aspect = None
|
||||
yield self.image_widget(card["image"], aspect=aspect)
|
||||
|
||||
def poll_generator(self, poll):
|
||||
for idx, option in enumerate(poll["options"]):
|
||||
perc = (round(100 * option["votes_count"] / poll["votes_count"])
|
||||
|
@ -3,11 +3,15 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import urwid
|
||||
import math
|
||||
|
||||
from functools import reduce
|
||||
from html.parser import HTMLParser
|
||||
from typing import List
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
from term_image.image import auto_image_class, GraphicsImage
|
||||
|
||||
HASHTAG_PATTERN = re.compile(r'(?<!\w)(#\w+)\b')
|
||||
|
||||
|
||||
@ -102,6 +106,42 @@ def parse_content_links(content):
|
||||
return parser.links[:]
|
||||
|
||||
|
||||
def resize_image(basewidth: int, baseheight: int, img: Image) -> Image:
|
||||
if baseheight and not basewidth:
|
||||
hpercent = baseheight / float(img.size[1])
|
||||
width = math.ceil(img.size[0] * hpercent)
|
||||
img = img.resize((width, baseheight), Image.Resampling.LANCZOS)
|
||||
elif basewidth and not baseheight:
|
||||
wpercent = (basewidth / float(img.size[0]))
|
||||
hsize = int((float(img.size[1]) * float(wpercent)))
|
||||
img = img.resize((basewidth, hsize), Image.Resampling.LANCZOS)
|
||||
else:
|
||||
img = img.resize((basewidth, baseheight), Image.Resampling.LANCZOS)
|
||||
|
||||
if img.mode != 'P':
|
||||
img = img.convert('RGB')
|
||||
return img
|
||||
|
||||
|
||||
def add_corners(img, rad):
|
||||
circle = Image.new('L', (rad * 2, rad * 2), 0)
|
||||
draw = ImageDraw.Draw(circle)
|
||||
draw.ellipse((0, 0, rad * 2, rad * 2), fill=255)
|
||||
alpha = Image.new('L', img.size, "white")
|
||||
w, h = img.size
|
||||
alpha.paste(circle.crop((0, 0, rad, rad)), (0, 0))
|
||||
alpha.paste(circle.crop((0, rad, rad, rad * 2)), (0, h - rad))
|
||||
alpha.paste(circle.crop((rad, 0, rad * 2, rad)), (w - rad, 0))
|
||||
alpha.paste(circle.crop((rad, rad, rad * 2, rad * 2)), (w - rad, h - rad))
|
||||
img.putalpha(alpha)
|
||||
return img
|
||||
|
||||
|
||||
def can_render_pixels():
|
||||
# subclasses of GraphicsImage render to pixels
|
||||
return issubclass(auto_image_class(), GraphicsImage)
|
||||
|
||||
|
||||
def copy_to_clipboard(screen: urwid.raw_display.Screen, text: str):
|
||||
""" copy text to clipboard using OSC 52
|
||||
This escape sequence is documented
|
||||
|
@ -1,4 +1,11 @@
|
||||
from typing import List
|
||||
import urwid
|
||||
import re
|
||||
import requests
|
||||
from PIL import Image, ImageOps
|
||||
from term_image.image import AutoImage
|
||||
from term_image.widget import UrwidImage
|
||||
from .utils import can_render_pixels
|
||||
from wcwidth import wcswidth
|
||||
|
||||
|
||||
@ -67,3 +74,88 @@ class RadioButton(urwid.AttrWrap):
|
||||
button = urwid.RadioButton(*args, **kwargs)
|
||||
padding = urwid.Padding(button, width=len(args[1]) + 4)
|
||||
return super().__init__(padding, "button", "button_focused")
|
||||
|
||||
|
||||
class EmojiText(urwid.Padding):
|
||||
"""Widget to render text with embedded custom emojis
|
||||
|
||||
Note, these are Mastodon custom server emojis
|
||||
which are indicated by :shortcode: in the text
|
||||
and rendered as images on supporting clients.
|
||||
|
||||
For clients that do not support pixel rendering,
|
||||
they are rendered as plain text :shortcode:
|
||||
|
||||
This widget was designed for use with displaynames
|
||||
but could be used with any string of text.
|
||||
However, due to the internal use of columns,
|
||||
this widget will not wrap multi-line text
|
||||
correctly.
|
||||
|
||||
Note, you can embed this widget in AttrWrap to style
|
||||
the text as desired.
|
||||
|
||||
Parameters:
|
||||
|
||||
text -- text string (with or without embedded shortcodes)
|
||||
emojis -- list of emojis with nested lists of associated
|
||||
shortcodes and URLs
|
||||
make_gray -- if True, convert emojis to grayscale
|
||||
"""
|
||||
image_cache = {}
|
||||
|
||||
def __init__(self, text: str, emojis: List, make_gray=False):
|
||||
columns = []
|
||||
|
||||
if not can_render_pixels():
|
||||
return self.plain(text, columns)
|
||||
|
||||
# build a regex to find all available shortcodes
|
||||
regex = '|'.join(f':{emoji["shortcode"]}:' for emoji in emojis)
|
||||
|
||||
if 0 == len(regex):
|
||||
# if no shortcodes, just output plain Text
|
||||
return self.plain(text, columns)
|
||||
|
||||
regex = f"({regex})"
|
||||
|
||||
for word in re.split(regex, text):
|
||||
if word.startswith(":") and word.endswith(":"):
|
||||
shortcode = word[1:-1]
|
||||
found = False
|
||||
for emoji in emojis:
|
||||
if emoji["shortcode"] == shortcode:
|
||||
try:
|
||||
img = EmojiText.image_cache.get(str(hash(emoji["url"])))
|
||||
if not img:
|
||||
# TODO: consider asynchronous loading in future
|
||||
img = Image.open(requests.get(emoji["url"], stream=True).raw)
|
||||
EmojiText.image_cache[str(hash(emoji["url"]))] = img
|
||||
|
||||
if make_gray:
|
||||
img = ImageOps.grayscale(img)
|
||||
|
||||
image_widget = urwid.BoxAdapter(UrwidImage(AutoImage(img), upscale=True), 1)
|
||||
|
||||
columns.append(image_widget)
|
||||
except Exception:
|
||||
columns.append(("pack", urwid.Text(word)))
|
||||
finally:
|
||||
found = True
|
||||
break
|
||||
if found is False:
|
||||
columns.append(("pack", urwid.Text(word)))
|
||||
else:
|
||||
columns.append(("pack", urwid.Text(word)))
|
||||
|
||||
columns.append(("weight", 9999, urwid.Text("")))
|
||||
|
||||
column_widget = urwid.Columns(columns, dividechars=0, min_width=2)
|
||||
super().__init__(column_widget)
|
||||
|
||||
def plain(self, text, columns):
|
||||
# if can't render pixels, just output plain Text
|
||||
columns.append(("pack", urwid.Text(text)))
|
||||
columns.append(("weight", 9999, urwid.Text("")))
|
||||
column_widget = urwid.Columns(columns, dividechars=1, min_width=2)
|
||||
super().__init__(column_widget)
|
||||
|
Loading…
Reference in New Issue
Block a user