Implemented micron markup parser

This commit is contained in:
Mark Qvist 2021-07-06 09:23:37 +02:00
parent 4c226b7e00
commit 2f2fdd6a0c
5 changed files with 909 additions and 244 deletions

View File

@ -20,6 +20,7 @@ THEMES = {
# Style name # 16-color style # Monochrome style # 88, 256 and true-color style
('heading', 'light gray,underline', 'default', 'underline', 'g93,underline', 'default'),
('menubar', 'black', 'light gray', 'standout', '#111', '#bbb'),
('scrollbar', 'black', 'light gray', 'standout', '#444', 'default'),
('shortcutbar', 'black', 'light gray', 'standout', '#111', '#bbb'),
('body_text', 'white', 'default', 'default', '#ddd', 'default'),
('error_text', 'dark red', 'default', 'default', 'dark red', 'default'),
@ -91,9 +92,6 @@ class TextUI:
self.palette = THEMES[theme]["urwid_theme"]
for entry in nomadnet.ui.textui.MarkupParser.URWID_THEME:
self.palette.append(entry)
if self.app.config["textui"]["glyphs"] == "plain":
glyphset = "plain"
elif self.app.config["textui"]["glyphs"] == "unicoode":

View File

@ -2,7 +2,8 @@ import RNS
import urwid
import nomadnet
from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox, MODIFIER_KEY
from .MarkupParser import markup_to_attrmaps
from .MicronParser import markup_to_attrmaps
from .Scrollable import *
class GuideDisplayShortcuts():
def __init__(self, app):
@ -88,6 +89,7 @@ class TopicList(urwid.WidgetWrap):
GuideEntry(self.app, guide_display, "Introduction"),
GuideEntry(self.app, guide_display, "Conversations"),
GuideEntry(self.app, guide_display, "Markup"),
GuideEntry(self.app, guide_display, "First Run"),
GuideEntry(self.app, guide_display, "Licenses & Credits"),
]
@ -132,7 +134,8 @@ class GuideDisplay():
def set_content_widgets(self, new_content):
options = self.columns.options(width_type="weight", width_amount=1-GuideDisplay.list_width)
pile = urwid.Pile(new_content)
content = urwid.LineBox(urwid.Filler(pile, "top"))
#content = urwid.LineBox(urwid.Filler(pile, "top"))
content = urwid.LineBox(urwid.AttrMap(ScrollBar(Scrollable(pile), thumb_char="\u2503", trough_char=" "), "scrollbar"))
self.columns.contents[1] = (content, options)
@ -182,46 +185,240 @@ TOPIC_CONVERSATIONS = '''Conversations
Conversations in Nomad Network
'''
TOPIC_MARKUP = '''>Markup
Nomad Network supports a simple and functional markup language called micron. It has a lean markup structure that adds very little overhead, and is still readable as plain text, but offers basic formatting and text structuring, ideal for displaying in a terminal.
TOPIC_MARKUP = '''>Outputting Formatted Text
`cLorem ipsum dolor sit amet.
>>>>>>>>>>>>>>>
-\u223f
<
`c`!Hello!`! This is output from `*micron`*
Micron generates formatted text for your terminal
`a
>>Encoding
`F222`BdddAll uM source files are encoded as UTF-8, and clients supporting uM display should support UTF-8.
>>>>>>>>>>>>>>>
-\u223f
<
Nomad Network supports a simple and functional markup language called `*micron`*. If you are familiar with `*markdown`* or `*HTML`*, you will feel right at home writing pages with micron.
With micron you can easily create structured documents and pages with formatting, colors, glyphs and icons, ideal for display in terminals.
>>Recommendations and Requirements
While micron can output formatted text to even the most basic terminal, there's a few capabilities your terminal `*must`* support to display micron output correctly, and some that, while not strictly necessary, make the experience a lot better.
>>>Encoding
All micron sources are intepreted as UTF-8, and micron assumes it can output UTF-8 characters to the terminal. If your terminal does not support UTF-8, output will be faulty.
>>>Colors
Shading and coloring text and backgrounds is integral to micron output, and while micron will attempt to gracefully degrade output even to 1-bit terminals, you will get the best output with terminals supporting at least 256 colors. True-color support is recommended.
>>>Terminal Font
While any font any unicode capable font can be used with micron, it's highly recommended to use a `*"Nerd Font"`* (see https://www.nerdfonts.com/), which will add a lot of extra glyphs and icons to your output.
> A Few Demo Outputs
`F222`Bddd
`cWith micron, you can control layout and presentation
`a
``
`B33f
You can change background ...
``
`B393
`r... and foreground colors
`r`F320... and foreground colors`f
`a
`b
>>>Sections and `F900Headings`f
You can define an arbitrary number of sections and sub-sections, each with their own heading
If you want to make a break, horizontal dividers can be inserted. They can be plain, like the one below this text, or you can style them with unicode characters and glyphs, like the wavy divider in the beginning of this document.
-
Dividers inside section will adhere to section indents
`cText can be `_underlined`_, `!bold`! or `*italic`*.
You can also `_`*`!`B5d5`F222combine`f`b`_ `_`Ff00f`Ff80o`Ffd0r`F9f0m`F0f2a`F0fdt`F07ft`F43fi`F70fn`Fe0fg`` for some fabulous effects.
`a
>>>Sections and Headings
You can define an arbitrary number of sections and sub sections, each with their own named headings. Text inside sections will be automatically indented.
-
If you place a divider inside a section, it will adhere to the section indents.
>>>>>
If no heading text is defined, the section will appear as a sub-section without a header. This can be useful for creating indented blocks of text, like this one.
>Micron tags
Tags are used to format text with micron. Some tags can appear anywhere in text, and some must appear at the beginning of a line. If you need to write text that contains a sequence that would be interpreted as a tag, you can escape it with the character \\.
In the following sections, the different tags will be introduced. Any styling set within micron can be reset to the default style by using the special \\`\\` tag anywhere in the markup, which will immediately remove any formatting previously specified.
>>Alignment
To control text alignment use the tag \\`c to center text, \\`l to left-align, \\`r to right-align, and \\`a to return to the default alignment of the document. Alignment tags must appear at the beginning of a line. Here is an example:
`Faaa
`=
`cThis line will be centered.
So will this.
`aThe alignment has now been returned to default.
`rThis will be aligned to the right
``
`=
``
The above markup produces the following output:
`Faaa`B333
`cThis line will be centered.
So will this.
`aThe alignment has now been returned to default.
`rThis will be aligned to the right
``
>>Formatting
Text can be formatted as `!bold`! by using the \\`! tag, `_underline`_ by using the \\`_ tag and `*italic`* by using the \\`* tag.
Here's an example of formatting text:
`Faaa
`=
We shall soon see `!bold`! paragraphs of text decorated with `_underlines`_ and `*italics`*. Some even dare `!`*`_combine`` them!
`=
``
The above markup produces the following output:
`Faaa`B333
We shall soon see `!bold`! paragraphs of text decorated with `_underlines`_ and `*italics`*. Some even dare `!`*`_combine`!`*`_ them!
``
>>Sections
To create sections and subsections, use the > tag. This tag must be placed at the beginning of a line. To specify a sub-section of any level, use any number of > tags. If text is placed after a > tag, it will be used as a heading.
Here is an example of sections:
`Faaa
`=
>High Level Stuff
This is a section. It contains this text.
>>Another Level
This is a sub section.
>>>Going deeper
A sub sub section. We could continue, but you get the point.
>>>>
If no heading text is defined, the section will appear as a sub-section without a header.
Wait! It's worth noting that we can also create sections without headings. They look like this.
`=
``
<-
Horizontal dividers can be inserted
The above markup produces the following output:
Text can be `_underlined`_, `!bold`! or `*italic`*. You can also `_`*`!`B5d5`F222combine`f`b`_ `_`Ff00f`Ff80o`Ffd0r`F9f0m`F0f2a`F0fdt`F07ft`F43fi`F70fn`Fe0fg``!
`Faaa`B333
>High Level Stuff
This is a section. It contains this text.
>>Another Level
This is a sub section.
>>>Going deeper
A sub sub section. We could continue, but you get the point.
>>>>
Wait! It's worth noting that we can also create sections without headings. They look like this.
``
>Colors
Foreground colors can be specified with the \\`F tag, followed by three hexadecimal characters. To return to the default foreground color, use the \\`f tag. Background color is specified in the same way, but by using the \\`B and \\`b tags.
Here's a few examples:
`Faaa
`=
You can use `B5d5`F222 color `f`b `Ff00f`Ff80o`Ffd0r`F9f0m`F0f2a`F0fdt`F07ft`F43fi`F70fn`Fe0fg`f for some fabulous effects.
`=
``
The above markup produces the following output:
`Faaa`B333
You can use `B5d5`F222 color `f`B333 `Ff00f`Ff80o`Ffd0r`F9f0m`F0f2a`F0fdt`F07ft`F43fi`F70fn`Fe0fg`f for some fabulous effects.
``
>Literals
To display literal content, for example source-code, or blocks of text that should not be interpreted by micron, you can use literal blocks, specified by the \\`= tag. Below is the source code of this entire document, presented as a literal block.
-
`=
'''
TOPIC_MARKUP += TOPIC_MARKUP.replace("`=", "\\`=") + "[ micron source for document goes here, we don't want infinite recursion now, do we? ]\n\\`="
TOPIC_MARKUP += "\n`=\n\n>Closing Remarks\n\nIf you made it all the way here, you should be well equipped to write documents and pages using micron. Thank you for staying with me.\n\n`c\U0001F332\n"
TOPIC_FIRST_RUN = '''>First Time Information
Hi there. This first run message will only appear once. It contains a few pointers on getting started with Nomad Network, and getting the most out of the program. You're currently located in the guide section of the program. I'm sorry I had to drag you here by force, but it will only happen this one time, I promise. If you ever get lost, return here and peruse the list of topics you see on the left. I will do my best to fill it with answers to mostly anything about Nomad Network.
To get the most out of Nomad Network, you will need a terminal that supports UTF-8 and at least 256 colors, ideally true-color. By default, Nomad Network starts in low-color mode. It does this for the sake of compatibility, but it does look rather ugly. If your terminal supports true-color or just 256 colors, you should go to the `![ Config ]`! menu item, launch the editor and change the configuration to use a high-color mode.
If you don't already have a Nerd Font installed (see https://www.nerdfonts.com/), I also highly recommend to do so, since it will greatly expand the amount of glyphs, icons and graphics that Nomad Network can use.
Nomad Network expects that you are already connected to some form of Reticulum network. That could be as simple as the default UDP-based demo interface on your local ethernet network, or as advanced as some elaborate hybrid RF and free-space optical laser network. This short guide won't go into any details on that, but you will find other entries in the guide that deal with network setup and configuration.
At least, if Nomad Network launches, it means that it is connected to a running Reticulum instance, that should in turn be connected to `*something`*, which should get you started.
Now go out there and explore. This is still early days. See what you can find and create.
>>>>>>>>>>>>>>>
-\u223f
<
'''
TOPIC_LICENSES = '''>Thanks, Acknowledgements and Licenses
Lorem Ipsum
'''
TOPICS = {
"Introduction": TOPIC_INTRODUCTION,
"Conversations": TOPIC_CONVERSATIONS,
"Markup": TOPIC_MARKUP,
"First Run": TOPIC_FIRST_RUN,
"Licenses & Credits": TOPIC_LICENSES,
}

View File

@ -1,226 +0,0 @@
import nomadnet
import urwid
import re
URWID_THEME = [
# Style name 16-color style Monochrome style # 88, 256 and true-color style
('plain', 'light gray', 'default', 'default', '#ddd', 'default'),
('heading1', 'black', 'light gray', 'standout', '#222', '#bbb'),
('heading2', 'black', 'light gray', 'standout', '#111', '#999'),
('heading3', 'black', 'light gray', 'standout', '#000', '#777'),
('f_underline', 'default,underline', 'default', 'default,underline', 'default,underline', 'default'),
('f_bold', 'default,bold', 'default', 'default,bold', 'default,bold', 'default'),
('f_italic', 'default,italics', 'default', 'default,italics', 'default,italics', 'default'),
]
SYNTH_STYLES = []
SECTION_INDENT = 2
INDENT_RIGHT = 1
def markup_to_attrmaps(markup):
attrmaps = []
state = {
"depth": 0,
"fg_color": "default",
"bg_color": "default",
"formatting": {
"bold": False,
"underline": False,
"italic": False,
"strikethrough": False,
"blink": False,
},
"default_align": "left",
"align": "left",
}
# Split entire document into lines for
# processing.
lines = markup.split("\n");
for line in lines:
if len(line) > 0:
display_widget = parse_line(line, state)
else:
display_widget = urwid.Text("")
if display_widget != None:
attrmap = urwid.AttrMap(display_widget, make_style(state))
attrmaps.append(attrmap)
return attrmaps
def parse_line(line, state):
first_char = line[0]
# Check if the command is an escape
if first_char == "\\":
line = line[1:]
# Check for section heading reset
elif first_char == "<":
state["depth"] = 0
return parse_line(line[1:], state)
# Check for section headings
elif first_char == ">":
i = 0
while i < len(line) and line[i] == ">":
i += 1
state["depth"] = i
for j in range(1, i+1):
wanted_style = "heading"+str(i)
if any(s[0]==wanted_style for s in URWID_THEME):
style = wanted_style
line = line[state["depth"]:]
if len(line) > 0:
line = " "*left_indent(state)+line
return urwid.AttrMap(urwid.Text(line), style)
else:
return None
# Check for horizontal dividers
elif first_char == "-":
if len(line) == 2:
divider_char = line[1]
else:
divider_char = "\u2500"
if state["depth"] == 0:
return urwid.Divider(divider_char)
else:
return urwid.Padding(urwid.Divider(divider_char), left=left_indent(state), right=right_indent(state))
output = make_output(state, line)
if output != None:
if state["depth"] == 0:
return urwid.Text(output, align=state["align"])
else:
return urwid.Padding(urwid.Text(output, align=state["align"]), left=left_indent(state), right=right_indent(state))
else:
return None
def left_indent(state):
return (state["depth"]-1)*SECTION_INDENT
def right_indent(state):
return (state["depth"]-1)*SECTION_INDENT
def make_part(state, part):
return (make_style(state), part)
def make_style(state):
def mono_color(fg, bg):
return "default"
def low_color(color):
# TODO: Implement
return "default"
def high_color(color):
if color == "default":
return color
else:
return "#"+color
bold = state["formatting"]["bold"]
underline = state["formatting"]["underline"]
italic = state["formatting"]["italic"]
fg = state["fg_color"]
bg = state["bg_color"]
format_string = ""
if bold:
format_string += ",bold"
if underline:
format_string += ",underline"
if italic:
format_string += ",italics"
name = "micron_"+fg+"_"+bg+"_"+format_string
if not name in SYNTH_STYLES:
screen = nomadnet.NomadNetworkApp.get_shared_instance().ui.screen
screen.register_palette_entry(name, low_color(fg)+format_string,low_color(bg),mono_color(fg, bg)+format_string,high_color(fg)+format_string,high_color(bg))
SYNTH_STYLES.append(name)
return name
def make_output(state, line):
output = []
part = ""
mode = "text"
skip = 0
for i in range(0, len(line)):
c = line[i]
if skip > 0:
skip -= 1
else:
if mode == "formatting":
if c == "_":
state["formatting"]["underline"] ^= True
elif c == "!":
state["formatting"]["bold"] ^= True
elif c == "*":
state["formatting"]["italic"] ^= True
elif c == "F":
if len(line) > i+4:
color = line[i+1:i+4]
state["fg_color"] = color
skip = 3
elif c == "f":
state["fg_color"] = "default"
elif c == "B":
if len(line) >= i+4:
color = line[i+1:i+4]
state["bg_color"] = color
skip = 3
elif c == "b":
state["bg_color"] = "default"
elif c == "`":
state["formatting"]["bold"] = False
state["formatting"]["underline"] = False
state["formatting"]["italic"] = False
state["fg_color"] = "default"
state["bg_color"] = "default"
elif c == "c":
if state["align"] != "center":
state["align"] = "center"
else:
state["align"] = state["default_align"]
elif c == "l":
if state["align"] != "left":
state["align"] = "left"
else:
state["align"] = state["default_align"]
elif c == "r":
if state["align"] != "right":
state["align"] = "right"
else:
state["align"] = state["default_align"]
elif c == "a":
state["align"] = state["default_align"]
mode = "text"
if len(part) > 0:
output.append(make_part(state, part))
elif mode == "text":
if c == "`":
mode = "formatting"
if len(part) > 0:
output.append(make_part(state, part))
part = ""
else:
part += c
if i == len(line)-1:
if len(part) > 0:
output.append(make_part(state, part))
if len(output) > 0:
return output
else:
return None

View File

@ -0,0 +1,275 @@
import nomadnet
import urwid
import re
STYLES = {
"plain": { "fg": "bbb", "bg": "default", "bold": False, "underline": False, "italic": False },
"heading1": { "fg": "222", "bg": "bbb", "bold": False, "underline": False, "italic": False },
"heading2": { "fg": "111", "bg": "999", "bold": False, "underline": False, "italic": False },
"heading3": { "fg": "000", "bg": "777", "bold": False, "underline": False, "italic": False },
}
SYNTH_STYLES = []
SECTION_INDENT = 2
INDENT_RIGHT = 1
def markup_to_attrmaps(markup):
attrmaps = []
state = {
"literal": False,
"depth": 0,
"fg_color": "default",
"bg_color": "default",
"formatting": {
"bold": False,
"underline": False,
"italic": False,
"strikethrough": False,
"blink": False,
},
"default_align": "left",
"align": "left",
}
# Split entire document into lines for
# processing.
lines = markup.split("\n");
for line in lines:
if len(line) > 0:
display_widget = parse_line(line, state)
else:
display_widget = urwid.Text("")
if display_widget != None:
attrmap = urwid.AttrMap(display_widget, make_style(state))
attrmaps.append(attrmap)
return attrmaps
def parse_line(line, state):
if len(line) > 0:
first_char = line[0]
# Check for literals
if len(line) == 2 and line == "`=":
state["literal"] ^= True
return None
# Only parse content if not in literal state
if not state["literal"]:
# Check if the command is an escape
if first_char == "\\":
line = line[1:]
# Check for section heading reset
elif first_char == "<":
state["depth"] = 0
return parse_line(line[1:], state)
# Check for section headings
elif first_char == ">":
i = 0
while i < len(line) and line[i] == ">":
i += 1
state["depth"] = i
for j in range(1, i+1):
wanted_style = "heading"+str(i)
if wanted_style in STYLES:
style = STYLES[wanted_style]
line = line[state["depth"]:]
if len(line) > 0:
latched_style = state_to_style(state)
style_to_state(style, state)
heading_style = make_style(state)
output = make_output(state, line)
style_to_state(latched_style, state)
if len(output) > 0:
first_style = output[0][0]
heading_style = first_style
output.insert(0, " "*left_indent(state))
return urwid.AttrMap(urwid.Text(output, align=state["align"]), heading_style)
else:
return None
else:
return None
# Check for horizontal dividers
elif first_char == "-":
if len(line) == 2:
divider_char = line[1]
else:
divider_char = "\u2500"
if state["depth"] == 0:
return urwid.Divider(divider_char)
else:
return urwid.Padding(urwid.Divider(divider_char), left=left_indent(state), right=right_indent(state))
output = make_output(state, line)
if output != None:
if state["depth"] == 0:
return urwid.Text(output, align=state["align"])
else:
return urwid.Padding(urwid.Text(output, align=state["align"]), left=left_indent(state), right=right_indent(state))
else:
return None
else:
return None
def left_indent(state):
return (state["depth"]-1)*SECTION_INDENT
def right_indent(state):
return (state["depth"]-1)*SECTION_INDENT
def make_part(state, part):
return (make_style(state), part)
def state_to_style(state):
return { "fg": state["fg_color"], "bg": state["bg_color"], "bold": state["formatting"]["bold"], "underline": state["formatting"]["underline"], "italic": state["formatting"]["italic"] }
def style_to_state(style, state):
if style["fg"] != None:
state["fg_color"] = style["fg"]
if style["bg"] != None:
state["bg_color"] = style["bg"]
if style["bold"] != None:
state["formatting"]["bold"] = style["bold"]
if style["underline"] != None:
state["formatting"]["underline"] = style["underline"]
if style["italic"] != None:
state["formatting"]["italic"] = style["italic"]
def make_style(state):
def mono_color(fg, bg):
return "default"
def low_color(color):
# TODO: Implement
return "default"
def high_color(color):
if color == "default":
return color
else:
return "#"+color
bold = state["formatting"]["bold"]
underline = state["formatting"]["underline"]
italic = state["formatting"]["italic"]
fg = state["fg_color"]
bg = state["bg_color"]
format_string = ""
if bold:
format_string += ",bold"
if underline:
format_string += ",underline"
if italic:
format_string += ",italics"
name = "micron_"+fg+"_"+bg+"_"+format_string
if not name in SYNTH_STYLES:
screen = nomadnet.NomadNetworkApp.get_shared_instance().ui.screen
screen.register_palette_entry(name, low_color(fg)+format_string,low_color(bg),mono_color(fg, bg)+format_string,high_color(fg)+format_string,high_color(bg))
SYNTH_STYLES.append(name)
return name
def make_output(state, line):
output = []
if state["literal"]:
if line == "\\`=":
line = "`="
output.append(make_part(state, line))
else:
part = ""
mode = "text"
escape = False
skip = 0
for i in range(0, len(line)):
c = line[i]
if skip > 0:
skip -= 1
else:
if mode == "formatting":
if c == "_":
state["formatting"]["underline"] ^= True
elif c == "!":
state["formatting"]["bold"] ^= True
elif c == "*":
state["formatting"]["italic"] ^= True
elif c == "F":
if len(line) >= i+4:
color = line[i+1:i+4]
state["fg_color"] = color
skip = 3
elif c == "f":
state["fg_color"] = "default"
elif c == "B":
if len(line) >= i+4:
color = line[i+1:i+4]
state["bg_color"] = color
skip = 3
elif c == "b":
state["bg_color"] = "default"
elif c == "`":
state["formatting"]["bold"] = False
state["formatting"]["underline"] = False
state["formatting"]["italic"] = False
state["fg_color"] = "default"
state["bg_color"] = "default"
state["align"] = state["default_align"]
elif c == "c":
if state["align"] != "center":
state["align"] = "center"
else:
state["align"] = state["default_align"]
elif c == "l":
if state["align"] != "left":
state["align"] = "left"
else:
state["align"] = state["default_align"]
elif c == "r":
if state["align"] != "right":
state["align"] = "right"
else:
state["align"] = state["default_align"]
elif c == "a":
state["align"] = state["default_align"]
mode = "text"
if len(part) > 0:
output.append(make_part(state, part))
elif mode == "text":
if c == "\\":
escape = True
elif c == "`":
if escape:
part += c
escape = False
else:
mode = "formatting"
if len(part) > 0:
output.append(make_part(state, part))
part = ""
else:
part += c
if i == len(line)-1:
if len(part) > 0:
output.append(make_part(state, part))
if len(output) > 0:
return output
else:
return None

View File

@ -0,0 +1,421 @@
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details
# http://www.gnu.org/licenses/gpl-3.0.txt
import urwid
from urwid.widget import BOX, FIXED, FLOW
# Scroll actions
SCROLL_LINE_UP = 'line up'
SCROLL_LINE_DOWN = 'line down'
SCROLL_PAGE_UP = 'page up'
SCROLL_PAGE_DOWN = 'page down'
SCROLL_TO_TOP = 'to top'
SCROLL_TO_END = 'to end'
# Scrollbar positions
SCROLLBAR_LEFT = 'left'
SCROLLBAR_RIGHT = 'right'
class Scrollable(urwid.WidgetDecoration):
def sizing(self):
return frozenset([BOX,])
def selectable(self):
return True
def __init__(self, widget):
"""Box widget that makes a fixed or flow widget vertically scrollable
TODO: Focusable widgets are handled, including switching focus, but
possibly not intuitively, depending on the arrangement of widgets. When
switching focus to a widget that is ouside of the visible part of the
original widget, the canvas scrolls up/down to the focused widget. It
would be better to scroll until the next focusable widget is in sight
first. But for that to work we must somehow obtain a list of focusable
rows in the original canvas.
"""
if not any(s in widget.sizing() for s in (FIXED, FLOW)):
raise ValueError('Not a fixed or flow widget: %r' % widget)
self._trim_top = 0
self._scroll_action = None
self._forward_keypress = None
self._old_cursor_coords = None
self._rows_max_cached = 0
self.__super.__init__(widget)
def render(self, size, focus=False):
maxcol, maxrow = size
# Render complete original widget
ow = self._original_widget
ow_size = self._get_original_widget_size(size)
canv_full = ow.render(ow_size, focus)
# Make full canvas editable
canv = urwid.CompositeCanvas(canv_full)
canv_cols, canv_rows = canv.cols(), canv.rows()
if canv_cols <= maxcol:
pad_width = maxcol - canv_cols
if pad_width > 0:
# Canvas is narrower than available horizontal space
canv.pad_trim_left_right(0, pad_width)
if canv_rows <= maxrow:
fill_height = maxrow - canv_rows
if fill_height > 0:
# Canvas is lower than available vertical space
canv.pad_trim_top_bottom(0, fill_height)
if canv_cols <= maxcol and canv_rows <= maxrow:
# Canvas is small enough to fit without trimming
return canv
self._adjust_trim_top(canv, size)
# Trim canvas if necessary
trim_top = self._trim_top
trim_end = canv_rows - maxrow - trim_top
trim_right = canv_cols - maxcol
if trim_top > 0:
canv.trim(trim_top)
if trim_end > 0:
canv.trim_end(trim_end)
if trim_right > 0:
canv.pad_trim_left_right(0, -trim_right)
# Disable cursor display if cursor is outside of visible canvas parts
if canv.cursor is not None:
curscol, cursrow = canv.cursor
if cursrow >= maxrow or cursrow < 0:
canv.cursor = None
# Figure out whether we should forward keypresses to original widget
if canv.cursor is not None:
# Trimmed canvas contains the cursor, e.g. in an Edit widget
self._forward_keypress = True
else:
if canv_full.cursor is not None:
# Full canvas contains the cursor, but scrolled out of view
self._forward_keypress = False
else:
# Original widget does not have a cursor, but may be selectable
# FIXME: Using ow.selectable() is bad because the original
# widget may be selectable because it's a container widget with
# a key-grabbing widget that is scrolled out of view.
# ow.selectable() returns True anyway because it doesn't know
# how we trimmed our canvas.
#
# To fix this, we need to resolve ow.focus and somehow
# ask canv whether it contains bits of the focused widget. I
# can't see a way to do that.
if ow.selectable():
self._forward_keypress = True
else:
self._forward_keypress = False
return canv
def keypress(self, size, key):
# Maybe offer key to original widget
if self._forward_keypress:
ow = self._original_widget
ow_size = self._get_original_widget_size(size)
# Remember previous cursor position if possible
if hasattr(ow, 'get_cursor_coords'):
self._old_cursor_coords = ow.get_cursor_coords(ow_size)
key = ow.keypress(ow_size, key)
if key is None:
return None
# Handle up/down, page up/down, etc
command_map = self._command_map
if command_map[key] == urwid.CURSOR_UP:
self._scroll_action = SCROLL_LINE_UP
elif command_map[key] == urwid.CURSOR_DOWN:
self._scroll_action = SCROLL_LINE_DOWN
elif command_map[key] == urwid.CURSOR_PAGE_UP:
self._scroll_action = SCROLL_PAGE_UP
elif command_map[key] == urwid.CURSOR_PAGE_DOWN:
self._scroll_action = SCROLL_PAGE_DOWN
elif command_map[key] == urwid.CURSOR_MAX_LEFT: # 'home'
self._scroll_action = SCROLL_TO_TOP
elif command_map[key] == urwid.CURSOR_MAX_RIGHT: # 'end'
self._scroll_action = SCROLL_TO_END
else:
return key
self._invalidate()
def mouse_event(self, size, event, button, col, row, focus):
ow = self._original_widget
if hasattr(ow, 'mouse_event'):
ow_size = self._get_original_widget_size(size)
row += self._trim_top
return ow.mouse_event(ow_size, event, button, col, row, focus)
else:
return False
def _adjust_trim_top(self, canv, size):
"""Adjust self._trim_top according to self._scroll_action"""
action = self._scroll_action
self._scroll_action = None
maxcol, maxrow = size
trim_top = self._trim_top
canv_rows = canv.rows()
if trim_top < 0:
# Negative trim_top values use bottom of canvas as reference
trim_top = canv_rows - maxrow + trim_top + 1
if canv_rows <= maxrow:
self._trim_top = 0 # Reset scroll position
return
def ensure_bounds(new_trim_top):
return max(0, min(canv_rows - maxrow, new_trim_top))
if action == SCROLL_LINE_UP:
self._trim_top = ensure_bounds(trim_top - 1)
elif action == SCROLL_LINE_DOWN:
self._trim_top = ensure_bounds(trim_top + 1)
elif action == SCROLL_PAGE_UP:
self._trim_top = ensure_bounds(trim_top - maxrow + 1)
elif action == SCROLL_PAGE_DOWN:
self._trim_top = ensure_bounds(trim_top + maxrow - 1)
elif action == SCROLL_TO_TOP:
self._trim_top = 0
elif action == SCROLL_TO_END:
self._trim_top = canv_rows - maxrow
else:
self._trim_top = ensure_bounds(trim_top)
# If the cursor was moved by the most recent keypress, adjust trim_top
# so that the new cursor position is within the displayed canvas part.
# But don't do this if the cursor is at the top/bottom edge so we can still scroll out
if self._old_cursor_coords is not None and self._old_cursor_coords != canv.cursor:
self._old_cursor_coords = None
curscol, cursrow = canv.cursor
if cursrow < self._trim_top:
self._trim_top = cursrow
elif cursrow >= self._trim_top + maxrow:
self._trim_top = max(0, cursrow - maxrow + 1)
def _get_original_widget_size(self, size):
ow = self._original_widget
sizing = ow.sizing()
if FIXED in sizing:
return ()
elif FLOW in sizing:
return (size[0],)
def get_scrollpos(self, size=None, focus=False):
"""Current scrolling position
Lower limit is 0, upper limit is the maximum number of rows with the
given maxcol minus maxrow.
NOTE: The returned value may be too low or too high if the position has
changed but the widget wasn't rendered yet.
"""
return self._trim_top
def set_scrollpos(self, position):
"""Set scrolling position
If `position` is positive it is interpreted as lines from the top.
If `position` is negative it is interpreted as lines from the bottom.
Values that are too high or too low values are automatically adjusted
during rendering.
"""
self._trim_top = int(position)
self._invalidate()
def rows_max(self, size=None, focus=False):
"""Return the number of rows for `size`
If `size` is not given, the currently rendered number of rows is returned.
"""
if size is not None:
ow = self._original_widget
ow_size = self._get_original_widget_size(size)
sizing = ow.sizing()
if FIXED in sizing:
self._rows_max_cached = ow.pack(ow_size, focus)[1]
elif FLOW in sizing:
self._rows_max_cached = ow.rows(ow_size, focus)
else:
raise RuntimeError('Not a flow/box widget: %r' % self._original_widget)
return self._rows_max_cached
class ScrollBar(urwid.WidgetDecoration):
def sizing(self):
return frozenset((BOX,))
def selectable(self):
return True
def __init__(self, widget, thumb_char=u'\u2588', trough_char=' ',
side=SCROLLBAR_RIGHT, width=1):
"""Box widget that adds a scrollbar to `widget`
`widget` must be a box widget with the following methods:
- `get_scrollpos` takes the arguments `size` and `focus` and returns
the index of the first visible row.
- `set_scrollpos` (optional; needed for mouse click support) takes the
index of the first visible row.
- `rows_max` takes `size` and `focus` and returns the total number of
rows `widget` can render.
`thumb_char` is the character used for the scrollbar handle.
`trough_char` is used for the space above and below the handle.
`side` must be 'left' or 'right'.
`width` specifies the number of columns the scrollbar uses.
"""
if BOX not in widget.sizing():
raise ValueError('Not a box widget: %r' % widget)
self.__super.__init__(widget)
self._thumb_char = thumb_char
self._trough_char = trough_char
self.scrollbar_side = side
self.scrollbar_width = max(1, width)
self._original_widget_size = (0, 0)
def render(self, size, focus=False):
maxcol, maxrow = size
sb_width = self._scrollbar_width
ow_size = (max(0, maxcol - sb_width), maxrow)
sb_width = maxcol - ow_size[0]
ow = self._original_widget
ow_base = self.scrolling_base_widget
ow_rows_max = ow_base.rows_max(size, focus)
if ow_rows_max <= maxrow:
# Canvas fits without scrolling - no scrollbar needed
self._original_widget_size = size
return ow.render(size, focus)
ow_rows_max = ow_base.rows_max(ow_size, focus)
ow_canv = ow.render(ow_size, focus)
self._original_widget_size = ow_size
pos = ow_base.get_scrollpos(ow_size, focus)
posmax = ow_rows_max - maxrow
# Thumb shrinks/grows according to the ratio of
# <number of visible lines> / <number of total lines>
thumb_weight = min(1, maxrow / max(1, ow_rows_max))
thumb_height = max(1, round(thumb_weight * maxrow))
# Thumb may only touch top/bottom if the first/last row is visible
top_weight = float(pos) / max(1, posmax)
top_height = int((maxrow - thumb_height) * top_weight)
if top_height == 0 and top_weight > 0:
top_height = 1
# Bottom part is remaining space
bottom_height = maxrow - thumb_height - top_height
assert thumb_height + top_height + bottom_height == maxrow
# Create scrollbar canvas
# Creating SolidCanvases of correct height may result in "cviews do not
# fill gaps in shard_tail!" or "cviews overflow gaps in shard_tail!"
# exceptions. Stacking the same SolidCanvas is a workaround.
# https://github.com/urwid/urwid/issues/226#issuecomment-437176837
top = urwid.SolidCanvas(self._trough_char, sb_width, 1)
thumb = urwid.SolidCanvas(self._thumb_char, sb_width, 1)
bottom = urwid.SolidCanvas(self._trough_char, sb_width, 1)
sb_canv = urwid.CanvasCombine(
[(top, None, False)] * top_height +
[(thumb, None, False)] * thumb_height +
[(bottom, None, False)] * bottom_height,
)
combinelist = [(ow_canv, None, True, ow_size[0]),
(sb_canv, None, False, sb_width)]
if self._scrollbar_side != SCROLLBAR_LEFT:
return urwid.CanvasJoin(combinelist)
else:
return urwid.CanvasJoin(reversed(combinelist))
@property
def scrollbar_width(self):
"""Columns the scrollbar uses"""
return max(1, self._scrollbar_width)
@scrollbar_width.setter
def scrollbar_width(self, width):
self._scrollbar_width = max(1, int(width))
self._invalidate()
@property
def scrollbar_side(self):
"""Where to display the scrollbar; must be 'left' or 'right'"""
return self._scrollbar_side
@scrollbar_side.setter
def scrollbar_side(self, side):
if side not in (SCROLLBAR_LEFT, SCROLLBAR_RIGHT):
raise ValueError('scrollbar_side must be "left" or "right", not %r' % side)
self._scrollbar_side = side
self._invalidate()
@property
def scrolling_base_widget(self):
"""Nearest `original_widget` that is compatible with the scrolling API"""
def orig_iter(w):
while hasattr(w, 'original_widget'):
w = w.original_widget
yield w
yield w
def is_scrolling_widget(w):
return hasattr(w, 'get_scrollpos') and hasattr(w, 'rows_max')
for w in orig_iter(self):
if is_scrolling_widget(w):
return w
raise ValueError('Not compatible to be wrapped by ScrollBar: %r' % w)
def keypress(self, size, key):
return self._original_widget.keypress(self._original_widget_size, key)
def mouse_event(self, size, event, button, col, row, focus):
ow = self._original_widget
ow_size = self._original_widget_size
handled = False
if hasattr(ow, 'mouse_event'):
handled = ow.mouse_event(ow_size, event, button, col, row, focus)
if not handled and hasattr(ow, 'set_scrollpos'):
if button == 4: # scroll wheel up
pos = ow.get_scrollpos(ow_size)
ow.set_scrollpos(pos - 1)
return True
elif button == 5: # scroll wheel down
pos = ow.get_scrollpos(ow_size)
ow.set_scrollpos(pos + 1)
return True
return False