build / build (push) Successful in 38m26s
- das-shell 0.1.0: the Das two-line bash prompt by default (replaces the PS1 of Void's skel .bashrc once), dprompt (Textual prompt builder with presets and live preview) and dbanner (SSH login banner editor: canvas tools, fields with values, widgets, das-banner service for cached ones, Das OS logo and tryzub pictures). Python tests in make test. - das-server 0.3.0 depends on das-shell; the server installer mentions dprompt and dbanner; the desktop image includes it. - run-qemu.sh forwards the guest's SSH to localhost:2222. - Hardware fixes: Ventoy menu icon, boot menu titles "Das OS <version>". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
316 lines
12 KiB
Python
316 lines
12 KiB
Python
"""The drawing canvas of the dbanner editor: a Grid shown cell by cell, drawn with
|
|
the mouse or the keyboard."""
|
|
|
|
from rich.segment import Segment
|
|
from rich.style import Style
|
|
from textual import events
|
|
from textual.geometry import Size
|
|
from textual.message import Message
|
|
from textual.reactive import reactive
|
|
from textual.scroll_view import ScrollView
|
|
from textual.strip import Strip
|
|
|
|
from . import banner
|
|
|
|
TOOLS = ("pen", "eraser", "line", "box", "fill", "text", "bigtext", "field", "art", "select")
|
|
DRAG_TOOLS = ("line", "box", "fill", "select", "field")
|
|
|
|
|
|
def rich_cell_style(grid, key):
|
|
fg, bg, bold = grid.style(key)
|
|
return Style(color=fg, bgcolor=bg, bold=bold)
|
|
|
|
|
|
class Canvas(ScrollView, can_focus=True):
|
|
"""Shows a grid with its fields. before() is called right before every
|
|
change (for undo; a message would arrive after the change)."""
|
|
|
|
DEFAULT_CSS = """
|
|
Canvas { background: $background; }
|
|
Canvas:focus { border: none; }
|
|
"""
|
|
|
|
cursor = reactive((0, 0), always_update=True)
|
|
|
|
class Changed(Message):
|
|
pass
|
|
|
|
class Request(Message):
|
|
"""A tool needs a dialog: big text, field, picture."""
|
|
|
|
def __init__(self, tool, x, y, width=0, field_index=None):
|
|
super().__init__()
|
|
self.tool, self.x, self.y, self.width, self.field_index = tool, x, y, width, field_index
|
|
|
|
def __init__(self, grid, fields, before=None, **kw):
|
|
super().__init__(**kw)
|
|
self.grid, self.fields = grid, fields
|
|
self.before = before
|
|
self.tool = "pen"
|
|
self.char = "█"
|
|
self.fg, self.bg, self.bold = "cyan", None, False
|
|
self.box_kind = "single"
|
|
self.anchor = None # start of a drag or of a keyboard shape
|
|
self.last = None # last point of the pen
|
|
self.selection = None # (x0, y0, x1, y1)
|
|
self.clipboard = None
|
|
self.text_origin = None
|
|
|
|
# --- geometry ------------------------------------------------------------
|
|
def set_grid(self, grid, fields):
|
|
self.grid, self.fields = grid, fields
|
|
self.virtual_size = Size(grid.width + 1, grid.height + 1)
|
|
self.refresh()
|
|
|
|
def on_mount(self):
|
|
self.virtual_size = Size(self.grid.width + 1, self.grid.height + 1)
|
|
|
|
def key_now(self):
|
|
return self.grid.style_key(self.fg, self.bg, self.bold)
|
|
|
|
def _cell_at(self, event):
|
|
offset = event.get_content_offset(self)
|
|
if offset is None:
|
|
return None
|
|
x, y = offset.x + int(self.scroll_offset.x), offset.y + int(self.scroll_offset.y)
|
|
return (min(max(x, 0), self.grid.width - 1), min(max(y, 0), self.grid.height - 1))
|
|
|
|
# --- rendering --------------------------------------------------------------
|
|
def _shape_preview(self):
|
|
"""Cells the current drag would change: {(x, y): (char, key)}."""
|
|
if self.anchor is None or self.tool not in DRAG_TOOLS or self.tool in ("select", "field"):
|
|
return {}
|
|
g = banner.Grid(self.grid.width, self.grid.height, self.grid.styles)
|
|
x0, y0 = self.anchor
|
|
x1, y1 = self.cursor
|
|
key = self.key_now()
|
|
if self.tool == "line":
|
|
g.line(x0, y0, x1, y1, self.char, key)
|
|
elif self.tool == "box":
|
|
g.box(x0, y0, x1, y1, self.box_kind, key)
|
|
else:
|
|
g.fill(x0, y0, x1, y1, self.char, key)
|
|
return {(x, y): c for y, row in enumerate(g.cells) for x, c in enumerate(row) if c != [" ", " "]}
|
|
|
|
def _field_cells(self):
|
|
cells = {}
|
|
for n, f in enumerate(self.fields):
|
|
label = banner.fit(f.get("text", ""), int(f.get("width", 1)), f.get("align", "left"))
|
|
for i, ch in enumerate(label):
|
|
cells[(int(f["x"]) + i, int(f["y"]))] = (ch, f.get("style", " "), n)
|
|
return cells
|
|
|
|
def render_line(self, y):
|
|
sx, sy = self.scroll_offset
|
|
row = y + int(sy)
|
|
width = self.size.width
|
|
if row > self.grid.height:
|
|
return Strip.blank(width)
|
|
if row == self.grid.height:
|
|
# The bottom edge shows where the canvas ends
|
|
edge = "▔" * self.grid.width + "◢"
|
|
return Strip([Segment(edge[int(sx):], Style(color="grey35"))]).crop(0, width)
|
|
preview = self._shape_preview()
|
|
fields = self._field_cells()
|
|
sel = None
|
|
if self.selection:
|
|
x0, y0, x1, y1 = self.selection
|
|
sel = (min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1))
|
|
if self.tool in ("select", "field") and self.anchor is not None:
|
|
(ax, ay), (cx, cy) = self.anchor, self.cursor
|
|
if self.tool == "field":
|
|
cy = ay
|
|
sel = (min(ax, cx), min(ay, cy), max(ax, cx), max(ay, cy))
|
|
segs = []
|
|
for x in range(int(sx), self.grid.width):
|
|
ch, key = preview.get((x, row)) or self.grid.get(x, row)
|
|
style = rich_cell_style(self.grid, key)
|
|
if (x, row) in fields:
|
|
ch, fkey, _ = fields[(x, row)]
|
|
style = rich_cell_style(self.grid, fkey) + Style(underline=True, italic=True)
|
|
if sel and sel[0] <= x <= sel[2] and sel[1] <= row <= sel[3]:
|
|
style += Style(reverse=True)
|
|
if (x, row) == tuple(self.cursor) and self.has_focus:
|
|
style += Style(bgcolor="grey50" if ch == " " else None, reverse=ch != " ", blink=False)
|
|
segs.append(Segment(ch, style))
|
|
segs.append(Segment("▕", Style(color="grey35")))
|
|
return Strip(segs).crop(0, width)
|
|
|
|
def watch_cursor(self, old, new):
|
|
self.refresh()
|
|
self.post_message(self.Changed())
|
|
|
|
# --- editing helpers ------------------------------------------------------------
|
|
def _change(self):
|
|
if self.before:
|
|
self.before()
|
|
|
|
def _done(self):
|
|
self.refresh()
|
|
self.post_message(self.Changed())
|
|
|
|
def field_at(self, x, y):
|
|
for n, f in enumerate(self.fields):
|
|
if int(f["y"]) == y and int(f["x"]) <= x < int(f["x"]) + int(f.get("width", 1)):
|
|
return n
|
|
return None
|
|
|
|
def press(self, x, y):
|
|
"""The mouse button or Enter at a cell."""
|
|
tool = self.tool
|
|
if tool in ("pen", "eraser"):
|
|
self._change()
|
|
ch, key = (self.char, self.key_now()) if tool == "pen" else (" ", " ")
|
|
self.grid.put(x, y, ch, key)
|
|
self.last = (x, y)
|
|
self._done()
|
|
elif tool == "field" and self.field_at(x, y) is not None:
|
|
self.post_message(self.Request("field", x, y, field_index=self.field_at(x, y)))
|
|
elif tool in DRAG_TOOLS:
|
|
self.anchor = (x, y)
|
|
if tool == "select":
|
|
self.selection = None
|
|
elif tool == "text":
|
|
self.text_origin = (x, y)
|
|
elif tool in ("bigtext", "art"):
|
|
self.post_message(self.Request(tool, x, y))
|
|
self.cursor = (x, y)
|
|
|
|
def drag(self, x, y):
|
|
if self.tool in ("pen", "eraser") and self.last is not None:
|
|
ch, key = (self.char, self.key_now()) if self.tool == "pen" else (" ", " ")
|
|
self.grid.line(self.last[0], self.last[1], x, y, ch, key)
|
|
self.last = (x, y)
|
|
self.refresh()
|
|
self.cursor = (x, y)
|
|
|
|
def release(self, x, y):
|
|
"""Ends a drag: draws the shape."""
|
|
self.last = None
|
|
if self.anchor is None or self.tool not in DRAG_TOOLS:
|
|
if self.tool in ("pen", "eraser"):
|
|
self.post_message(self.Changed())
|
|
return
|
|
(x0, y0), self.anchor = self.anchor, None
|
|
key = self.key_now()
|
|
if self.tool == "select":
|
|
self.selection = (x0, y0, x, y)
|
|
elif self.tool == "field":
|
|
x0, x1 = sorted((x0, x))
|
|
self.post_message(self.Request("field", x0, y0, width=x1 - x0 + 1))
|
|
else:
|
|
self._change()
|
|
if self.tool == "line":
|
|
self.grid.line(x0, y0, x, y, self.char, key)
|
|
elif self.tool == "box":
|
|
self.grid.box(x0, y0, x, y, self.box_kind, key)
|
|
elif self.tool == "fill":
|
|
self.grid.fill(x0, y0, x, y, self.char, key)
|
|
self._done()
|
|
self.refresh()
|
|
|
|
# --- mouse --------------------------------------------------------------------------
|
|
def on_mouse_down(self, event: events.MouseDown):
|
|
cell = self._cell_at(event)
|
|
if cell and event.button == 1:
|
|
self.focus()
|
|
self.capture_mouse()
|
|
self.press(*cell)
|
|
|
|
def on_mouse_move(self, event: events.MouseMove):
|
|
if event.button == 1:
|
|
cell = self._cell_at(event)
|
|
if cell:
|
|
self.drag(*cell)
|
|
|
|
def on_mouse_up(self, event: events.MouseUp):
|
|
self.release_mouse()
|
|
cell = self._cell_at(event) or self.cursor
|
|
self.release(*cell)
|
|
|
|
# --- keyboard -----------------------------------------------------------------------
|
|
def on_key(self, event: events.Key):
|
|
x, y = self.cursor
|
|
moves = {"left": (-1, 0), "right": (1, 0), "up": (0, -1), "down": (0, 1)}
|
|
if event.key in moves:
|
|
dx, dy = moves[event.key]
|
|
nx = min(max(x + dx, 0), self.grid.width - 1)
|
|
ny = min(max(y + dy, 0), self.grid.height - 1)
|
|
self.cursor = (nx, ny)
|
|
if self.tool in ("pen", "eraser") and self.last is not None:
|
|
self.drag(nx, ny)
|
|
self.scroll_to_region_cell(nx, ny)
|
|
event.stop()
|
|
elif self.tool == "text" and self.text_origin is not None and event.key not in ("escape", "tab"):
|
|
self._type(event)
|
|
elif event.key in ("enter", "space"):
|
|
self._keyboard_press(x, y)
|
|
event.stop()
|
|
elif event.key in ("delete", "backspace") and self.selection:
|
|
self._change()
|
|
x0, y0, x1, y1 = self.selection
|
|
self.grid.fill(x0, y0, x1, y1, " ", " ")
|
|
self._done()
|
|
event.stop()
|
|
elif event.key == "escape":
|
|
self.anchor = self.last = self.selection = self.text_origin = None
|
|
self.refresh()
|
|
|
|
def _keyboard_press(self, x, y):
|
|
if self.tool in DRAG_TOOLS and self.anchor is not None:
|
|
self.release(x, y)
|
|
elif self.tool in ("pen", "eraser"):
|
|
if self.last is None:
|
|
self.press(x, y) # Enter starts drawing as the cursor moves
|
|
else:
|
|
self.last = None
|
|
self.post_message(self.Changed())
|
|
else:
|
|
self.press(x, y)
|
|
|
|
def _type(self, event):
|
|
x, y = self.cursor
|
|
if event.key == "enter":
|
|
ox, _ = self.text_origin
|
|
self.cursor = (ox, min(y + 1, self.grid.height - 1))
|
|
elif event.key == "backspace":
|
|
if x > 0:
|
|
self._change()
|
|
self.grid.put(x - 1, y, " ", " ")
|
|
self.cursor = (x - 1, y)
|
|
self._done()
|
|
elif event.character and event.is_printable:
|
|
self._change()
|
|
self.grid.put(x, y, event.character, self.key_now())
|
|
self.cursor = (min(x + 1, self.grid.width - 1), y)
|
|
self._done()
|
|
else:
|
|
return
|
|
event.stop()
|
|
|
|
def scroll_to_region_cell(self, x, y):
|
|
from textual.geometry import Region
|
|
self.scroll_to_region(Region(x, y, 1, 1), animate=False)
|
|
|
|
# --- clipboard ------------------------------------------------------------------------
|
|
def copy_selection(self, cut=False):
|
|
if not self.selection:
|
|
return False
|
|
x0, y0, x1, y1 = self.selection
|
|
self.clipboard = self.grid.region(x0, y0, x1, y1)
|
|
if cut:
|
|
self._change()
|
|
self.grid.fill(x0, y0, x1, y1, " ", " ")
|
|
self._done()
|
|
return True
|
|
|
|
def paste(self):
|
|
if self.clipboard is None:
|
|
return False
|
|
self._change()
|
|
x, y = self.cursor
|
|
self.grid.stamp(x, y, self.clipboard, transparent=False)
|
|
self.selection = (x, y, x + self.clipboard.width - 1, y + self.clipboard.height - 1)
|
|
self._done()
|
|
return True
|