Files
Das-OS/srcpkgs/das-shell/files/python/das_shell/canvas.py
T
dasandClaude Opus 5.5 a2bffc7fe9
build / build (push) Successful in 38m26s
Terminal: Das prompt, dprompt and dbanner (0.9.1)
- 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>
2026-10-21 09:17:10 +03:00

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