- 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>
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user