"""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