diff --git a/README.rst b/README.rst index 03a34401..b9687c34 100644 --- a/README.rst +++ b/README.rst @@ -20,6 +20,27 @@ Try building a sample application:: make python-requirements make +If you get errors relating to tkinter or TkImage then additional system packages may be required. + +Debian/Ubuntu:: + + sudo apt install python3-tk + +Fedora:: + + sudo dnf install python3-tkinter python3-pillow-tk + +MacOS:: + + brew install python-tk + + +Graphic Editor +-------------- + +This library contains a basic layout editor which may be useful regardless of which graphics library is being used. +See :doc:`Tools/ged/README` for further details. + Virtual Screen -------------- diff --git a/Tools/ged/README.rst b/Tools/ged/README.rst new file mode 100644 index 00000000..314c857d --- /dev/null +++ b/Tools/ged/README.rst @@ -0,0 +1,173 @@ +Graphical Editor +================ + +This is a simple GUI layout tool to assist with placement and setting attributes, etc. for objects. + +It is still very much a work-in-progress but should still be of some assistance. + +See below for installation requirements. +The :sample:`Graphic_Editor` sample application demonstrates updating the screen in a running application. + +Project files have a ``.ged`` file extension and are in JSON format. +They should be easy to read and parse to suit any particular application. + +.. image:: graphic-editor.png + :height: 192px + +User Interface +-------------- + +Top menu buttons: + - **Clear** Reset everything ready to create a new layout + - **Add random** Add some random items to the screen (testing) + - **Load** Load project file + - **Save** Save project file + - **List** Dump project data to console + - **Send resources** Send resource block to :sample:`Graphic_Editor` application + - **Send layout** Send layout information and update :sample:`Graphic_Editor` screen + +On the right is context-sensitive information in pages. + +Project + General project information. + + - **Client** IP address for device running :sample:`Graphic_Editor` sample application + - **Width**, **Height**, **Orientation** target device attributes + - **Scale** reflects zoom level for editing area + - **Grid alignment** Set grid snapping in pixels + +Items + Lists all items in layout, synchronised with selection. + Item ordering can be adjusted using navigation shortcuts listed below. + +Font + Allows editing of font resources. + - To add a new font, type a new name then select the font **family**. + - To change font parameters select its name from the list and amend fields as required. + + Note that only system opentype/truetype fonts are selectable. + Other font types as discussed in :library:`Graphics` are not currently supported in the editor. + + Parameters: + - **name** Name of font resource as referred to in layout + - **family** Available system fonts to use + - **size** Which size to compile the font in + - **mono** Whether to use grayscale (looks better) or monochome (saves memory) + - **normal**, **bold**, **italic**, **bold-italic**. Which typefaces will to compile for the application. + +Image + Allows basic editing of image resources + + Parameters: + - **name** Name of image resource as referred to in layout + - **source** Path to source image file (e.g. png, jpeg, etc.) + - **format** Which format to convert this image to. + Ideally this matches the target device (RGB24, RGB565) to avoid processing overhead. + If left blank then the raw source data will be used; it's up to the application + do perform any decoding, etc. + - **width**, **height** Dimensions for converted image. Ignored if **format** is blank. + + +Properties + Shows details of selected item. + If multiple items are selected then shows only attributes common to all items. + For some fields (e.g. width) a drop-down list contains values for each item. + For other fields (e.g. type) shows possible values. + Note: Cannot currently change item type. + +Complex edits such as renaming font resources can always be done by manually editing the project file as it's a pretty simple format. +Make sure to back it up first, though! + + +Navigation +---------- + +========================== =========================================== +Action Key combination +========================== =========================================== +**Pan/Zoom** +Pan left/right Mouse Wheel +Pan up/down Shift + Wheel +Zoom Ctrl + Wheel + Right-Click + Wheel +Pan window Right-Click, hold and move mouse + +**Add new item of type** +Rectangle (outline) R +Filled Rectangle Shift + R +Ellipse (outline) E +Filled Ellipse Shift + E +Image I +Text T +Button B +Label L + +**Selection** +Select item Left-Click +Duplicate item(s) Ctrl+D +Add item to selection Ctrl + Left-Click +Remove item from selection Not implemented +Delete item(s) Delete +Copy items(s) Not implemented +Paste items(s) Not implemented +Undo last operation Not implemented +Select all items Ctrl + A +Move selected items Left-Click, Drag with mouse + Cursor keys + +Shift don't snap to grid +Adjust selection bounds Left-Click on selection handle, drag + +Shift don't snap to grid + +Ctrl scale items inside selection + +**Change Z-order** +Move to top Home +Move to bottom End +Move up Page-Up +Move down Page-Down +========================== =========================================== + + + +Design goals +------------ + +- Platform portability + - Uses python 'tkinter' library which is old but universally supported +- Simplicity + - quick and easy to use + - requires minimal display parameters (size, pixel format) + - minimal install dependencies, no compilation required +- Accurate + - Pixel layout corresponds to actual hardware, not just some idealised representation +- Easy to modify/extend, e.g. + - adding additional export formats + + +TODO +---- + + +Features to add: + +code generation + At present this is left up to the application. + For simple layouts it can be done manually. + A python or javascript can be used to parse the JSON project file. + + Preferably generate data blocks which can be imported into applications. + Some will be static, e.g. screen backgrounds, + May require code generation but if so keep to an absolute minimum. + Data blocks can incorporate logic (Graphics::Drawing). + +editing + - multiple selections + - cut & paste + - undo / redo + +Grouping / overlays / scenes + - e.g. common page background ('master page') + - concept of 'scene library' perhaps + +Resource script integration + - export/import to/from .rc files + - select fonts diff --git a/Tools/ged/ged.py b/Tools/ged/ged.py new file mode 100644 index 00000000..4994339e --- /dev/null +++ b/Tools/ged/ged.py @@ -0,0 +1,1496 @@ +from __future__ import annotations +import sys + +if not sys.version_info >= (3, 10): + raise RuntimeError('Requires python 3.11 or later') + +import os +import io +import binascii +import copy +import struct +from enum import Enum, IntEnum +import random +from random import randrange, randint +from dataclasses import dataclass +import json +import tkinter as tk +import tkinter.font +from tkinter import ttk, filedialog, colorchooser +from gtypes import colormap, ColorFormat +import resource +from item import * +from widgets import * +import remote + +TkVarType: str | int | float # python types used for tk.StringVar, tk.IntVar, tk.DoubleVar +CanvasPoint: tuple[int, int] # x, y on canvas +CanvasRect: tuple[int, int, int, int] # x0, y0, x1, y1 on canvas +DisplayPoint: tuple[int, int] # x, y on display +ItemList: list[GItem] + +class Tag: + """TK Canvas tags to identify class of item""" + HANDLE = '@@handle' + ITEM = '@@item' + BORDER = '@@border' + BACKGROUND = '@@background' + +# Event state modifier masks +EVS_SHIFT = 0x0001 +EVS_LOCK = 0x0002 +EVS_CONTROL = 0x0004 +EVS_MOD1 = 0x0008 # Linux: Left ALT; Windows: numlock +EVS_MOD2 = 0x0010 # Linux: Numlock +EVS_MOD3 = 0x0020 # Windows: scroll lock +EVS_MOD4 = 0x0040 +EVS_MOD5 = 0x0080 # Linux: Right ALT +EVS_BUTTON1 = 0x0100 +EVS_BUTTON2 = 0x0200 +EVS_BUTTON3 = 0x0400 +EVS_BUTTON4 = 0x0800 +EVS_BUTTON5 = 0x1000 + +# Windows-specific codes +EVS_WIN_LEFTALT = 0x20000 +EVS_WIN_RIGHTALT = 0x20000 | EVS_CONTROL + +# MAC codes not checked + + +# Display scaling +MIN_SCALE, MAX_SCALE = 1.0, 5.0 + +def align(boundary: int, *values): + def align_single(value): + if boundary <= 1: + return value + n = (value + boundary // 2) // boundary + return n * boundary + if len(values) == 1: + return align_single(values[0]) + return tuple(align_single(x) for x in values) + +# Top 4 bits identify hit class +ELEMENT_CLASS_MASK = 0xf0 +# Lower 4 bits for direction +ELEMENT_DIR_MASK = 0x0f +# Hit mask for handles at corners and midpoints of bounding rect +EC_HANDLE = 0x10 +# Hit mask for item elements +EC_ITEM = 0x20 + +# Directions +DIR_N = 0x01 +DIR_E = 0x02 +DIR_S = 0x04 +DIR_W = 0x08 +DIR_NE = DIR_N | DIR_E +DIR_SE = DIR_S | DIR_E +DIR_SW = DIR_S | DIR_W +DIR_NW = DIR_N | DIR_W + +class Element(IntEnum): + HANDLE_N = EC_HANDLE | DIR_N + HANDLE_NE = EC_HANDLE | DIR_NE + HANDLE_E = EC_HANDLE | DIR_E + HANDLE_SE = EC_HANDLE | DIR_SE + HANDLE_S = EC_HANDLE | DIR_S + HANDLE_SW = EC_HANDLE | DIR_SW + HANDLE_W = EC_HANDLE | DIR_W + HANDLE_NW = EC_HANDLE | DIR_NW + + ITEM = EC_ITEM | 0x01 + + def is_handle(self): + return (self & ELEMENT_CLASS_MASK) == EC_HANDLE + +# Globals +font_assets = None +image_assets = None + +class Canvas: + """Provides the interface for items to draw themselves""" + def __init__(self, layout, tags): + self.layout = layout + self.tags = tags + self.back_color = 0 + self.color = 'white' + self.line_width = 1 + self.scale = 1 + self.font = None + self.fontstyle = set() + self.halign = Align.Left + self.valign = Align.Top + + def draw_rect(self, rect): + layout = self.layout + x0, y0, x1, y1 = layout.tk_bounds(rect) + w = self.line_width * layout.scale + color = self.color.value_str(ColorFormat.tkinter) + layout.canvas.create_rectangle(x0, y0, x1-1, y1-1, outline=color, width=w, tags=self.tags) + + def fill_rect(self, rect): + layout = self.layout + x0, y0, x1, y1 = layout.tk_bounds(rect) + color = self.color.value_str(ColorFormat.tkinter) + layout.canvas.create_rectangle(x0, y0, x1, y1, fill=color, outline='', width=0, tags=self.tags) + + def draw_rounded_rect(self, rect, r): + x0, y0, x1, y1 = rect.x, rect.y, rect.x + rect.w, rect.y + rect.h + self.draw_corner(x0, y0, r, 90) + self.draw_corner(x1-r*2, y0, r, 0) + self.draw_corner(x1-r*2, y1-r*2, r, 270) + self.draw_corner(x0, y1-r*2, r, 180) + self.draw_line(x0+r, y0, x1-r, y0) + self.draw_line(x0+r, y1, x1-r, y1) + self.draw_line(x0, y0+r, x0, y1-r) + self.draw_line(x1, y0+r, x1, y1-r) + + def fill_rounded_rect(self, rect, r): + x0, y0, x1, y1 = rect.x, rect.y, rect.x + rect.w, rect.y + rect.h + self.fill_corner(x0, y0, r, 90) + self.fill_corner(x1-r*2, y0, r, 0) + self.fill_corner(x1-r*2, y1-r*2, r, 270) + self.fill_corner(x0, y1-r*2, r, 180) + self.fill_rect(Rect(x0+r, y0, rect.w - 2*r, rect.h)) + self.fill_rect(Rect(x0, y0+r, r, rect.h-2*r)) + self.fill_rect(Rect(x0+rect.w-r, y0+r, r, rect.h-2*r)) + + def draw_line(self, x0, y0, x1, y1): + layout = self.layout + x0, y0 = layout.tk_point(x0, y0) + x1, y1 = layout.tk_point(x1, y1) + w = self.line_width * layout.scale + color = self.color.value_str(ColorFormat.tkinter) + layout.canvas.create_line(x0, y0, x1, y1, fill=color, width=w, tags=self.tags) + + def draw_corner(self, x, y, r, start_angle): + layout = self.layout + x0, y0 = layout.tk_point(x, y) + x1, y1 = layout.tk_point(x+r*2, y+r*2) + w = self.line_width * layout.scale + color = self.color.value_str(ColorFormat.tkinter) + layout.canvas.create_arc(x0, y0, x1, y1, start=start_angle, extent=90, outline=color, width=w, style='arc', tags=self.tags) + + def fill_corner(self, x, y, r, start_angle): + layout = self.layout + x0, y0 = layout.tk_point(x, y) + x1, y1 = layout.tk_point(x+r*2, y+r*2) + color = self.color.value_str(ColorFormat.tkinter) + layout.canvas.create_arc(x0, y0, x1, y1, start=start_angle, extent=90, fill=color, outline='', tags=self.tags) + + def draw_ellipse(self, rect): + layout = self.layout + x0, y0, x1, y1 = layout.tk_bounds(rect) + w = self.line_width * layout.scale + color = self.color.value_str(ColorFormat.tkinter) + layout.canvas.create_oval(x0, y0, x1, y1, outline=color, width=w, tags=self.tags) + + def fill_ellipse(self, rect): + layout = self.layout + x0, y0, x1, y1 = layout.tk_bounds(rect) + color = self.color.value_str(ColorFormat.tkinter) + layout.canvas.create_oval(x0, y0, x1, y1, fill=color, outline='', width=0, tags=self.tags) + + def draw_text(self, rect, text): + layout = self.layout + font = font_assets.get(self.font, font_assets.default) + tk_image = font.draw_tk_image( + width=rect.w, + height=rect.h, + scale=layout.scale, + fontstyle=self.fontstyle, + fontscale=self.scale, + halign=self.halign, + valign=self.valign, + back_color=self.back_color, + color=self.color, + text=text) + x, y = layout.tk_point(rect.x, rect.y) + layout.canvas.create_image(x, y, image=tk_image, anchor=tk.NW, tags=self.tags) + return tk_image + + def draw_image(self, rect, image: str, offset: CanvasPoint): + """Important: Caller must retain copy of returned image otherwise it gets disposed""" + layout = self.layout + x0, y0, x1, y1 = layout.tk_bounds(rect) + crop_rect = Rect(*offset, rect.w, rect.h) + image_asset = image_assets.get(image, resource.Image()) + tk_image = image_asset.get_tk_image(crop_rect, layout.scale) + layout.canvas.create_image(x0, y0, image=tk_image, anchor=tk.NW, tags=self.tags) + return tk_image + + +def union(r1: Rect, r2: Rect): + x = min(r1.x, r2.x) + y = min(r1.y, r2.y) + w = max(r1.x + r1.w, r2.x + r2.w) - x + h = max(r1.y + r1.h, r2.y + r2.h) - y + return Rect(x, y, w, h) + + +def tk_inflate(bounds, xo, yo): + return (bounds[0]-xo, bounds[1]-yo, bounds[2]+xo, bounds[3]+yo) + + +def get_handle_pos(r: Rect, elem: Element): + return { + Element.HANDLE_N: (r.x + r.w // 2, r.y), + Element.HANDLE_NE: (r.x + r.w, r.y), + Element.HANDLE_E: (r.x + r.w, r.y + r.h // 2), + Element.HANDLE_SE: (r.x + r.w, r.y + r.h), + Element.HANDLE_S: (r.x + r.w // 2, r.y + r.h), + Element.HANDLE_SW: (r.x, r.y + r.h), + Element.HANDLE_W: (r.x, r.y + r.h // 2), + Element.HANDLE_NW: (r.x, r.y), + }.get(elem) + + +class State(Enum): + IDLE = 0 + DRAGGING = 1 + PANNING = 2 + SCALING = 3 + +class LayoutEditor(ttk.Frame): + def __init__(self, master, width: int = 320, height: int = 240, scale: float = 1.0, grid_alignment: int = 8): + super().__init__(master) + self.width = width + self.height = height + self.client = '192.168.13.10' + self.orientation = 0 # Not used internally + self.scale = scale + self.grid_alignment: int = grid_alignment + self.display_list = [] + self.sel_items = [] + self.on_sel_changed = None + self.on_scale_changed = None + self.state = State.IDLE + + c = self.canvas = tk.Canvas(self) + hs = ttk.Scrollbar(self, orient=tk.HORIZONTAL, command=c.xview) + hs.pack(side=tk.BOTTOM, fill=tk.X) + vs = ttk.Scrollbar(self, orient=tk.VERTICAL, command=c.yview) + vs.pack(side=tk.RIGHT, fill=tk.Y) + c.configure(xscrollcommand=hs.set, yscrollcommand=vs.set) + c.pack(side=tk.LEFT, expand=True, fill=tk.BOTH) + + def set_default_scroll_positions(): + c.xview_moveto(0.22) + c.yview_moveto(0.22) + c.after(100, set_default_scroll_positions) + + # Windows + c.bind('', self.canvas_mousewheel) + # Linux + c.bind('<4>', self.canvas_mousewheel) + c.bind('<5>', self.canvas_mousewheel) + + c.bind('<1>', self.canvas_select) + c.bind('', self.canvas_move) + c.bind('', self.canvas_drag) + c.bind('', self.canvas_end_move) + c.bind('<3>', self.canvas_pan_mark) + c.bind('', self.canvas_pan) + c.bind('', self.canvas_end_pan) + c.bind('', self.canvas_key) + c.bind('', self.canvas_select_all) + c.bind('', self.canvas_duplicate_selection) + + def load(self, data: dict): + for k, v in data.items(): + setattr(self, k, v) + self.size_changed() + + def asdict(self): + return dict( + width=self.width, + height=self.height, + scale=self.scale, + orientation=self.orientation, + grid_alignment=self.grid_alignment, + ) + + def set_size(self, width: int, height: int): + if width == self.width and height == self.height: + return + self.width = width + self.height = height + self.size_changed() + + def set_scale(self, scale: float, refpos: CanvasPoint = None): + if scale == self.scale or scale < MIN_SCALE or scale > MAX_SCALE: + return + if refpos is None: + # Use current displayed centre + x, y = self.canvas.winfo_width() // 2, self.canvas.winfo_height() // 2 + else: + x, y = refpos + # Adjust scrollbars so given point remains unchanged with new scale + s = scale / self.scale + x = round((self.canvas.canvasx(x) * s) - x) + y = round((self.canvas.canvasy(y) * s) - y) + self.scale = scale + self.size_changed() + _, _, w, h = self.tk_canvas_size() + self.canvas.xview_moveto(x / w) + self.canvas.yview_moveto(y / h) + if self.on_scale_changed: + self.on_scale_changed(self) + + def size_changed(self): + xo, yo, w, h = self.tk_canvas_size() + self.draw_offset = xo, yo + self.canvas.configure(scrollregion=(0, 0, w, h)) + self.redraw() + + def grid_align(self, *values): + return align(self.grid_alignment, *values) + + def tk_scale(self, *values): + if len(values) == 1: + return round(values[0] * self.scale) + return tuple(round(x * self.scale) for x in values) + + def tk_canvas_size(self): + """Return 4-tuple (x, y, w, h) where (x,y) is top-left of display area, (w,h) is total canvas size""" + w, h = 2 * self.width * self.scale, 2 * self.height * self.scale + x, y = w // 4, h // 4 + return x, y, w, h + + def tk_point(self, x: int, y: int): + xo, yo = self.draw_offset + x, y = self.tk_scale(x, y) + return (xo + x, yo + y) + + def canvas_point(self, x: int, y: int) -> DisplayPoint: + xo, yo = self.draw_offset + x1 = round((self.canvas.canvasx(x) - xo) / self.scale) + y1 = round((self.canvas.canvasy(y) - yo) / self.scale) + return x1, y1 + + def tk_bounds(self, rect: Rect) -> CanvasRect: + xo, yo = self.draw_offset + b = self.tk_scale(rect.x, rect.y, rect.x + rect.w, rect.y + rect.h) + return (xo + b[0], yo + b[1], xo + b[2], yo + b[3]) + + def clear(self): + self.display_list.clear() + self.sel_items.clear() + self.state = State.IDLE + self.redraw() + self.sel_changed(True) + + def add_items(self, items: ItemList): + self.display_list.extend(items) + for item in items: + self.draw_item(item) + + def add_item(self, item: GItem): + self.display_list.append(item) + self.draw_item(item) + + def draw_item(self, item: GItem): + tags = (Tag.ITEM, int(Element.ITEM), item.id) + c = Canvas(self, tags) + item.draw(c) + + def remove_item(self, item: GItem): + self.canvas.delete(item.id) + self.display_list.remove(item) + + def get_current(self) -> tuple[Element, GItem]: + tags = self.canvas.gettags(tk.CURRENT) + if len(tags) < 2 or tags[0] not in [Tag.HANDLE, Tag.ITEM]: + return None, None + elem = Element(int(tags[1])) + if tags[0] == Tag.HANDLE: + return elem, None + item = next(x for x in self.display_list if x.id == tags[2]) + return elem, item + + def draw_handles(self): + self.canvas.delete(Tag.HANDLE) + if not self.sel_items: + return + hr = None + tags = (Tag.HANDLE, str(Element.ITEM.value)) + for item in self.sel_items: + hr = union(hr, item) if hr else item.bounds + r = tk_inflate(self.tk_bounds(item), 1, 1) + self.canvas.create_rectangle(r, outline='white', width=1, tags=tags) + if len(self.sel_items) > 1: + r = tk_inflate(self.tk_bounds(hr), 1, 1) + self.canvas.create_rectangle(r, outline='white', width=1, tags=tags) + for e in Element: + if e == Element.ITEM: + continue + tags = (Tag.HANDLE, str(e.value)) + pt = get_handle_pos(hr, e) + r = self.tk_bounds(Rect(pt[0], pt[1])) + r = tk_inflate(r, 4, 4) + self.canvas.create_rectangle(r, outline='', fill='white', tags=tags) + if self.state == State.IDLE: + self.sel_bounds = hr + + def select(self, items: ItemList): + self.state = State.IDLE + self.sel_items = items + self.draw_handles() + self.sel_changed(True) + + def canvas_select(self, evt): + self.canvas.focus_set() + self.sel_pos = (evt.x, evt.y) + is_multi = (evt.state & EVS_CONTROL) != 0 + elem, item = self.get_current() + if not elem: + if not is_multi and self.sel_items: + self.sel_items = [] + self.draw_handles() + self.sel_changed(True) + self.canvas.scan_mark(evt.x, evt.y) + return + + self.sel_elem = elem + sel_changed = False + if item and not item in self.sel_items: + if is_multi: + self.sel_items.append(item) + else: + self.sel_items = [item] + sel_changed = True + self.draw_handles() + self.orig_bounds = [x.get_bounds() for x in self.sel_items] + if sel_changed: + self.sel_changed(True) + + def get_cursor(self, elem: Element): + if elem is None: + return '' + return { + Element.HANDLE_NW: 'top_left_corner', + Element.HANDLE_SE: 'bottom_right_corner', + Element.HANDLE_NE: 'top_right_corner', + Element.HANDLE_SW: 'bottom_left_corner', + Element.HANDLE_N: 'sb_v_double_arrow', + Element.HANDLE_S: 'sb_v_double_arrow', + Element.HANDLE_E: 'sb_h_double_arrow', + Element.HANDLE_W: 'sb_h_double_arrow', + Element.ITEM: 'target' if self.state == State.DRAGGING else 'hand1', + }.get(elem) + + def canvas_mousewheel(self, evt): + shift = evt.state & EVS_SHIFT + control = evt.state & EVS_CONTROL + if evt.state & EVS_BUTTON3: + if self.state != State.SCALING: + self.set_cursor('circle') + self.state = State.SCALING + control = True + if evt.num == 5: + delta = -1 + elif evt.num == 4: + delta = 1 + else: + delta = 1 if evt.delta > 0 else -1 + if control and not shift: + scale = round(self.scale + delta / 10, 2) + self.set_scale(scale, (evt.x, evt.y)) + elif shift and not control: + self.canvas.yview_scroll(delta, tk.UNITS) + elif not (control or shift): + self.canvas.xview_scroll(delta, tk.UNITS) + + def set_cursor(self, cursor: str): + self.canvas.configure(cursor=cursor) + + def canvas_move(self, evt): + if self.state != State.IDLE: + return + elem, item = self.get_current() + self.set_cursor(self.get_cursor(elem)) + + def canvas_pan_mark(self, evt): + self.canvas.scan_mark(evt.x, evt.y) + self.state = State.PANNING + self.set_cursor('sizing') + + def canvas_pan(self, evt): + if self.state == State.IDLE: + self.state = State.PANNING + self.set_cursor('sizing') + elif self.state != State.PANNING: + return + self.canvas.scan_dragto(evt.x, evt.y, gain=1) + + def canvas_end_pan(self, evt): + self.state = State.IDLE + self.set_cursor('') + + def canvas_drag(self, evt): + if not self.sel_items: + self.canvas_pan(evt) + return + def align(*values): + if evt.state & EVS_SHIFT: + return values[0] if len(values) == 1 else values + return self.grid_align(*values) + elem = self.sel_elem + if self.state != State.DRAGGING: + self.state = State.DRAGGING + self.set_cursor(self.get_cursor(elem)) + self.orig_bounds = [x.get_bounds() for x in self.sel_items] + off = round((evt.x - self.sel_pos[0]) / self.scale), round((evt.y - self.sel_pos[1]) / self.scale) + + def resize_item(item, r): + item.bounds = r + self.canvas.addtag_withtag('updating', item.id) + self.draw_item(item) + self.canvas.tag_raise(item.id, 'updating') + self.canvas.delete('updating') + + if elem == Element.ITEM: + x, y = self.sel_bounds.x, self.sel_bounds.y + off = align(x + off[0]) - x, align(y + off[1]) - y + for item, orig in zip(self.sel_items, self.orig_bounds): + r = item.get_bounds() + r.x, r.y = orig.x + off[0], orig.y + off[1] + resize_item(item, r) + elif len(self.sel_items) == 1: + item, orig = self.sel_items[0], self.orig_bounds[0] + r = item.get_bounds() + if elem & DIR_N: + r.y = align(orig.y + off[1]) + r.h = orig.y + orig.h - r.y + if elem & DIR_E: + r.w = align(orig.w + off[0]) + if elem & DIR_S: + r.h = align(orig.h + off[1]) + if elem & DIR_W: + r.x = align(orig.x + off[0]) + r.w = orig.x + orig.w - r.x + min_size = item.get_min_size() + if r.w >= min_size[0] and r.h >= min_size[1]: + resize_item(item, r) + else: + # Scale the bounding rectangle + orig = self.sel_bounds + r = copy.copy(orig) + if elem & DIR_N: + r.y += off[1] + r.h += orig.y - r.y + elif elem & DIR_S: + r.h += off[1] + if elem & DIR_E: + r.w += off[0] + elif elem & DIR_W: + r.x += off[0] + r.w += orig.x - r.x + cur_bounds = r + orig_bounds = orig + if evt.state & EVS_CONTROL: + if r.w > 0 and r.h > 0: + # Use scaled rectangle to resize items + for item, orig in zip(self.sel_items, self.orig_bounds): + r = Rect( + align(cur_bounds.x + (orig.x - orig_bounds.x) * cur_bounds.w // orig_bounds.w), + align(cur_bounds.y + (orig.y - orig_bounds.y) * cur_bounds.h // orig_bounds.h), + align(orig.w * cur_bounds.w // orig_bounds.w), + align(orig.h * cur_bounds.h // orig_bounds.h) ) + min_size = item.get_min_size() + if r.w >= min_size[0] and r.h >= min_size[1]: + resize_item(item, r) + else: + # Adjust item positions only within new bounding rectangle + for item, orig in zip(self.sel_items, self.orig_bounds): + r = copy.copy(orig) + if elem & (DIR_N | DIR_S): + r.y = align(cur_bounds.y + (orig.y - orig_bounds.y) * (cur_bounds.h - orig.h) // (orig_bounds.h - orig.h)) + if elem & (DIR_E | DIR_W): + r.x = align(cur_bounds.x + (orig.x - orig_bounds.x) * (cur_bounds.w - orig.w) // (orig_bounds.w - orig.w)) + resize_item(item, r) + + self.draw_handles() + self.sel_changed(False) + + + def sel_changed(self, full: bool): + if self.on_sel_changed: + self.on_sel_changed(full) + + def canvas_end_move(self, evt): + if self.state == State.PANNING: + self.state = State.IDLE + self.set_cursor('') + elif self.state == State.DRAGGING: + self.state = State.IDLE + self.set_cursor(self.get_cursor(self.sel_elem)) + + def redraw(self): + self.canvas.delete(tk.ALL) + r = self.tk_bounds(Rect(0, 0, self.width, self.height)) + self.canvas.create_rectangle(r, outline='', fill='black', tags=(Tag.BACKGROUND)) + for item in self.display_list: + self.draw_item(item) + self.canvas.create_rectangle(r[0]-1, r[1]-1, r[2]+1, r[3]+1, outline='dimgray', tags=(Tag.BORDER)) + self.draw_handles() + + def canvas_key(self, evt): + # print(evt) + def add_item(itemtype): + x, y = self.canvas_point(evt.x, evt.y) + x, y, w, h = *self.grid_align(x, y), *self.grid_align(50, 50) + item = GItem.create(itemtype, x=x, y=y, w=w, h=h) + item.assign_unique_id(self.display_list) + if hasattr(item, 'text'): + item.text = item.id.replace('_', ' ') + self.add_item(item) + self.select([item]) + + def delete_items(): + for item in self.sel_items: + self.display_list.remove(item) + self.canvas.delete(item.id) + self.select([]) + + def shift_items(xo, yo): + for item in self.sel_items: + item.x += xo + item.y += yo + self.redraw() + self.sel_changed(False) + + mod = evt.state & (EVS_CONTROL | EVS_SHIFT) + if mod & EVS_CONTROL: + return + c = evt.keysym.upper() if (mod & EVS_SHIFT) else evt.keysym.lower() + g = self.grid_alignment + opt = { + 'delete': (delete_items,), + 'left': (shift_items, -g, 0), + 'right': (shift_items, g, 0), + 'up': (shift_items, 0, -g), + 'down': (shift_items, 0, g), + 'LEFT': (shift_items, -1, 0), + 'RIGHT': (shift_items, 1, 0), + 'UP': (shift_items, 0, -1), + 'DOWN': (shift_items, 0, 1), + 'r': (add_item, 'Rect'), + 'R': (add_item, 'FilledRect'), + 'e': (add_item, 'Ellipse'), + 'E': (add_item, 'FilledEllipse'), + 'i': (add_item, 'Image'), + 't': (add_item, 'Text'), + 'b': (add_item, 'Button'), + 'l': (add_item, 'Label'), + 'home': (self.z_move, 'top'), + 'end': (self.z_move, 'bottom'), + 'prior': (self.z_move, 'raise'), + 'next': (self.z_move, 'lower'), + }.get(c) + if opt: + opt[0](*opt[1:]) + + def z_move(self, cmd: str): + """Move item with highest Z-order to top, stack others immediately below it""" + if not self.sel_items: + return + dl = self.display_list + z_list = self.sel_items + z_list.sort(reverse=cmd in ['bottom', 'lower'], key=lambda x: dl.index(x)) + # z = [(self.display_list.index(x), x) for x in items] + # z.sort(key=lambda e: e[0]) + i = { + 'top': len(dl), + 'bottom': 0, + 'raise': min(dl.index(z_list[-1]) + 1, len(dl) - 1), + 'lower': max(dl.index(z_list[-1]) - 1, 0), + }[cmd] + for item in z_list: + dl.remove(item) + dl.insert(i, item) + self.redraw() + self.sel_changed(False) + + + def canvas_select_all(self, evt): + self.sel_items = list(self.display_list) + self.redraw() + self.sel_changed(True) + + def canvas_duplicate_selection(self, evt): + items = [copy.copy(x) for x in self.sel_items] + off = self.grid_align(20) + id_list = set(x.id for x in self.display_list) + for item in items: + item.assign_unique_id(id_list) + id_list.add(item.id) + item.x += off + item.y += off + self.sel_items = items + self.add_items(items) + self.draw_handles() + self.sel_changed(True) + + +class Editor(ttk.LabelFrame): + def __init__(self, master, title: str): + super().__init__(master, text=title) + self.columnconfigure(1, weight=1) + self.is_updating = False + self.on_value_changed = None + self.fields = {} + + @property + def name(self): + return self.cget('text') + + def reset(self): + for w in self.winfo_children(): + w.destroy() + self.fields.clear() + + @staticmethod + def text_from_name(name: str) -> str: + words = re.findall(r'[A-Z]?[a-z]+|[A-Z]+(?=[A-Z]|$)', name) + return ' '.join(words).lower() + + def add_field(self, name: str, widget_type, **kwargs): + row = len(self.fields) + LabelWidget(self, self.text_from_name(name)).set_row(row) + w = widget_type(self, name, callback=self.value_changed, **kwargs).set_row(row) + self.fields[name] = w + return w + + def add_entry_field(self, name: str, vartype=int): + return self.add_field(name, EntryWidget, vartype=vartype) + + def add_spinbox_field(self, name: str, from_: int, to: int, increment: int = 1): + return self.add_field(name, SpinboxWidget, from_=from_, to=to, increment=increment) + + def add_text_field(self, name: str): + return self.add_field(name, TextWidget) + + def add_scale_field(self, name: str, vartype: type, from_: int | float, to: int | float, resolution: int | float = 1): + return self.add_field(name, ScaleWidget, vartype=vartype, from_=from_, to=to, resolution=resolution) + + def add_combo_field(self, name: str, values=[], vartype=str): + return self.add_field(name, ComboboxWidget, vartype=vartype, values=values) + + def add_check_field(self, name: str): + return self.add_field(name, CheckFieldWidget) + + def add_check_fields(self, name: str, is_set: bool, values: list): + return self.add_field(name, CheckFieldsWidget, is_set=is_set, values=values) + + def add_grouped_check_fields(self, name: str, groups: dict): + w = GroupedCheckFieldsWidget(self, name, groups, self.value_changed) + self.fields[name] = w + return w + + def value_changed(self, name: str, value: TkVarType): + if not self.is_updating and self.on_value_changed: + self.on_value_changed(name, value) + + def get_value(self, name: str) -> TkVarType: + return self.fields[name].get_value() + + def load_values(self, object): + self.is_updating = True + try: + for name, widget in self.fields.items(): + widget.set_value(getattr(object, name)) + finally: + self.is_updating = False + + +class ProjectEditor(Editor): + def __init__(self, root): + super().__init__(root, 'Project') + self.filename = None + self.add_entry_field('client', str) + self.add_entry_field('width') + self.add_entry_field('height') + self.add_check_fields('orientation', False, ['0:0°', '1:90°','2:180°', '3:270°']) + self.add_scale_field('scale', float, MIN_SCALE, MAX_SCALE, 0.1) + self.add_entry_field('grid_alignment') + + +class PropertyEditor(Editor): + def __init__(self, root): + super().__init__(root, 'Properties') + + def set_field(self, name=str, values=list, options=list, callback=None): + self.is_updating = True + try: + value_list = values + [o for o in options if o not in values] + if name in self.fields: + widget = self.fields[name] + widget.set_value(value=values[0] if len(values) == 1 else '') + widget.set_choices(value_list) + return + + if name == 'fontstyle': + widget = self.add_grouped_check_fields(name, + { + 'faceStyle:set': ('Bold', 'Italic'), + 'underscore': ('Single:Underscore', 'Double:DoubleUnderscore'), + 'overscore': ('Single:Overscore', 'Double:DoubleOverscore'), + 'strikeout': ('Single:Strikeout', 'Double:DoubleStrikeout'), + 'extra': ('DotMatrix', 'HLine', 'VLine') + }) + elif name == 'halign': + widget = self.add_check_fields(name, False, ('Left', 'Centre', 'Right')) + elif name == 'valign': + widget = self.add_check_fields(name, False, ('Top', 'Middle', 'Bottom')) + elif name == 'text': + widget = self.add_text_field(name) + elif name == 'fontscale': + widget = self.add_spinbox_field(name, 1, 16) + elif name in ['xoff', 'yoff']: + widget = self.add_spinbox_field(name, -100, 100) + elif name == 'radius': + widget = self.add_spinbox_field(name, 0, 255) + else: + widget = self.add_combo_field(name, value_list) + if callback: + def handle_event(evt, callback=callback, widget=widget): + if not self.is_updating: + callback(widget, evt.type) + widget.bind('', handle_event) + widget.bind('', handle_event) + widget.bind('', handle_event) + if len(values) == 1: + widget.set_value(values[0]) + finally: + self.is_updating = False + + +class ItemEditor(Editor): + def __init__(self, root): + super().__init__(root, 'Items') + self.on_select = None + def select_changed(): + if self.on_select: + self.on_select() + tree = self.tree = TreeviewWidget(self, select_changed) + tree.pack(expand=True, fill=tk.BOTH) + + def bind(self, *args): + self.tree.bind(*args) + + def update(self, items): + self.tree.set_choices(reversed(items)) + + def select(self, sel_items): + self.tree.select(sel_items) + + @property + def sel_items(self): + return self.tree.get_selection() + + +class FontEditor(Editor): + def __init__(self, root): + super().__init__(root, 'Font') + self.add_combo_field('name') + self.add_combo_field('family', font_assets.families()) + self.add_spinbox_field('size', 5, 100) + self.add_check_field('mono') + for x in resource.FaceStyle: + self.add_combo_field(x.name) + self.update() + + def value_changed(self, name: str, value: TkVarType): + if name == 'name': + font = font_assets.get(value) + if font: + self.select(font) + return + font_name = self.get_value('name') + # print(f'value_changed: "{name}", "{value}", "{font_name}"') + font = font_assets.get(font_name) + if font is None: + font = resource.Font(name=font_name) + font_assets.append(font) + curvalue = getattr(font, name) + if value == curvalue: + return + setattr(font, name, value) + if name == 'family': + sysfont = resource.system_fonts[font.family] + for fs in resource.FaceStyle: + style = '' + for x in sysfont.values(): + if fs == x.facestyle: + style = x.style + break + self.fields[fs.name].set_value(style) + self.update() + super().value_changed(name, value) + + def update(self): + font_name = self.get_value('name') + font_names = font_assets.names() + self.fields['name'].set_choices(font_names) + if not font_name and font_names: + font_name = font_names[0] + self.select(font_name) + + def select(self, font: str | resource.Font): + if font is None: + font = font_assets.default + elif isinstance(font, str): + font = font_assets.get(font, font_assets.default) + sysfont = resource.system_fonts.get(font.family, []) + choices = list(sysfont) + for x in resource.FaceStyle: + self.fields[x.name].set_choices(choices) + self.load_values(font) + + +class ImageEditor(Editor): + def __init__(self, root): + super().__init__(root, 'Image') + self.add_combo_field('name') + widget = self.add_combo_field('source') + widget.bind('', self.choose_source) + self.add_combo_field('format', values=['BMP', 'RGB24', 'RGB565']) + self.add_entry_field('width') + self.add_entry_field('height') + self.update() + + def choose_source(self, evt): + image_name = self.get_value('name').strip() + if not image_name: + return + IMAGE_FILTER = [('Image files', '*.*')] + filename = filedialog.askopenfilename( + title='Load project', + initialfile=self.get_value('source'), + filetypes=IMAGE_FILTER + ) + if len(filename) == 0: + return + filename = os.path.relpath(filename) + image = image_assets.get(image_name, None) + if image is None: + image = resource.Image(name=image_name, source=filename) + image_assets.append(image) + else: + image.source = filename + self.update() + + def value_changed(self, name: str, value: TkVarType): + if name == 'name': + image = image_assets.get(value) + if image: + self.select(image) + return + image_name = self.get_value('name') + # print(f'value_changed: "{name}", "{value}", "{font_name}"') + image = image_assets.get(image_name) + if image is None: + return + setattr(image, name, value) + self.update() + super().value_changed(name, value) + + def update(self): + image = self.get_value('name') + image_names = image_assets.names() + self.fields['name'].set_choices(image_names) + if not image and image_assets: + image = image_assets[0] + self.select(image) + + def select(self, image: str | resource.Image): + if isinstance(image, str): + image = image_assets.get(image) + if not image: + for widget in self.fields.values(): + widget.set_value('') + return + self.load_values(image) + + +def run(): + PROJECT_EXT = '.ged' + PROJECT_FILTER = [('GED Project', '*' + PROJECT_EXT)] + + def json_loads(s): + return json.loads(s)#, object_pairs_hook=OrderedDict) + + def json_load(filename): + with open(filename) as f: + return json_loads(f.read()) + + def json_save(data: dict, filename): + with open(filename, "w") as f: + json.dump(data, f, indent=4) + + def json_dumps(obj): + return json.dumps(obj, indent=4) + + def dl_serialise(display_list: ItemList) -> dict: + data = {} + for item in display_list: + d = { + 'type': item.typename, + } + for name, value in item.asdict().items(): + if name == 'id': + continue + if type(value) in [int, float, str, list]: + d[name] = value + elif isinstance(value, set): + d[name] = list(value) + else: + d[name] = str(value) + data[item.id] = d + return data + + def dl_deserialise(data: dict) -> ItemList: + display_list = [] + for id, d in data.items(): + item = GItem.create(d.pop('type'), id=id) + for a, v in d.items(): + setattr(item, a, v) + display_list.append(item) + return display_list + + def get_project_data() -> dict: + return { + 'project': layout.asdict(), + 'fonts': font_assets.asdict(), + 'images': image_assets.asdict(), + 'layout': dl_serialise(layout.display_list), + } + + def load_project(data: dict): + fileClear() + layout.load(data['project']) + for k, v in data['project'].items(): + project_editor.fields[k].set_value(v) + font_assets.load(data.get('fonts', {})) + font_editor.update() + image_assets.load(data.get('images', {})) + image_editor.update() + layout.display_list = [] + display_list = dl_deserialise(data['layout']) + layout.clear() + layout.add_items(display_list) + item_editor.update(layout.display_list) + + # Menus + def fileClear(): + layout.clear() + font_assets.clear() + font_editor.update() + image_assets.clear() + image_editor.update() + project_editor.filename = None + + def fileAddRandom(count=10): + display_list = [] + id_list = set(x.id for x in layout.display_list) + for i in range(count): + w_min, w_max = 10, 200 + h_min, h_max = 10, 100 + w = randint(w_min, w_max) + h = randint(h_min, h_max) + x = randrange(layout.width - w) + y = randrange(layout.height - h) + x, y, w, h = layout.grid_align(x, y, w, h) + itemtype = random.choice([ + GRect, + GFilledRect, + GEllipse, + GFilledEllipse, + GText, + GButton, + ]) + item = itemtype(x=x, y=y, w=w, h=h, color = random.choice(list(colormap))) + item.assign_unique_id(id_list) + id_list.add(item.id) + if hasattr(item, 'line_width'): + item.line_width = randint(1, 5) + if hasattr(item, 'radius'): + item.radius = randint(0, min(w, h) // 2) + if hasattr(item, 'text'): + item.text = item.id.replace('_', ' ') + display_list.append(item) + layout.add_items(display_list) + layout.sel_changed(False) + + + def fileLoad(): + filename = filedialog.askopenfilename( + title='Load project', + initialfile=project_editor.filename, + filetypes=PROJECT_FILTER + ) + if len(filename) != 0: + os.chdir(os.path.dirname(filename)) + data = json_load(filename) + load_project(data) + project_editor.filename = filename + + def fileSave(): + filename = filedialog.asksaveasfilename( + title='Save project', + initialfile=project_editor.filename, + filetypes=PROJECT_FILTER) + if len(filename) != 0: + ext = os.path.splitext(filename)[1] + if ext != PROJECT_EXT: + filename += PROJECT_EXT + data = get_project_data() + json_save(data, filename) + project_editor.filename = filename + + def fileList(): + data = get_project_data() + print(json_dumps(data)) + # layout.canvas.delete(Tag.HANDLE, Tag.BORDER) + # layout.canvas.update() + # x, y = layout.draw_offset + # w, h = layout.width * layout.scale, layout.height * layout.scale + # B = 8 + # layout.canvas.postscript(file='test.ps', x=x-B, y=y-B, width=w+B*2, height=h+B*2) + + def fileSendResources(): + from resource import rclib, FaceStyle + + images = {} + for img in image_assets: + d = dict( + source = img.source, + transform = dict( + resize=f'{img.width},{img.height}' + ), + ) + if img.format: + d['format'] = img.format + images[img.name] = d + + fonts = {} + for font in font_assets: + d = dict( + mono = font.mono, + size = font.size, + ) + sysfont = resource.system_fonts[font.family] + for fs in FaceStyle: + style = getattr(font, fs.name) + if style: + d[fs.name] = os.path.basename(sysfont[style].filename) + fonts[font.name] = d + + resources = dict( + image = images, + font = fonts, + ) + + print(json_dumps(resources)) + + def build_resource_index(data, res_offset, ptr64: bool): + """ + Resource map is FSTR_VECTOR so we can refer to resources by name + Alternative is using indices, but names are easier for debugging + Can place name strings after everything else + + uint32_t length (element count) + FlashString* name1 + void* resource + .... + + Size of this map is fixed at 4 + (length * 8) bytes. + + name1, etc. stored similarly: + + uint32_t length (characters) + char text[] + + Remember to align to nearest uint32_t + + """ + def align_up(data): + aligned_length = 4 * ((len(data) + 3) // 4) + return data.ljust(aligned_length, b'\0') + + PAIR_SIZE = 16 if ptr64 else 8 + map_struct_size = 4 + len(data) * PAIR_SIZE + res_offset += map_struct_size + res_offsets = [] + resdata = b'' + bmOffset = 0 + for item in data: + res_offsets.append(res_offset) + itemdata = align_up(item.serialize(bmOffset, res_offset, ptr64)) + res_offset += len(itemdata) + resdata += itemdata + bmOffset += item.get_bitmap_size() + # print(f'Font {item.name} has no typefaces! Skipping.') + resmap = struct.pack(' tuple[int, bool]: + client.send_line(f'@:resaddr;size={size};') + rsp = client.recv_line() + print("RESADDR:", rsp) + if rsp.startswith('@:'): + rsp = rsp[2:] + res_offset = 0 + ptr64 = False + while rsp: + tag, _, rsp = rsp.partition('=') + value, _, rsp = rsp.partition(';') + if tag == 'ptr64': + ptr64 = True + elif tag == 'addr': + res_offset = int(value, 0) + if res_offset == 0 and size != 0: + raise ValueError('Resource index too big!') + return res_offset, ptr64 + raise ValueError('BAD RESPONSE') + + print("Connecting ...") + client = remote.Client(layout.client) + + res_addr, ptr64 = get_resaddr(0) + + print('Building resource data ...') + data = rclib.parse(resources) + index = build_resource_index(data, 0, ptr64) + res_addr, _ = get_resaddr(len(index)) + index = build_resource_index(data, res_addr, ptr64) + + def send_resource(kind, data): + client.send_line(f'@:{kind}') + buf = io.BytesIO(data) if isinstance(data, bytes) else data + buf.seek(0) + while blk := buf.read(64): + client.send_line(b'b:;' + binascii.b2a_base64(blk, newline=False)) + client.send_line('@:end;') + print('RESPONSE:', client.recv_line()) + + print('Sending resource index ...') + send_resource('index', index) + + print('Sending resource bitmap ...') + buf = io.BytesIO() + rclib.writeBitmap(data, buf) + send_resource('bitmap', buf) + + print('Resources uploaded.') + + + def fileSendLayout(): + client = remote.Client(layout.client) + data = remote.serialise(layout.display_list) + client.send_line(f'@:size;w={layout.width};h={layout.height};orient={layout.orientation};') + client.send_line(f'@:clear') + for line in data: + client.send_line(line) + client.send_line('@:render;') + + + root = tk.Tk(className='GED') + root.geometry('1000x600') + root.title('Graphical Layout Editor') + + pane = ttk.PanedWindow(root, orient=tk.HORIZONTAL) + pane.pack(side=tk.TOP, expand=True, fill=tk.BOTH) + + # Layout editor + layout = LayoutEditor(pane) + layout.set_scale(2) + layout.pack(side=tk.LEFT, expand=True, fill=tk.BOTH) + pane.add(layout, weight=2) + + # Editors to right + edit_frame = ttk.PanedWindow(pane, orient=tk.VERTICAL, width=320) + pane.add(edit_frame) + + # Toolbar + toolbar = ttk.Frame(root) + toolbar.pack(side=tk.TOP, before=pane, fill=tk.X) + def addButton(text, command): + btn = ttk.Button(toolbar, text=text, command=command) + btn.pack(side=tk.LEFT) + addButton('Clear', fileClear) + addButton('Add Random', fileAddRandom) + addButton('Load...', fileLoad) + addButton('Save...', fileSave) + sep = ttk.Separator(toolbar, orient=tk.VERTICAL) + sep.pack(side=tk.LEFT) + addButton('List', fileList) + addButton('Send resources', fileSendResources) + addButton('Send layout', fileSendLayout) + + # Status bar + status = tk.StringVar() + label = ttk.Label(root, textvariable=status) + label.pack() + + res_frame = ttk.Notebook(edit_frame) + edit_frame.add(res_frame, weight=1) + def add_editor(editor_class): + editor = editor_class(res_frame) + res_frame.add(editor, text=editor.name, sticky=tk.NSEW) + return editor + + # Project + project_editor = add_editor(ProjectEditor) + project_editor.load_values(layout) + def project_value_changed(name, value): + if name == 'width': + layout.set_size(value, layout.height) + elif name == 'height': + layout.set_size(layout.width, value) + elif name == 'orientation': + layout.orientation = int(value) + elif name == 'scale': + layout.set_scale(value) + else: + setattr(layout, name, value) + project_editor.on_value_changed = project_value_changed + def scale_changed(layout): + project_editor.fields['scale'].set_value(layout.scale) + layout.on_scale_changed = scale_changed + + # Properties + prop = PropertyEditor(edit_frame) + edit_frame.add(prop, weight=2) + + def value_changed(name: str, value: TkVarType): + if name == 'type': + # Must defer this as caller holds reference to var which messes things up when destroying fields + def change_type(): + new_sel_items = [] + for item in layout.sel_items: + new_item = item.copy_as(value) + i = layout.display_list.index(item) + layout.display_list[i] = new_item + new_sel_items.append(new_item) + layout.select(new_sel_items) + root.after(100, change_type) + return + + for item in layout.sel_items: + if not hasattr(item, name): + continue + try: + setattr(item, name, value) + if name == 'font': + font_editor.select(value) + elif name == 'image': + image_editor.select(value) + except ValueError as e: + status.set(str(e)) + layout.redraw() + prop.on_value_changed = value_changed + + # Selection handling + def sel_changed(full_change: bool): + if full_change: + prop.reset() + items = layout.sel_items + item_editor.update(layout.display_list) + item_editor.select(layout.sel_items) + if not items: + return + if full_change: + typenames = set(x.typename for x in items) + prop.set_field('type', list(typenames), TYPENAMES) + fields = {} + for item in items: + for name, value in item.asdict().items(): + if isinstance(value, list): + values = fields.setdefault(name, []) + if value: + values.append(value) + else: + values = fields.setdefault(name, set()) + if str(value): + values.add(value) + # ID must be unique for each item, don't allow group set + if len(items) > 1: + del fields['id'] + for name, values in fields.items(): + callback = None + values = list(values) + if name == 'font': + options = font_assets.names() + def select_font(widget, event_type): + if event_type == tk.EventType.FocusIn: + widget.set_choices(font_assets.names()) + res_frame.select(font_editor) + callback = select_font + elif name == 'image': + options = image_assets.names() + def select_image(widget, event_type): + if event_type == tk.EventType.FocusIn: + widget.set_choices(image_assets.names()) + res_frame.select(image_editor) + callback = select_image + elif values and isinstance(values[0], Color): + options = sorted(colormap.keys()) + def select_color(widget, event_type): + if event_type == tk.EventType.ButtonPress: + res = colorchooser.askcolor(color=widget.get_value()) + if res[1] is not None: + widget.set_value(Color(res[1])) + callback = select_color + # elif name == 'fontstyle': + # pass + else: + options = [] + prop.set_field(name, values, options, callback) + + layout.on_sel_changed = sel_changed + + # Items + item_editor = add_editor(ItemEditor) + def item_select(): + layout.select(item_editor.sel_items) + item_editor.on_select = item_select + item_editor.bind('', lambda _: layout.z_move('top')) + item_editor.bind('', lambda _: layout.z_move('bottom')) + item_editor.bind('', lambda _: layout.z_move('raise')) + item_editor.bind('', lambda _: layout.z_move('lower')) + + # Fonts + global font_assets + font_assets = resource.FontList() + font_editor = add_editor(FontEditor) + def font_value_changed(name: str, value: TkVarType): + # print(f'font_value_changed("{name}", "{value}")') + layout.redraw() + font_editor.on_value_changed = font_value_changed + + # Images + global image_assets + image_assets = resource.ImageList() + image_editor = add_editor(ImageEditor) + def image_value_changed(name: str, value: TkVarType): + # print(f'image_value_changed("{name}", "{value}")') + layout.redraw() + image_editor.on_value_changed = image_value_changed + + # + tk.mainloop() + + +if __name__ == "__main__": + run() diff --git a/Tools/ged/graphic-editor.png b/Tools/ged/graphic-editor.png new file mode 100644 index 00000000..a96930e0 Binary files /dev/null and b/Tools/ged/graphic-editor.png differ diff --git a/Tools/ged/gtypes.py b/Tools/ged/gtypes.py new file mode 100644 index 00000000..1cc374f9 --- /dev/null +++ b/Tools/ged/gtypes.py @@ -0,0 +1,109 @@ +import copy +import dataclasses +from enum import Enum +from dataclasses import dataclass +from PIL.ImageColor import colormap +rev_colormap = {value: name for name, value in colormap.items()} + +class FaceStyle(Enum): + normal = 0 + bold = 1 + italic = 2 + boldItalic = 3 + + +class Align(Enum): + Left = 0 + Centre = 1 + Right = 2 + Top = 0 + Middle = 1 + Bottom = 2 + + +@dataclass +class DataObject: + def __post_init__(self): + pass + + def asdict(self): + return dataclasses.asdict(self) + + def __setattr__(self, name, value): + fld = self.__dataclass_fields__.get(name) + if fld: + value = fld.type(value) + super().__setattr__(name, value) + + +@dataclass +class Rect(DataObject): + x: int = 0 + y: int = 0 + w: int = 0 + h: int = 0 + + @property + def bounds(self): + return copy.copy(self) + + @bounds.setter + def bounds(self, rect): + self.x, self.y, self.w, self.h = rect.x, rect.y, rect.w, rect.h + + def inflate(self, xo, yo): + return Rect(self.x - xo, self.y - yo, self.w + xo*2, self.h + yo*2) + +class ColorFormat(Enum): + graphics = 0 # Alpha as first 2 digits + tkinter = 1 # No alpha channel in color (tkinter) + pillow = 2 # Alpha as last 2 digits + +class Color(int): + def __new__(cls, value): + if isinstance(value, str): + if value == '' or value is None: + value = 0 + elif str.isdigit(value[0]): + value = int(value, 0) + elif value[0] != '#': + try: + value = colormap[value] + except KeyError: + raise ValueError(f'Unknown color name {value}') + value = 0xff000000 | int(value[1:], 16) + elif value[0] != '#': + raise ValueError(f'Bad color {value}') + elif len(value) < 8: + value = 0xff000000 | int(value[1:], 16) + else: + value = int(value[1:], 16) + return int.__new__(cls, value) + + def __init__(self, *args, **kwds): + pass + + def value_str(self, format: ColorFormat): + if format == ColorFormat.graphics: + return '#%08x' % self + if format == ColorFormat.pillow: + return '#%06x%02x' % (self & 0x00ffffff, self.alpha) + return '#%06x' % self.rgb + + @property + def rgb(self): + return self & 0x00ffffff + + @property + def alpha(self): + return self >> 24 + + def __str__(self): + if self.alpha == 0xff: + s = self.value_str(ColorFormat.tkinter) + return rev_colormap.get(s, s) + return self.value_str(ColorFormat.graphics) + + def __repr__(self): + return str(self) + diff --git a/Tools/ged/item.py b/Tools/ged/item.py new file mode 100644 index 00000000..31e1a2f5 --- /dev/null +++ b/Tools/ged/item.py @@ -0,0 +1,199 @@ +import sys +import copy +from gtypes import Rect, Color, DataObject, Align +import dataclasses +from dataclasses import dataclass +import tkinter as tk + +MIN_ITEM_WIDTH = MIN_ITEM_HEIGHT = 2 + + +@dataclass +class GItem(Rect): + id: str = None + + @staticmethod + def create(itemtype, **field_values): + if isinstance(itemtype, str): + itemtype = getattr(sys.modules[__name__], f'G{itemtype}') + return itemtype(**field_values) + + def copy_as(itemtype): + """Create a new item and copy over any applicable attributes""" + item = self.create(itemtype) + for a, v in self.asdict().items(): + if hasattr(item, a): + setattr(item, a, v) + return item + + @classmethod + @property + def typename(cls): + return cls.__name__[1:] + + def assign_unique_id(self, existing_ids_or_list): + if isinstance(existing_ids_or_list, set): + existing_ids = existing_ids_or_list + else: + existing_ids = set(item.id for item in existing_ids_or_list) + n = 1 + typename = self.typename + while True: + id = f'{typename}_{n}' + if id not in existing_ids: + self.id = id + break + n += 1 + + def get_min_size(self, offset=0): + return (MIN_ITEM_WIDTH + offset, MIN_ITEM_HEIGHT + offset) + + def get_bounds(self): + return Rect(self.x, self.y, self.w, self.h) + + +@dataclass +class GRect(GItem): + color: Color = Color('orange') + line_width: int = 1 + radius: int = 0 + + def get_min_size(self): + return super().get_min_size((self.line_width + self.radius) * 2) + + def draw(self, c): + c.color = self.color + c.line_width = self.line_width + + if self.radius > 1: + c.draw_rounded_rect(self, self.radius) + else: + c.draw_rect(self) + + +@dataclass +class GFilledRect(GItem): + color: Color = Color('orange') + radius: int = 0 + + def get_min_size(self): + return super().get_min_size(self.radius * 2) + + def draw(self, c): + c.color = self.color + + if self.radius > 1: + c.fill_rounded_rect(self, self.radius) + else: + c.fill_rect(self) + + +@dataclass +class GEllipse(GItem): + color: Color = Color('orange') + line_width: int = 1 + + def get_min_size(self): + return super().get_min_size(self.line_width * 2) + + def draw(self, c): + c.color = self.color + c.line_width = self.line_width + c.draw_ellipse(self) + + +@dataclass +class GFilledEllipse(GItem): + color: Color = Color('orange') + def draw(self, c): + c.color = self.color + c.fill_ellipse(self) + + +@dataclass +class GText(GItem): + back_color: Color = '' + color: Color = Color('orange') + font: str = '' + text: str = '' + halign: str = 'Left' + valign: str = 'Top' + fontstyle: list[str] = dataclasses.field(default_factory=list) + fontscale: int = 1 + + def draw(self, c): + c.back_color = self.back_color + c.color = self.color + c.font = self.font + c.fontstyle = self.fontstyle + c.scale = self.fontscale + c.halign = Align[self.halign] + c.valign = Align[self.valign] + r = self.get_bounds() + M = 8 + r.inflate(-M, -M) + self._tk_image_ref = c.draw_text(r, self.text) + + +@dataclass +class GImage(GItem): + image: str = '' + xoff: int = 0 + yoff: int = 0 + + def draw(self, c): + self._tk_image_ref = c.draw_image(self, self.image, (self.xoff, self.yoff)) + + +@dataclass +class GButton(GItem): + back_color: Color = Color('gray') + border: Color = Color('white') + color: Color = Color('black') + font: str = '' + text: str = '' + fontscale: int = 1 + fontstyle: list[str] = dataclasses.field(default_factory=list) + + def draw(self, c): + radius = min(self.w, self.h) // 8 + M = radius // 2 + r = self.get_bounds() + r.inflate(-M, -M) + c.color = self.back_color + c.fill_rounded_rect(self, radius) + c.color = self.border + c.line_width = 4 + c.draw_rounded_rect(self, radius) + c.color = self.color + c.font = self.font + c.scale = self.fontscale + c.fontstyle = self.fontstyle + c.halign = Align.Centre + c.valign = Align.Middle + self._tk_image_ref = c.draw_text(r, self.text) + +@dataclass +class GLabel(GItem): + back_color: Color = Color('gray') + color: Color = Color('white') + font: str = '' + text: str = '' + halign: str = 'Centre' + fontscale: int = 1 + fontstyle: list[str] = dataclasses.field(default_factory=list) + + def draw(self, c): + c.color = self.back_color + c.fill_rect(self) + c.color = self.color + c.font = self.font + c.scale = self.fontscale + c.fontstyle = self.fontstyle + c.halign = Align[self.halign] + c.valign = Align.Middle + self._tk_image_ref = c.draw_text(self, self.text) + + +TYPENAMES = tuple(t.typename for t in sys.modules[__name__].__dict__.values() + if isinstance(t, type) and t != GItem and issubclass(t, GItem)) diff --git a/Tools/ged/remote.py b/Tools/ged/remote.py new file mode 100644 index 00000000..eb94984d --- /dev/null +++ b/Tools/ged/remote.py @@ -0,0 +1,57 @@ +import socket, struct +from item import * +from gtypes import ColorFormat +from resource import FontStyle +import urllib.parse + +class Client: + def __init__(self, address: str): + ipaddr, _, port = address.partition(':') + if not port: + port = 23 + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.socket.settimeout(5) + print(f'Connecting to {ipaddr}:{port} ...') + self.socket.connect((ipaddr, port)) + + def send_line(self, data): + if isinstance(data, str): + data = data.encode() + self.socket.send(data + b'\n') + + def recv_line(self): + rsp = b'' + while True: + c = self.socket.recv(1, socket.MSG_WAITALL) + if c == b'\n': + break + rsp += c + return rsp.decode() + + +def serialise(layout: list) -> list[str]: + data = [] + for item in layout: + line = f'i:{item.typename};' + for name, value in item.asdict().items(): + if name == 'id': + continue + if name == 'halign': + s = Align[value].value + elif name == 'valign': + s = Align[value].value + elif name == 'fontstyle': + s = hex(FontStyle.evaluate(value)) + elif name == 'text': + s = urllib.parse.quote(value.replace('\n', '\r\n').encode()) + elif type(value) in [int, float]: + s = value + elif type(value) is Color: + s = value.value_str(ColorFormat.graphics)[1:] + elif type(value) in [set, list]: + s = ",".join(value) + else: + s = value + line += f'{name}={s};' + data.append(line) + return data diff --git a/Tools/ged/resource.py b/Tools/ged/resource.py new file mode 100644 index 00000000..c6e6ef05 --- /dev/null +++ b/Tools/ged/resource.py @@ -0,0 +1,284 @@ +import sys +import os +import dataclasses +from dataclasses import dataclass +import freetype +import PIL.Image, PIL.ImageOps, PIL.ImageDraw, PIL.ImageFont +from PIL.ImageTk import PhotoImage as TkImage +from gtypes import Rect, DataObject, Color, ColorFormat, FaceStyle, Align +from enum import Enum +import textwrap + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../rc'))) +import rclib +from rclib.font import FontStyle + + +@dataclass +class Resource(DataObject): + name: str = '' + + +class ResourceList(list): + def get(self, name, default=None): + try: + return next(r for r in self if r.name == name) + except StopIteration: + return default + + def names(self): + return [r.name for r in self] + + def asdict(self): + res = {} + for r in self: + d = r.asdict() + del d['name'] + res[r.name] = d + return res + + def load(self, res_class, res_dict): + self.clear() + for name, rdef in res_dict.items(): + r = res_class(name=name) + for a, v in rdef.items(): + setattr(r, a, v) + self.append(r) + + +@dataclass +class FaceInfo: + flags: int + style: str + facestyle: FaceStyle + filename: str + + +# @dataclass +# class SystemFont: +# faces: dict[FaceInfo] = dataclasses.field(default_factory=dict) + + +class SystemFonts(dict): + def __init__(self): + self.scan() + + def scan(self): + self.clear() + + fontfiles = set() + for path in rclib.font.system_font_directories: + for walkroot, walkdirs, walkfilenames in os.walk(os.path.expandvars(path)): + fontfiles |= {os.path.join(walkroot, name) for name in walkfilenames} + + for filename in fontfiles: + _, ext = os.path.splitext(filename) + if ext not in rclib.font.parsers: + continue + try: + face = freetype.Face(filename) + except: + continue + name = face.family_name.decode() + ft_italic = face.style_flags & freetype.FT_STYLE_FLAGS['FT_STYLE_FLAG_ITALIC'] + ft_bold = face.style_flags & freetype.FT_STYLE_FLAGS['FT_STYLE_FLAG_BOLD'] + if ft_italic: + facestyle = FaceStyle.boldItalic if ft_bold else FaceStyle.italic + else: + facestyle = FaceStyle.bold if ft_bold else FaceStyle.normal + style_name = face.style_name.decode() + faceinfo = FaceInfo(hex(face.style_flags), style_name, facestyle, filename) + font = self.setdefault(name, {}) + font[style_name] = faceinfo + + +system_fonts = SystemFonts() + + +@dataclass +class Font(Resource): + family: str = '' + size: int = 12 + mono: bool = False + normal: str = '' + bold: str = '' + italic: str = '' + boldItalic: str = '' + + def draw_tk_image(self, width: int, height: int, scale: int, fontstyle, fontscale: int, halign: Align, valign: Align, back_color, color, text): + fontstyle = {FontStyle[s] for s in fontstyle} + back_color = Color(back_color).value_str(ColorFormat.pillow) + color = Color(color).value_str(ColorFormat.pillow) + w_box, h_box = width // fontscale, height // fontscale + img = PIL.Image.new('RGBA', (w_box, h_box)) + draw = PIL.ImageDraw.Draw(img) + draw.fontmode = '1' if self.mono else 'L' + try: + sysfont = system_fonts[self.family] + face_name = '' + if FontStyle.Italic in fontstyle: + if FontStyle.Bold in fontstyle: + face_name = self.boldItalic + face_name = face_name or self.italic + elif FontStyle.Bold in fontstyle: + face_name = self.bold + face_name = face_name or self.normal + font = PIL.ImageFont.truetype(sysfont[face_name].filename, self.size) + except: + font = PIL.ImageFont.load_default(self.size) + draw.font = font + ascent, descent = font.getmetrics() + yAdvance = ascent + abs(descent) + + def split_word(word, width): + for i in range(len(word) - 1, 1, -1): + w = draw.textlength(word[:i]) + if w <= width: + return word[:i], word[i:], w + # Too narrow, emit 1 character + return word[0], word[1:], draw.textlength(word[0]) + + # Break text up into words for wrapping + paragraphs = [textwrap.wrap(para, 1, break_long_words = False, + replace_whitespace = False, drop_whitespace=False) + for para in text.splitlines()] + # Measure and build list of lines and their length in pixels + lines = [] + for para in paragraphs: + text = '' + x = 0 + space = (0, '') + for word in para: + w = draw.textlength(word) + if word.isspace(): + space = (w, word) + continue + if x == 0 and w > w_box: + while w > w_box: + s, word, w = split_word(word, w_box) + lines.append((w, s)) + w = draw.textlength(word) + x = w + text = word + if x > 0 and (x + space[0] + w) > w_box: + lines.append((x, text)) + x = 0 + text = '' + else: + x += space[0] + w + text += space[1] + word + space = (0, '') + if x > 0 or not para: + lines.append((x, text)) + h = yAdvance * len(lines) + if valign == Align.Middle: + y = (h_box - h) // 2 + elif valign == Align.Bottom: + y = h_box - h + else: + y = 0 + for w, text in lines: + if w > 0: + if halign == Align.Centre: + x = (w_box - w) // 2 + elif halign == Align.Right: + x = w_box - w + else: + x = 0 + draw.text((x, y), text, fill=color) + # draw.rectangle((x,y,x+w,y+yAdvance), outline='white') + def hline(y): + draw.line((x, y, x + w, y), fill=color) + if FontStyle.Underscore in fontstyle or FontStyle.DoubleUnderscore in fontstyle: + hline(y + ascent) + if FontStyle.DoubleUnderscore in fontstyle: + hline(y + ascent + 3) + if FontStyle.Overscore in fontstyle or FontStyle.DoubleOverscore in fontstyle: + hline(y + 3) + if FontStyle.DoubleOverscore in fontstyle: + hline(y) + yc = y + yAdvance // 2 + if FontStyle.Strikeout in fontstyle: + hline(yc) + if FontStyle.DoubleStrikeout in fontstyle: + hline(yc - 1) + hline(yc + 1) + y += yAdvance + + img = img.resize((round(width * scale), round(height * scale)), + resample=PIL.Image.Resampling.NEAREST) + return TkImage(img) + + +class FontList(ResourceList): + def __init__(self): + self.default = Font(family=next(iter(system_fonts))) + + @staticmethod + def families(): + return sorted(system_fonts) + + def load(self, res_dict): + super().load(Font, res_dict) + + +@dataclass +class Image(Resource): + source: str = '' + format: str = '' + width: int = 64 + height:int = 64 + image = None + + def __post_init__(self): + super().__post_init__() + if self.source: + self.reset_size() + + def reset_size(self): + try: + img = PIL.Image.open(self.source) + self.width, self.height = img.size + except: + pass + + def get_tk_image(self, crop_rect: Rect, scale: float = 1) -> TkImage: + w, h = self.width, self.height + img = self.image + if img and (img.width != w or img.height != h): + img = None + if img is None: + try: + img = PIL.Image.open(self.source) + if w == 0: + if h == 0: + w, h = img.width, img.height + else: + w = img.width * h // img.height + elif h == 0: + h = img.height * w // img.width + if w != img.width or h != img.height: + img = img.resize((w, h)) + self.width = w + self.height = h + except: + img = None + self.image = img + + if img: + box = crop_rect.x, crop_rect.y, crop_rect.x + crop_rect.w, crop_rect.y + crop_rect.h + img = self.image.crop(box) + img = img.resize((round(img.width * scale), round(img.height * scale)), + resample=PIL.Image.Resampling.NEAREST) + else: + w, h = round(crop_rect.w * scale), round(crop_rect.h * scale) + img = PIL.Image.new('RGB', (w, h), color='red') + draw = PIL.ImageDraw.Draw(img) + draw.line((0, 0, w, h), fill=128, width=3) + draw.line((0, h, w, 0), fill=128, width=3) + return TkImage(img) + + +class ImageList(ResourceList): + def load(self, res_dict): + super().load(Image, res_dict) diff --git a/Tools/ged/widgets.py b/Tools/ged/widgets.py new file mode 100644 index 00000000..6268d3c5 --- /dev/null +++ b/Tools/ged/widgets.py @@ -0,0 +1,358 @@ +import re +import tkinter as tk +from tkinter import ttk +from tkinter.scrolledtext import ScrolledText + +class LabelWidget(ttk.Label): + def __init__(self, master, text): + super().__init__(master, text=text) + + def set_row(self, row: int): + self.grid(row=row, column=0, sticky=tk.E, padx=2) + return self + + +class CustomWidget: + @staticmethod + def text_from_name(name: str) -> str: + words = re.findall(r'[A-Z]?[a-z]+|[A-Z]+(?=[A-Z]|$)', name) + return ' '.join(words).lower() + + def set_row(self, row: int): + self.grid(row=row, column=1, sticky=tk.EW, padx=2, pady=1) + return self + + @staticmethod + def _addvar(name, type, callback): + if type is str: + var = tk.StringVar() + elif type is int: + var = tk.IntVar() + elif type is float: + var = tk.DoubleVar() + elif type is bool: + var = tk.BooleanVar + else: + raise TypeError(f'Invalid variable type {type}') + def tk_value_changed(name1, name2, op, name=name, var=var): + try: + value = var.get() + # print(f'value_changed:"{name1}", "{name2}", "{op}", "{value}"') + callback(name, value) + except tk.TclError: # Can happen whilst typing if conversion fails, empty strings, etc. + pass + var.trace_add('write', tk_value_changed) + return var + + def set_choices(self, choices): + pass + + +class FrameWidget(ttk.Frame, CustomWidget): + pass + + +class EntryWidget(ttk.Entry, CustomWidget): + def __init__(self, master, name, vartype, callback): + self.var = self._addvar(name, vartype, callback) + super().__init__(master, textvariable=self.var) + + def get_value(self): + return self.var.get() + + def set_value(self, value): + self.var.set(value) + + +class SpinboxWidget(ttk.Spinbox, CustomWidget): + def __init__(self, master, name, from_: int, to: int, increment: int, callback): + self.callback = callback + def spin_invoked(name=name): + if self.callback: + self.callback(name, self.get_value()) + super().__init__(master, text=self.text_from_name(name), command=spin_invoked, + from_=from_, to=to, increment=increment) + + def get_value(self): + return self.get() + + def set_value(self, value): + self.set(value) + + +class TextWidget(ScrolledText, CustomWidget): + def __init__(self, master, name, callback): + super().__init__(master, height=4) + self.callback = callback + if self.callback: + def on_modified(evt, name=name): + if self.callback: + self.edit_modified(False) + self.callback(name, self.get_value()) + self.bind('<>', on_modified) + + def get_value(self): + # TK always appends a newline, so remove it + return self.get('1.0', 'end')[:-1] + + def set_value(self, value): + self.replace('1.0', 'end', value) + + +class ComboboxWidget(ttk.Combobox, CustomWidget): + def __init__(self, master, name, vartype, values: list, callback): + self.var = self._addvar(name, vartype, callback) + super().__init__(master, textvariable=self.var, values=values) + + def get_value(self): + return self.var.get() + + def set_value(self, value): + self.var.set(value) + + def set_choices(self, choices): + self.configure(values=choices) + + +class ListboxWidget(tk.Listbox, CustomWidget): + def __init__(self, master, callback): + self.callback = callback + self.itemlist = [] + self.listvar = tk.StringVar() + self.selection = None + super().__init__(master, listvariable=self.listvar, selectmode=tk.EXTENDED) + if self.callback: + def on_select(evt): + sel = self.curselection() + if sel == self.selection: + return + self.selection = sel + self.callback() + self.bind('<>', on_select) + + def get_value(self): + return None + # return self.var.get() + + def set_value(self, value): + pass + # self.var.set(value) + + def get_selection(self): + return [self.itemlist[i] for i in self.curselection()] + + def set_choices(self, choices): + self.listvar.set(list(item.id for item in choices)) + self.itemlist = choices + + def select(self, sel_items): + self.select_clear(0, tk.END) + i_last = -1 + for i, item in enumerate(self.itemlist): + if item not in sel_items: + continue + self.select_set(i) + i_last = i + if i_last >= 0: + self.see(i_last) + + +class TreeviewWidget(ttk.Frame, CustomWidget): + def __init__(self, master, callback): + self.callback = callback + self.itemlist = [] + self.is_selecting = False + COLUMNS = dict( + type='Type' + ) + + super().__init__(master) + tree = self.tree = ttk.Treeview(self, columns=list(COLUMNS)) + vscroll = ttk.Scrollbar(self, orient=tk.VERTICAL, command=tree.yview) + vscroll.pack(side=tk.RIGHT, fill=tk.Y) + tree.configure(yscrollcommand=vscroll.set) + tree.pack(side=tk.LEFT, expand=True, fill=tk.BOTH) + + for k, v in COLUMNS.items(): + tree.heading(k, text=v, anchor=tk.W) + if self.callback: + def on_select(evt): + if not self.is_selecting: + self.callback() + tree.bind('<>', on_select) + + def bind(self, *args): + self.tree.bind(*args) + + def get_value(self): + return None + # return self.var.get() + + def set_value(self, value): + pass + # self.var.set(value) + + def get_selection(self): + index = dict((item.id, item) for item in self.itemlist) + return [index[id] for id in self.tree.selection()] + + def set_choices(self, choices): + choices = list(choices) + tree = self.tree + idlist = set(tree.get_children('')) + choice_ids = set(item.id for item in choices) + del_ids = [id for id in idlist if id not in choice_ids] + if del_ids: + tree.delete(*del_ids) + for i, item in enumerate(choices): + if item.id in idlist: + tree.move(item.id, '', i) + tree.item(item.id, text=item.id, values=(item.typename)) + else: + tree.insert('', i, item.id, text=item.id, values=(item.typename)) + self.itemlist = choices + + def select(self, sel_items): + self.is_selecting = True + tree = self.tree + tree.selection_set([item.id for item in sel_items]) + tree.update() + focus = tree.focus() + if focus: + tree.see(focus) + self.is_selecting = False + + +class ScaleWidget(tk.Scale, CustomWidget): + def __init__(self, master, name, vartype, callback, from_, to, resolution): + self.callback = callback + # Don't get variable trace callbacks for scale widgets (don't know why) so use command + def cmd_changed(value, name=name, vartype=vartype): + if self.callback: + self.callback(name, vartype(value)) + super().__init__(master, orient=tk.HORIZONTAL, from_=from_, to=to, + resolution=resolution, command=cmd_changed) + + def get_value(self): + return self.get() + + def set_value(self, value): + self.set(value) + + +class CheckFieldWidget(ttk.Checkbutton, CustomWidget): + def __init__(self, master, name, callback): + self.callback = callback + def check_invoked(name=name): + if self.callback: + self.callback(name, self.get_value()) + super().__init__(master, text=self.text_from_name(name), command=check_invoked) + self.state(['!alternate']) + + def get_value(self): + return 'selected' in self.state() + + def set_value(self, value): + self.state(['selected' if value else '!selected']) + + +class CheckFieldsWidget(ttk.Frame, CustomWidget): + def __init__(self, master, name, is_set: bool, values, callback): + super().__init__(master) + self.callback = callback + self.is_set = is_set + self.var = set() if is_set else '' + self.ctrls = {} + for value in values: + tag, _, text = value.partition(':') + def check_invoked(name=name, value=tag): + ctrl = self.ctrls[value] + state = ctrl.state() + if self.is_set: + if 'selected' in state: + self.var.add(value) + else: + self.var.discard(value) + elif 'selected' in state: + self.var = value + if self.callback: + self.callback(name, self.var) + self.update_checks() + ctrl = ttk.Checkbutton(self, text=text or value, command=check_invoked) + ctrl.state(['!alternate']) + ctrl.pack(side=tk.LEFT) + self.ctrls[tag] = ctrl + + def update_checks(self): + def is_selected(f): + return f == f in self.var if self.is_set else f == self.var + for f, c in self.ctrls.items(): + c.state(['selected' if is_selected(f) else '!selected']) + + def get_value(self): + return self.var + + def set_value(self, value): + if self.is_set: + self.var = set(value) + else: + self.var = str(value) + self.update_checks() + + +class GroupedCheckFieldsWidget(ttk.LabelFrame, CustomWidget): + def __init__(self, master, name, groups: dict, callback): + """Split set up into distinct parts""" + super().__init__(master, text=self.text_from_name(name)) + self.grid(column=0, columnspan=2, sticky=tk.EW) + self.callback = callback + self.var = set() + self.ctrls = {} + row = 0 # Within our new frame + for group, values in groups.items(): + def split(value): + text, _, value = value.partition(':') + return (text, value) if value else (text, text) + group, _, kind = group.partition(':') + if kind == 'set': + value_set = () + else: + if kind: + raise ValueError(f'Bad field group kind "{kind}"') + value_set = {split(x)[1] for x in values} + LabelWidget(self, self.text_from_name(group)).set_row(row) + col = 1 + for value in values: + text, value = split(value) + def check_invoked(name=name, value=value, value_set=value_set): + ctrl = self.ctrls[value] + state = ctrl.state() + for v in value_set: + if v != value: + self.var.discard(v) + if 'selected' in state: + self.var.add(value) + else: + self.var.discard(value) + if self.callback: + self.callback(name, self.var) + self.update_checks() + ctrl = ttk.Checkbutton(self, text=text, command=check_invoked) + ctrl.state(['!alternate']) + ctrl.grid(row=row, column=col) + col += 1 + self.ctrls[value] = ctrl + row += 1 + + def update_checks(self): + for f, c in self.ctrls.items(): + c.state(['selected' if f in self.var else '!selected']) + + def get_value(self): + return self.var + + def set_value(self, value): + self.var = set(value) + self.update_checks() + + diff --git a/Tools/rc/resource/common.py b/Tools/rc/common.py similarity index 97% rename from Tools/rc/resource/common.py rename to Tools/rc/common.py index 217358fb..466b1b08 100644 --- a/Tools/rc/resource/common.py +++ b/Tools/rc/common.py @@ -39,10 +39,6 @@ def critical(msg): sys.stderr.write('\n') -def compact_string(s): - return ''.join(s.split()) - - def json_loads(s): return json.loads(jsmin(s), object_pairs_hook=OrderedDict) diff --git a/Tools/rc/rc.py b/Tools/rc/rc.py index 32497249..4b444618 100644 --- a/Tools/rc/rc.py +++ b/Tools/rc/rc.py @@ -19,10 +19,14 @@ # @author: July 2021 - mikee47 # -import os, sys, json, argparse -from resource import * -import resource.font -import resource.image +import os +import sys +import argparse +import rclib +import rclib.font +import rclib.image +import common +from common import json_load, status def main(): parser = argparse.ArgumentParser(description='Sming Resource Compiler') @@ -33,20 +37,20 @@ def main(): args = parser.parse_args() common.quiet = args.quiet - resource.resourcePaths.append(os.path.dirname(os.path.abspath(args.input))) + rclib.base.resourcePaths.append(os.path.dirname(os.path.abspath(args.input))) script = json_load(args.input) - list = resource.parse(script['resources']) + data = rclib.parse(script['resources']) with openOutput(os.path.join(args.output, 'resource.h'), 'w') as out: - writeHeader(list, out) + rclib.writeHeader(data, out) with openOutput(os.path.join(args.output, 'resource.bin'), 'wb') as out: - writeBitmap(list, out) + rclib.writeBitmap(data, out) bitmapSize = out.tell() - structSize = sum(item.headerSize for item in list) + structSize = sum(item.headerSize for item in data) status("Resource compiled %u items, structures are %u bytes, bitmap is %u bytes" - % (len(list), structSize, bitmapSize)) + % (len(data), structSize, bitmapSize)) def openOutput(path, mode): @@ -56,62 +60,10 @@ def openOutput(path, mode): return open(path, mode) -def writeHeader(list, out): - out.write( - "/**\n" - " * Auto-generated file\n" - " */\n" - "\n" - "#include \n" - "#include \n" - "\n" - "namespace Graphics {\n" - "namespace Resource {\n" - "\n" - ) - bmOffset = 0 - for item in list: - item.bmOffset = bmOffset - bmOffset = item.writeHeader(bmOffset, out) - item.bmSize = bmOffset - item.bmOffset - - out.write("DEFINE_FSTR_VECTOR(fontTable, FontResource,\n") - for item in list: - if type(item) is resource.font.Font: - out.write("\t&%s,\n" % item.name) - out.write(");\n\n") - - out.write("DEFINE_FSTR_VECTOR(imageTable, ImageResource,\n") - for item in list: - if type(item) is resource.image.Image: - out.write("\t&%s,\n" % item.name) - out.write(");\n\n") - - out.write( - "} // namespace Resource\n" - "} // namespace Graphics\n" - ) - - out.write("\n/*\nSummary\n") - out.write("Bitmap Offset Size Header Type Name\n") - out.write("---------- ------ ------ -------- ---------\n") - headerSize = 0 - for item in list: - out.write("0x%08x %6u %6u %-8s %s\n" - % (item.bmOffset, item.bmSize, item.headerSize, type(item).__name__, item.name)) - headerSize += item.headerSize - out.write("---------- ------ ------ -------- ---------\n") - out.write("Total: %6u %6u\n*/\n\n" % (bmOffset, headerSize)) - - -def writeBitmap(list, out): - for item in list: - item.writeBitmap(out) - - if __name__ == '__main__': try: main() - except InputError as e: + except Exception as e: + raise print("** ERROR! %s" % e, file=sys.stderr) sys.exit(2) diff --git a/Tools/rc/rclib/__init__.py b/Tools/rc/rclib/__init__.py new file mode 100644 index 00000000..08f9b425 --- /dev/null +++ b/Tools/rc/rclib/__init__.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +# +# __init__.py - Base resource class and parsing support +# +# Copyright 2021 mikee47 +# +# This file is part of the Sming-Graphics Library +# +# This library 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, version 3 or later. +# +# This library 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. +# +# You should have received a copy of the GNU General Public License along with this library. +# If not, see . +# +# @author: July 2021 - mikee47 +# + +from . import font, gfx, linux, vlw, freetype, pfi +from . import image + +# Dictionary of registered resource type parsers +# 'type': parse_item(item, name) +parsers = { + 'font': font.parse_item, + 'image': image.parse_item, +} + + +def parse(resources): + list = [] + for type, entries in resources.items(): + parse = parsers.get(type) + if not parse: + raise InputError("Unsupported resource type '%s'" % type) + for name, item in entries.items(): + # status("Parsing %s '%s'..." % (type, name)) + obj = parse(item, name) + list.append(obj) + return list + + +def writeHeader(data: list, out): + out.write( + "/**\n" + " * Auto-generated file\n" + " */\n" + "\n" + "#include \n" + "#include \n" + "\n" + "namespace Graphics {\n" + "namespace Resource {\n" + "\n" + ) + bmOffset = 0 + for item in data: + item.bmOffset = bmOffset + bmOffset = item.writeHeader(bmOffset, out) + item.bmSize = bmOffset - item.bmOffset + + out.write("DEFINE_FSTR_VECTOR(fontTable, FontResource,\n") + for item in data: + if isinstance(item, font.Font): + out.write("\t&%s,\n" % item.name) + out.write(");\n\n") + + out.write("DEFINE_FSTR_VECTOR(imageTable, ImageResource,\n") + for item in data: + if isinstance(item, image.Image): + out.write("\t&%s,\n" % item.name) + out.write(");\n\n") + + out.write( + "} // namespace Resource\n" + "} // namespace Graphics\n" + ) + + out.write("\n/*\nSummary\n") + out.write("Bitmap Offset Size Header Type Name\n") + out.write("---------- ------ ------ -------- ---------\n") + headerSize = 0 + for item in data: + out.write("0x%08x %6u %6u %-8s %s\n" + % (item.bmOffset, item.bmSize, item.headerSize, type(item).__name__, item.name)) + headerSize += item.headerSize + out.write("---------- ------ ------ -------- ---------\n") + out.write("Total: %6u %6u\n*/\n\n" % (bmOffset, headerSize)) + + +def writeBitmap(data: list, out): + for item in data: + item.writeBitmap(out) diff --git a/Tools/rc/resource/__init__.py b/Tools/rc/rclib/base.py similarity index 68% rename from Tools/rc/resource/__init__.py rename to Tools/rc/rclib/base.py index c81bca07..c8385e46 100644 --- a/Tools/rc/resource/__init__.py +++ b/Tools/rc/rclib/base.py @@ -19,8 +19,21 @@ # @author: July 2021 - mikee47 # -import os, sys, enum -from .common import * +import os +import enum + +ORDER_RGB = 0 +ORDER_BGR = 1 + +def pixel_format(bytes, bpp, color_order): + return (bytes - 1) | ((bpp >> 1) << 2) | (color_order << 7) + +class PixelFormat(enum.Enum): + NONE = 0 + RGB24 = pixel_format(3, 24, ORDER_RGB) + BGRA32 = pixel_format(4, 32, ORDER_RGB) + BGR24 = pixel_format(3, 24, ORDER_BGR) + RGB565 = pixel_format(2, 16, ORDER_RGB) # Used to calculate compiled size of resource header information class StructSize(enum.IntEnum): @@ -29,6 +42,14 @@ class StructSize(enum.IntEnum): Typeface = 16, Font = 24, Image = 20, + # These values are used for 64-bit host builds + Typeface64 = 24, + Font64 = 44, + Image64 = 24, + + +def compact_string(s): + return ''.join(s.split()) def fstrSize(s): @@ -58,33 +79,19 @@ def writeComment(self, out): '${GRAPHICS_LIB_ROOT}/resource', ] -# Dictionary of registered resource type parsers -# 'type': parse_item(item, name) -parsers = {} - def findFile(filename, dirs = []): - alldirs = [] - for directory in [os.path.expandvars(path) for path in resourcePaths + dirs]: - for walkroot, walkdir, walkfilenames in os.walk(directory): - alldirs.append(walkroot) + if os.path.exists(filename): + return filename + + alldirs = set() + for path in resourcePaths + dirs: + for walkroot, _, _ in os.walk(os.path.expandvars(path)): + alldirs.add(walkroot) for dir in alldirs: path = os.path.join(dir, filename) if os.path.exists(path): - status("Found '%s'" % path) + # status("Found '%s'" % path) return path - raise InputError("File not found '%s'" % filename) - - -def parse(resources): - list = [] - for type, entries in resources.items(): - parse = parsers.get(type) - if not parse: - raise InputError("Unsupported resource type '%s'" % type) - for name, item in entries.items(): - status("Parsing %s '%s'..." % (type, name)) - obj = parse(item, name) - list.append(obj) - return list + raise FileNotFoundError(filename) diff --git a/Tools/rc/resource/font/font.py b/Tools/rc/rclib/font.py similarity index 68% rename from Tools/rc/resource/font/font.py rename to Tools/rc/rclib/font.py index 87d0968c..637fb2bf 100644 --- a/Tools/rc/resource/font/font.py +++ b/Tools/rc/rclib/font.py @@ -19,11 +19,37 @@ # @author: July 2021 - mikee47 # -import resource, enum, os, sys -from resource import InputError - - -class Glyph(resource.Resource): +from __future__ import annotations +import enum +import os +import sys +import struct +from .base import Resource, findFile, StructSize, fstrSize + +class FontStyle(enum.Enum): + """Style is a set of these values, using strings here but bitfields in library""" + # typeface + Bold = 0 + Italic = 1 + Underscore = 2 + Overscore = 3 + Strikeout = 4 + DoubleUnderscore = 5 + DoubleOverscore = 6 + DoubleStrikeout = 7 + DotMatrix = 8 + HLine = 9 + VLine = 10 + + @staticmethod + def evaluate(value: list[str]): + n = 0 + for x in value: + n |= 1 << FontStyle[x].value + return n + + +class Glyph(Resource): class Flag(enum.IntEnum): alpha = 0x01, @@ -46,7 +72,7 @@ def packBits(self, rows, width): We identify defined area, exclude surround empty region, then pack bits and update glyph details. """ - if self.flags & resource.font.Glyph.Flag.alpha: + if self.flags & Glyph.Flag.alpha: return height = len(rows) @@ -125,7 +151,7 @@ def packBits(self, rows, width): # print("packBits %u; width %u, height %u, leading %u, trailing %u" % (len(src), width, height, leading, trailing)) -class Typeface(resource.Resource): +class Typeface(Resource): def __init__(self, font, style): super().__init__() self.font = font @@ -136,6 +162,66 @@ def __init__(self, font, style): self.glyphs = [] self.headerSize = 0 + def serialize(self, bmOffset, res_offset, ptr64: bool): + glyph_data = self.serialize_glyphs() + block_data = self.serialize_glyph_blocks() + + # `struct TypefaceResource' + typefaceSize = StructSize.Typeface64 if ptr64 else StructSize.Typeface + face_res = struct.pack('= 0 and g.codePoint == cp + length: + length += 1 + continue + if cp >= 0: + writeBlock() + count += 1 + cp = g.codePoint + length = 1 + writeBlock() + return resdata + def writeGlyphRecords(self, out): # Array of glyph definitions bmOffset = 0 @@ -149,7 +235,7 @@ def writeGlyphRecords(self, out): out.write("\t{ 0x%04x, %3u, %3u, %3d, %3d, %3u, 0x%02x }, // #0x%04x %s \n" % (bmOffset, g.width, g.height, g.xOffset, g.yOffset, g.xAdvance, g.flags, g.codePoint, c)) bmOffset += len(g.bitmap) - self.headerSize += resource.StructSize.GlyphResource + self.headerSize += StructSize.GlyphResource out.write("};\n\n") return bmOffset @@ -161,7 +247,7 @@ def writeGlyphBlocks(self, out): def writeBlock(): out.write("\t{ 0x%04x, %u },\n" % (cp, length)) - self.headerSize += resource.StructSize.GlyphBlock + self.headerSize += StructSize.GlyphBlock out.write("const GlyphBlock %s_blocks[] PROGMEM {\n" % self.name) for g in self.glyphs: @@ -178,6 +264,9 @@ def writeBlock(): count += 1 return count + def get_bitmap_size(self): + return sum(len(g.bitmap) for g in self.glyphs) + def writeHeader(self, bmOffset, out): self.headerSize = 0 bmSize = self.writeGlyphRecords(out) @@ -186,7 +275,7 @@ def writeHeader(self, bmOffset, out): super().writeComment(out) out.write("const TypefaceResource %s_typeface PROGMEM {\n" % self.name) out.write("\t.bmOffset = 0x%08x,\n" % bmOffset) - bmSize = sum(len(g.bitmap) for g in self.glyphs) + bmSize = self.get_bitmap_size() out.write("//\t.bmSize = %u,\n" % bmSize) if self.style != []: out.write("\t.style = uint8_t(FontStyles(%s).value()),\n" % ' | '.join('FontStyle::' + style for style in self.style)) @@ -196,7 +285,7 @@ def writeHeader(self, bmOffset, out): out.write("\t.glyphs = %s_glyphs,\n" % self.name) out.write("\t.blocks = %s_blocks,\n" % self.name) out.write("};\n\n") - self.headerSize += resource.StructSize.Typeface + self.headerSize += StructSize.Typeface return bmOffset + bmSize def writeBitmap(self, out): @@ -204,7 +293,7 @@ def writeBitmap(self, out): out.write(g.bitmap) -class Font(resource.Resource): +class Font(Resource): def __init__(self): super().__init__() self.typefaces = [] @@ -212,6 +301,34 @@ def __init__(self): self.descent = 0 self.headerSize = 0 + def serialize(self, bmOffset, res_offset, ptr64: bool): + resdata = b'' + face_offsets = [] + fontSize = StructSize.Font64 if ptr64 else StructSize.Font + for typeface in self.typefaces: + # print(typeface.name) + offset = res_offset + fontSize + len(resdata) + face_offsets.append(offset) + resdata += typeface.serialize(bmOffset, offset, ptr64) + bmOffset += typeface.get_bitmap_size() + while len(face_offsets) < 4: + face_offsets.append(0) + + # `struct FontResource` + font_res = struct.pack(' # -import resource.font, freetype, os, PIL, array -from resource import InputError +import os +import PIL +import array +from .font import Glyph def parse_typeface(typeface): with open(typeface.source) as f: @@ -28,7 +30,7 @@ def parse_typeface(typeface): pfi = pfi.split('\n') if pfi[0] != 'F1': - raise InputError('Require a text .PFI file') + raise ValueError('Require a text .PFI file') while pfi[1].startswith('#'): del pfi[1] # print("Name: %s" % pfi[1]) @@ -58,7 +60,7 @@ def parse_typeface(typeface): # if not c in typeface.font.codePoints: # continue - g = resource.font.Glyph(typeface) + g = Glyph(typeface) g.codePoint = c if line[0] != '' and (width == 0 or len(line) == 3): # Proportional w = int(line[0]) diff --git a/Tools/rc/resource/font/vlw.py b/Tools/rc/rclib/vlw.py similarity index 89% rename from Tools/rc/resource/font/vlw.py rename to Tools/rc/rclib/vlw.py index 34524bf0..87c1492c 100644 --- a/Tools/rc/resource/font/vlw.py +++ b/Tools/rc/rclib/vlw.py @@ -62,8 +62,8 @@ # // uint8_t bitmap[width * height] # }; -import resource.font, struct -from resource import status +import struct +from .font import Glyph def parse_typeface(typeface): with open(typeface.source, "rb") as f: @@ -78,7 +78,7 @@ def parse_typeface(typeface): bmOffset = offset + (numGlyphs * GLYPH_HEADER_SIZE) for i in range(numGlyphs): - g = resource.font.Glyph(typeface) + g = Glyph(typeface) g.codePoint, g.height, g.width, g.xAdvance, topExtent, g.xOffset, c_ptr = struct.unpack_from('>7i', data, offset) bmSize = g.height * g.width if g.codePoint in typeface.font.codePoints: @@ -87,7 +87,7 @@ def parse_typeface(typeface): descent = max(descent, g.height - topExtent) ascent = max(ascent, topExtent) g.yOffset = -topExtent - g.flags = resource.font.Glyph.Flag.alpha + g.flags = Glyph.Flag.alpha g.bitmap = data[bmOffset:bmOffset+bmSize] typeface.glyphs.append(g) offset += GLYPH_HEADER_SIZE @@ -109,9 +109,9 @@ def readName(): psname = readName() smooth = data[offset] - status("name '%s', psname '%s', smooth %u, offset %u, len(data) %u" % (name, psname, smooth, offset, len(data))) - status("\tnumGlyphs %u, version %u, pointSize %u, ascent %d, descent %d" - % (numGlyphs, version, pointSize, ascent, descent)) + # status("name '%s', psname '%s', smooth %u, offset %u, len(data) %u" % (name, psname, smooth, offset, len(data))) + # status("\tnumGlyphs %u, version %u, pointSize %u, ascent %d, descent %d" + # % (numGlyphs, version, pointSize, ascent, descent)) from .font import parsers diff --git a/Tools/rc/resource/font/__init__.py b/Tools/rc/resource/font/__init__.py deleted file mode 100644 index 22021079..00000000 --- a/Tools/rc/resource/font/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env python3 -# -# __init__.py - Font resource parsers -# -# Copyright 2021 mikee47 -# -# This file is part of the Sming-Graphics Library -# -# This library 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, version 3 or later. -# -# This library 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. -# -# You should have received a copy of the GNU General Public License along with this library. -# If not, see . -# -# @author: July 2021 - mikee47 -# - -from . import gfx, linux, vlw, freetype, pfi -from .font import * - -resource.parsers['font'] = parse_item diff --git a/Tools/rc/resource/image/__init__.py b/Tools/rc/resource/image/__init__.py deleted file mode 100644 index 7ecc0c5d..00000000 --- a/Tools/rc/resource/image/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .image import * - -resource.parsers['image'] = image.parse_item diff --git a/component.mk b/component.mk index 5186c9d1..3de97118 100644 --- a/component.mk +++ b/component.mk @@ -15,6 +15,9 @@ COMPONENT_INCDIRS := \ resource COMPONENT_DOXYGEN_INPUT := src/include +COMPONENT_DOCFILES := \ + Tools/ged/README.rst \ + Tools/ged/graphic-editor.png export GRAPHICS_LIB_ROOT := $(COMPONENT_PATH) @@ -62,7 +65,9 @@ graphics-clean: endif -# + +##@Tools + ifeq ($(SMING_ARCH),Host) # For application use @@ -93,3 +98,15 @@ virtual-screen: ##Start virtual screen server $(Q) $(VIRTUAL_SCREEN_CMDLINE) & endif + + +GEDIT_PY := $(GRAPHICS_LIB_ROOT)/Tools/ged/ged.py +GEDIT_CMDLINE := $(PYTHON) $(GEDIT_PY) +ifdef WSL_ROOT +GEDIT_CMDLINE := powershell.exe -Command "$(GEDIT_CMDLINE)" +endif + +.PHONY: graphic-editor +graphic-editor: ##Run graphical editor + $(info Starting graphical editor) + $(Q) $(GEDIT_CMDLINE) & diff --git a/samples/Graphic_Editor/Makefile b/samples/Graphic_Editor/Makefile new file mode 100644 index 00000000..ff51b6c3 --- /dev/null +++ b/samples/Graphic_Editor/Makefile @@ -0,0 +1,9 @@ +##################################################################### +#### Please don't change this file. Use component.mk instead #### +##################################################################### + +ifndef SMING_HOME +$(error SMING_HOME is not set: please configure it as an environment variable) +endif + +include $(SMING_HOME)/project.mk diff --git a/samples/Graphic_Editor/README.rst b/samples/Graphic_Editor/README.rst new file mode 100644 index 00000000..a7b2cd56 --- /dev/null +++ b/samples/Graphic_Editor/README.rst @@ -0,0 +1,48 @@ +Graphic Editor +============== + +Run this application on target hardware or in the host emulator to allow GED (Graphical Editor) to control it. + +It is intended to be a companion application which can be run on the target hardware +to evaluate **exactly** how the screen will appear in the final application. + +See :doc:`../../Tools/ged/README` for further details. + + +Getting started +--------------- + +Run:: + + make graphic-editor + +If this doesn't work, see :library:`Graphics` for installation requirements. + +To demonstrate using the host emulator: + +1. Build this application:: + + make SMING_SOC=host + +2. Run the virtual screen:: + + make virtual-screen + +3. Run this application using the virtual screen address (in the title bar):: + + make run VSADDR=192.168.1.40 + +4. Run the graphical editor, if it isn't already:: + + make graphic-editor + +5. Hit the ``load`` button and navigate to the ``samples/Graphic_Editor/projects`` directory. + Load the ``images.ged`` sample project. + +6. Hit the ``Send resources`` button. + You should see messages on the console indicating successful resource upload. + +7. Hit the ``Send layout`` button to update the virtual screen. + +The ``Send resouces`` only needs to be done once in a session unless new images are added +or their sizes changed. diff --git a/samples/Graphic_Editor/app/application.cpp b/samples/Graphic_Editor/app/application.cpp new file mode 100644 index 00000000..99ff812e --- /dev/null +++ b/samples/Graphic_Editor/app/application.cpp @@ -0,0 +1,595 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// If you want, you can define WiFi settings globally in Eclipse Environment Variables +#ifndef WIFI_SSID +#define WIFI_SSID "PleaseEnterSSID" // Put your SSID and password here +#define WIFI_PWD "PleaseEnterPass" +#endif + +using namespace Graphics; + +namespace +{ +RenderQueue renderQueue(tft); +TcpServer server; + +/* + * This is an experimental feature where we store resouce information in flash memory + * instead of RAM. + * + * This works OK on the esp8266 and rp2040, but the esp32 has memory protection so blocks + * attempts to write to the program region. + */ +// #define RESOURCE_MAP_IN_FLASH + +#ifdef RESOURCE_MAP_IN_FLASH +#define RESOURCE_INDEX_SIZE 0x20000 +#define RESOURCE_CONST const +const uint8_t resource_index[RESOURCE_INDEX_SIZE] PROGMEM{}; +#else +#define RESOURCE_CONST +#endif + +struct ResourceInfo { + ResourceInfo(const LinkedObject* object, const void* data) : object(object), data(data) + { + } + + std::unique_ptr object; + const void* data; +}; + +class ResourceMap : public ObjectMap +{ +public: +#ifdef RESOURCE_MAP_IN_FLASH + ResourceMap() + { + auto resPtr = resource_index; + auto blockOffset = uint32_t(resource_index) % SPI_FLASH_SEC_SIZE; + if(blockOffset != 0) { + resPtr += SPI_FLASH_SEC_SIZE - blockOffset; + } + map = reinterpret_cast(resPtr); + } +#endif + + const void* reset(size_t size) + { + clear(); + +#ifdef RESOURCE_MAP_IN_FLASH + auto blockOffset = uint32_t(resource_index) % SPI_FLASH_SEC_SIZE; + + if(blockOffset + size > RESOURCE_INDEX_SIZE) { + debug_e("Resource too big"); + mapSize = 0; + return nullptr; + } + + mapSize = std::max(size, size_t(RESOURCE_INDEX_SIZE - blockOffset)); +#else + free(map); + auto buf = malloc(size); + map = static_cast(buf); + mapSize = buf ? size : 0; +#endif + + return static_cast(map); + } + + std::unique_ptr createStream() + { +#if defined(RESOURCE_MAP_IN_FLASH) + auto addr = flashmem_get_address(map); + auto part = *Storage::findPartition(Storage::Partition::Type::app); + Serial << part << endl; + if(!part.contains(addr)) { + debug_e("Bad resource index address %p, part %p, map %p", addr, part.address(), map); + assert(false); + return nullptr; + } + auto offset = addr - part.address(); + debug_i("Resource index @ %p, addr %p, offset %p", map, addr, offset); + return std::make_unique(part, offset, mapSize, true); +#else + return std::make_unique(map, mapSize, 0, false); +#endif + } + + const Font* getFont(const String& name) + { + auto obj = findObject(name); + if(obj) { + return static_cast(obj); + } + + auto data = findResource(name, "Font"); + if(!data) { + return nullptr; + } + + auto fontres = static_cast(data); + if(fontres->faces[0] == nullptr) { + Serial << "Font '" << name << "' has no typefaces" << endl; + return nullptr; + } + auto font = new ResourceFont(*fontres); + set(name, new ResourceInfo{font, data}); + + return font; + } + + const ImageObject* getImage(const String& name) + { + auto obj = findObject(name); + if(obj) { + return static_cast(obj); + } + + auto data = findResource(name, "Image"); + if(!data) { + return nullptr; + } + + auto& imgres = *reinterpret_cast(data); + // Serial << "bmOffset " << imgres.bmOffset << ", bmSize " << imgres.bmSize << ", width " << imgres.width + // << ", height " << imgres.height << ", format " << imgres.format << endl; + ImageObject* img; + if(imgres.getFormat() == PixelFormat::None) { + img = new BitmapObject(imgres); + if(!img->init()) { + debug_e("Bad bitmap"); + delete img; + return nullptr; + } + } else { + img = new RawImageObject(imgres); + } + // Serial << "Image size " << toString(img->getSize()) << endl; + set(name, new ResourceInfo{img, data}); + + return img; + } + +private: + const LinkedObject* findObject(const String& name) + { + auto info = get(name).getValue(); + if(!info) { + // Serial << "Resource '" << name << "' not found" << endl; + return nullptr; + } + return info->object.get(); + } + + const void* findResource(const String& name, const String& type) + { + if(!map) { + Serial << "Resource map not initialised" << endl; + return nullptr; + } + + auto data = (*map)[name]; + if(!data) { + Serial << type << " resource '" << name << "' not found" << endl; + return nullptr; + } + + // Serial << "Found " << name << endl; + return data.content_; + } + + struct Data { + uint8_t data; + }; + + RESOURCE_CONST FSTR::Map* map{}; + size_t mapSize{0}; +}; + +ResourceMap resourceMap; + +uint32_t hexValue(const String& s) +{ + return strtoul(s.c_str(), nullptr, 16); +} + +struct PropertySet; + +class CustomLabel : public Label +{ +public: + CustomLabel(const PropertySet& props); + + const Font* getFont() const override + { + return font; + } + + Color getColor(Element element) const override + { + switch(element) { + case Element::text: + return color; + case Element::back: + return back_color; + default: + return Label::getColor(element); + } + } + + Align getTextAlign() const override + { + return halign; + } + + Color back_color; + Color color; + const Font* font{}; + // fontstyle + uint8_t fontscale; + Align halign; +}; + +class CustomButton : public Button +{ +public: + CustomButton(const PropertySet& props); + + const Font* getFont() const override + { + return font; + } + + Color getColor(Element element) const override + { + switch(element) { + case Element::border: + return border; + case Element::text: + return color; + case Element::back: + return back_color; + default: + return Button::getColor(element); + } + } + + Color border; + Color back_color; + Color color; + const Font* font{}; + // fontstyle + uint8_t fontscale; +}; + +struct PropertySet { + void setProperty(const String& name, const String& value) + { + auto getColor = [&]() -> Color { return Color(hexValue(value)); }; + + if(name == "x") { + x = value.toInt(); + } else if(name == "y") { + y = value.toInt(); + } else if(name == "w") { + w = value.toInt(); + } else if(name == "h") { + h = value.toInt(); + } else if(name == "back_color") { + back_color = getColor(); + } else if(name == "border") { + border = getColor(); + } else if(name == "color") { + color = getColor(); + } else if(name == "line_width") { + line_width = value.toInt(); + } else if(name == "radius") { + radius = value.toInt(); + } else if(name == "font") { + font = value; + } else if(name == "text") { + text = uri_unescape(value); + } else if(name == "fontstyle") { + fontstyles = FontStyles(hexValue(value)); + // Serial << "FontStyle " << fontstyles << endl; + } else if(name == "fontscale") { + fontscale = value.toInt(); + } else if(name == "image") { + image = value; + } else if(name == "xoff") { + xoff = value.toInt(); + } else if(name == "yoff") { + yoff = value.toInt(); + } else if(name == "halign") { + halign = Align(value.toInt()); + } else if(name == "valign") { + valign = Align(value.toInt()); + } else if(name == "orient") { + orientation = Orientation(value.toInt()); + } else if(name == "size") { + size = value.toInt(); + } + } + + void draw(SceneObject& scene, const String& type) + { + if(type == "Rect") { + scene.drawRect(Pen(color, line_width), rect(), radius); + } else if(type == "FilledRect") { + scene.fillRect(color, rect(), radius); + } else if(type == "Ellipse") { + scene.drawEllipse(Pen(color, line_width), rect()); + } else if(type == "FilledEllipse") { + scene.fillEllipse(color, rect()); + } else if(type == "Text") { + // font + auto font = resourceMap.getFont(this->font); + TextBuilder textBuilder(scene.assets, rect()); + textBuilder.setFont(font); + textBuilder.setStyle(fontstyles); + textBuilder.setColor(color, back_color); + textBuilder.setScale(fontscale); + textBuilder.setTextAlign(halign); + textBuilder.setLineAlign(valign); + textBuilder.print(text); + textBuilder.commit(scene); + textBuilder.commit(scene); + } else if(type == "Image") { + auto img = resourceMap.getImage(image); + if(img) { + auto r = rect(); + scene.drawObject(*img, r, Point{xoff, yoff}); + } + } else if(type == "Button") { + scene.addObject(new CustomButton(*this)); + } else if(type == "Label") { + scene.addObject(new CustomLabel(*this)); + } + } + + Rect rect() const + { + return Rect(x, y, w, h); + } + + int16_t x = 0; + int16_t y = 0; + uint16_t w = 0; + uint16_t h = 0; + Color back_color = Color::Gray; + Color border = Color::White; + Color color = Color::Black; + uint16_t line_width = 1; + uint16_t radius = 0; + String font; + String text; + FontStyles fontstyles{}; + uint8_t fontscale = 1; + String image; + int16_t xoff = 0; + int16_t yoff = 0; + Align halign{}; + Align valign{}; + // Size command + Orientation orientation{}; + // resaddr + uint32_t size{0}; +}; + +CustomLabel::CustomLabel(const PropertySet& props) + : Label(props.rect(), props.text), back_color(props.back_color), color(props.color), fontscale(props.fontscale), + halign(props.halign) +{ + font = resourceMap.getFont(props.font); +} + +CustomButton::CustomButton(const PropertySet& props) + : Button(props.rect(), props.text), border(props.border), back_color(props.back_color), color(props.color), + fontscale(props.fontscale) +{ + font = resourceMap.getFont(props.font); +} + +void processLine(TcpClient& client, String& line) +{ + static SceneObject* scene; + static unsigned resourceLockCount; + static std::unique_ptr resourceStream; + static size_t resourceSize; + + if(resourceLockCount) { + Serial << "RENDER BUSY" << endl; + return; + } + + // Serial << line << endl; + + auto lineptr = line.c_str(); + if(lineptr[1] != ':') { + return; + } + char dataKind = lineptr[0]; + lineptr += 2; + + auto split = [&](char sep) -> String { + auto p = lineptr; + auto psep = strchr(lineptr, sep); + if(!psep) { + lineptr += strlen(lineptr); + return String(p); + } + lineptr = psep + 1; + return String(p, psep - p); + }; + + // Decode base64 data at lineptr in-situ, replacing line contents (will always be smaller) + auto decodeBinary = [&]() { + auto offset = lineptr - line.c_str(); + auto charCount = line.length() - offset; + auto bufptr = line.begin(); + auto bytecount = base64_decode(charCount, lineptr, charCount, reinterpret_cast(bufptr)); + line.setLength(bytecount); + }; + + if(dataKind == 'b') { + if(resourceStream) { + decodeBinary(); + resourceSize += resourceStream->print(line); + } + return; + } + + PropertySet props; + String instr = split(';'); + // Serial << dataKind << " : " << instr << endl; + String tag; + String value; + while(*lineptr) { + tag = split('='); + value = split(';'); + // Serial << " " << tag << " = " << value << endl; + props.setProperty(tag, value); + } + switch(dataKind) { + case '@': + if(instr == "size") { +#ifdef ENABLE_VIRTUAL_SCREEN + tft.setDisplaySize(props.w, props.h, props.orientation); +#else + tft.setOrientation(props.orientation); +#endif + break; + } + if(instr == "clear") { + delete scene; + scene = new SceneObject(tft.getSize()); + scene->clear(); + break; + } + if(instr == "render") { + renderQueue.render(scene, [&](SceneObject* scene) { + Serial << "Render done" << endl; + delete scene; + --resourceLockCount; + }); + scene = nullptr; + ++resourceLockCount; + break; + } + if(instr == "resaddr") { + delete scene; + scene = nullptr; + auto buffer = resourceMap.reset(props.size); + String line; + line += "@:"; + if (sizeof(uintptr_t) == 8) { + line += "ptr64=;"; + } + line += "addr=0x"; + line += String(uintptr_t(buffer), HEX); + line += ";\n"; + client.sendString(line); + break; + } + if(instr == "index") { + resourceStream = resourceMap.createStream(); + resourceSize = 0; + Serial << "** Writing index" << endl; + break; + } + if(instr == "bitmap") { + auto part = Storage::findPartition(F("resource")); + if(!part) { + Serial << "Resource partition not found" << endl; + resourceStream.reset(); + break; + } + resourceStream = std::make_unique(part, Storage::Mode::BlockErase); + resourceSize = 0; + Serial << "** Writing resource bitmap" << endl; + break; + } + if(instr == "end") { + if(resourceStream) { + Serial << "** Resource written, " << resourceSize << " bytes" << endl; + resourceStream.reset(); + resourceSize = 0; + client.sendString("@:OK\n"); + } + break; + } + break; + + case 'i': { + if(!scene) { + Serial << "NO SCENE!"; + break; + } + props.draw(*scene, instr); + break; + } + } +} + +bool processClientData(TcpClient& client, char* data, int size) +{ + static String line; + + while(size) { + auto p = (const char*)memchr(data, '\n', size); + auto n = p ? (p - data) : size; + line.concat(data, n); + if(!p) { + break; + } + ++n; + data += n; + size -= n; + + processLine(client, line); + line.setLength(0); + } + + return true; +} + +void gotIP(IpAddress ip, IpAddress netmask, IpAddress gateway) +{ + server.setClientReceiveHandler(processClientData); + server.listen(23); + Serial << _F("\r\n=== TCP server started ===") << endl; +} + +} // namespace + +void init() +{ + Serial.begin(SERIAL_BAUD_RATE); // 115200 by default + Serial.systemDebugOutput(true); // Allow debug output to serial + + Serial << _F("Display start") << endl; + initDisplay(); + + auto part = Storage::findPartition(F("resource")); + Serial << part << endl; + Graphics::Resource::init(new Storage::PartitionStream(part)); + + WifiStation.enable(true); + WifiStation.config(WIFI_SSID, WIFI_PWD); + WifiAccessPoint.enable(false); + + WifiEvents.onStationGotIP(gotIP); +} diff --git a/samples/Graphic_Editor/component.mk b/samples/Graphic_Editor/component.mk new file mode 100644 index 00000000..0108b05e --- /dev/null +++ b/samples/Graphic_Editor/component.mk @@ -0,0 +1,2 @@ +COMPONENT_DEPENDS := Graphics Ota +HWCONFIG := graphic-editor diff --git a/samples/Graphic_Editor/graphic-editor.hw b/samples/Graphic_Editor/graphic-editor.hw new file mode 100644 index 00000000..e3c51714 --- /dev/null +++ b/samples/Graphic_Editor/graphic-editor.hw @@ -0,0 +1,13 @@ +{ + "name": "Graphic Editor", + "base_config": "standard", + "options": ["2m"], + "partitions": { + "resource": { + "address": "0x00100000", + "size": "960K", + "type": "user", + "subtype": 1 + } + } +} \ No newline at end of file diff --git a/samples/Graphic_Editor/projects/font-test.ged b/samples/Graphic_Editor/projects/font-test.ged new file mode 100644 index 00000000..36307e9d --- /dev/null +++ b/samples/Graphic_Editor/projects/font-test.ged @@ -0,0 +1,41 @@ +{ + "project": { + "width": 320, + "height": 240, + "scale": 2, + "orientation": 0, + "grid_alignment": 8 + }, + "fonts": { + "font1": { + "family": "Droid Sans", + "size": 18, + "mono": false, + "normal": "Regular", + "bold": "", + "italic": "", + "boldItalic": "" + } + }, + "layout": { + "Text_1": { + "type": "Text", + "x": 8, + "y": 8, + "w": 304, + "h": 224, + "back_color": "black", + "color": "orange", + "font": "font1", + "text": "Text\nsupport\n\nis intended\nto assist with sizing controls.\n\nIt's unlikely to match actual display though, and some styles aren't supported.", + "halign": "Centre", + "valign": "Middle", + "fontstyle": [ + "Italic", + "DotMatrix", + "Bold" + ], + "fontscale": 1 + } + } +} \ No newline at end of file diff --git a/samples/Graphic_Editor/projects/gui.ged b/samples/Graphic_Editor/projects/gui.ged new file mode 100644 index 00000000..1795d68a --- /dev/null +++ b/samples/Graphic_Editor/projects/gui.ged @@ -0,0 +1,369 @@ +{ + "project": { + "width": 320, + "height": 240, + "scale": 1.7, + "orientation": 0, + "grid_alignment": 5 + }, + "fonts": { + "font1": { + "family": "Noto Sans Mono", + "size": 10, + "mono": true, + "normal": "Regular", + "bold": "", + "italic": "", + "boldItalic": "" + } + }, + "layout": { + "FilledRect_1": { + "type": "FilledRect", + "x": 0, + "y": 0, + "w": 200, + "h": 48, + "color": "olive", + "radius": 0 + }, + "FilledRect_2": { + "type": "FilledRect", + "x": 204, + "y": 0, + "w": 118, + "h": 48, + "color": "red", + "radius": 0 + }, + "FilledRect_3": { + "type": "FilledRect", + "x": 104, + "y": 50, + "w": 216, + "h": 28, + "color": "darkcyan", + "radius": 0 + }, + "FilledRect_4": { + "type": "FilledRect", + "x": 0, + "y": 50, + "w": 100, + "h": 28, + "color": "darkcyan", + "radius": 0 + }, + "FilledRect_5": { + "type": "FilledRect", + "x": 0, + "y": 82, + "w": 100, + "h": 28, + "color": "darkcyan", + "radius": 0 + }, + "FilledRect_6": { + "type": "FilledRect", + "x": 104, + "y": 82, + "w": 216, + "h": 28, + "color": "darkcyan", + "radius": 0 + }, + "FilledRect_7": { + "type": "FilledRect", + "x": 0, + "y": 146, + "w": 100, + "h": 28, + "color": "darkcyan", + "radius": 0 + }, + "FilledRect_8": { + "type": "FilledRect", + "x": 104, + "y": 146, + "w": 216, + "h": 28, + "color": "darkcyan", + "radius": 0 + }, + "FilledRect_9": { + "type": "FilledRect", + "x": 0, + "y": 114, + "w": 100, + "h": 28, + "color": "darkcyan", + "radius": 0 + }, + "FilledRect_10": { + "type": "FilledRect", + "x": 104, + "y": 114, + "w": 216, + "h": 28, + "color": "darkcyan", + "radius": 0 + }, + "FilledRect_11": { + "type": "FilledRect", + "x": 0, + "y": 210, + "w": 100, + "h": 28, + "color": "darkcyan", + "radius": 0 + }, + "FilledRect_12": { + "type": "FilledRect", + "x": 104, + "y": 178, + "w": 216, + "h": 28, + "color": "darkcyan", + "radius": 0 + }, + "FilledRect_13": { + "type": "FilledRect", + "x": 0, + "y": 178, + "w": 100, + "h": 28, + "color": "darkcyan", + "radius": 0 + }, + "FilledRect_14": { + "type": "FilledRect", + "x": 104, + "y": 210, + "w": 216, + "h": 28, + "color": "darkcyan", + "radius": 0 + }, + "Text_1": { + "type": "Text", + "x": 5, + "y": 5, + "w": 380, + "h": 40, + "back_color": "#00000000", + "color": "white", + "font": "font1", + "text": "Sming is the framework", + "halign": "Left", + "valign": "Top", + "fontstyle": [ + "DotMatrix" + ], + "fontscale": 3 + }, + "Text_2": { + "type": "Text", + "x": 15, + "y": 56, + "w": 60, + "h": 20, + "back_color": "#00000000", + "color": "white", + "font": "font1", + "text": "a0", + "halign": "Left", + "valign": "Top", + "fontstyle": [ + "DotMatrix" + ], + "fontscale": 2 + }, + "Text_3": { + "type": "Text", + "x": 119, + "y": 56, + "w": 60, + "h": 20, + "back_color": "#00000000", + "color": "white", + "font": "font1", + "text": "0", + "halign": "Left", + "valign": "Top", + "fontstyle": [ + "DotMatrix" + ], + "fontscale": 2 + }, + "Text_4": { + "type": "Text", + "x": 15, + "y": 88, + "w": 60, + "h": 20, + "back_color": "#00000000", + "color": "white", + "font": "font1", + "text": "b0", + "halign": "Left", + "valign": "Top", + "fontstyle": [ + "DotMatrix" + ], + "fontscale": 2 + }, + "Text_5": { + "type": "Text", + "x": 119, + "y": 88, + "w": 60, + "h": 20, + "back_color": "#00000000", + "color": "white", + "font": "font1", + "text": "0", + "halign": "Left", + "valign": "Top", + "fontstyle": [ + "DotMatrix" + ], + "fontscale": 2 + }, + "Text_6": { + "type": "Text", + "x": 15, + "y": 120, + "w": 60, + "h": 20, + "back_color": "#00000000", + "color": "white", + "font": "font1", + "text": "c0", + "halign": "Left", + "valign": "Top", + "fontstyle": [ + "DotMatrix" + ], + "fontscale": 2 + }, + "Text_7": { + "type": "Text", + "x": 119, + "y": 120, + "w": 60, + "h": 20, + "back_color": "#00000000", + "color": "white", + "font": "font1", + "text": "0", + "halign": "Left", + "valign": "Top", + "fontstyle": [ + "DotMatrix" + ], + "fontscale": 2 + }, + "Text_8": { + "type": "Text", + "x": 15, + "y": 152, + "w": 60, + "h": 20, + "back_color": "#00000000", + "color": "white", + "font": "font1", + "text": "d0", + "halign": "Left", + "valign": "Top", + "fontstyle": [ + "DotMatrix" + ], + "fontscale": 2 + }, + "Text_9": { + "type": "Text", + "x": 119, + "y": 152, + "w": 60, + "h": 20, + "back_color": "#00000000", + "color": "white", + "font": "font1", + "text": "0", + "halign": "Left", + "valign": "Top", + "fontstyle": [ + "DotMatrix" + ], + "fontscale": 2 + }, + "Text_10": { + "type": "Text", + "x": 15, + "y": 184, + "w": 60, + "h": 20, + "back_color": "#00000000", + "color": "white", + "font": "font1", + "text": "e0", + "halign": "Left", + "valign": "Top", + "fontstyle": [ + "DotMatrix" + ], + "fontscale": 2 + }, + "Text_11": { + "type": "Text", + "x": 119, + "y": 184, + "w": 60, + "h": 20, + "back_color": "#00000000", + "color": "white", + "font": "font1", + "text": "0", + "halign": "Left", + "valign": "Top", + "fontstyle": [ + "DotMatrix" + ], + "fontscale": 2 + }, + "Text_12": { + "type": "Text", + "x": 15, + "y": 216, + "w": 60, + "h": 20, + "back_color": "#00000000", + "color": "white", + "font": "font1", + "text": "f0", + "halign": "Left", + "valign": "Top", + "fontstyle": [ + "DotMatrix" + ], + "fontscale": 2 + }, + "Text_13": { + "type": "Text", + "x": 119, + "y": 216, + "w": 60, + "h": 20, + "back_color": "#00000000", + "color": "white", + "font": "font1", + "text": "0", + "halign": "Left", + "valign": "Top", + "fontstyle": [ + "DotMatrix" + ], + "fontscale": 2 + } + } +} \ No newline at end of file diff --git a/samples/Graphic_Editor/projects/heatcon.ged b/samples/Graphic_Editor/projects/heatcon.ged new file mode 100644 index 00000000..be94ab86 --- /dev/null +++ b/samples/Graphic_Editor/projects/heatcon.ged @@ -0,0 +1,302 @@ +{ + "project": { + "width": 320, + "height": 240, + "scale": 2.1, + "orientation": 0, + "grid_alignment": 5 + }, + "fonts": { + "control_font": { + "family": "Droid Sans", + "size": 18, + "mono": false, + "normal": "Regular", + "bold": "Bold", + "italic": "", + "boldItalic": "" + }, + "small_font": { + "family": "Droid Sans", + "size": 14, + "mono": false, + "normal": "Regular", + "bold": "Bold", + "italic": "", + "boldItalic": "" + } + }, + "images": { + "sun_raw": { + "source": "heatcon/sun.png", + "format": "RGB565", + "width": 50, + "height": 50 + }, + "house_raw": { + "source": "heatcon/house.png", + "format": "RGB565", + "width": 50, + "height": 60 + }, + "tap_raw": { + "source": "heatcon/faucet.png", + "format": "RGB565", + "width": 50, + "height": 60 + }, + "grid_raw": { + "source": "heatcon/grid.png", + "format": "RGB565", + "width": 50, + "height": 60 + } + }, + "layout": { + "boostButton": { + "type": "Button", + "x": 5, + "y": 5, + "w": 80, + "h": 50, + "back_color": "grey", + "border": "white", + "color": "black", + "font": "control_font", + "text": "Boost", + "fontscale": 1, + "fontstyle": [] + }, + "fanButton": { + "type": "Button", + "x": 235, + "y": 5, + "w": 80, + "h": 50, + "back_color": "grey", + "border": "white", + "color": "black", + "font": "control_font", + "text": "Fan", + "fontscale": 1, + "fontstyle": [] + }, + "boostState": { + "type": "Label", + "x": 90, + "y": 5, + "w": 120, + "h": 50, + "back_color": "black", + "color": "white", + "font": "control_font", + "text": "Boost State", + "halign": "Centre", + "fontscale": 1, + "fontstyle": [] + }, + "gridEnergy": { + "type": "Label", + "x": 0, + "y": 90, + "w": 60, + "h": 26, + "back_color": "black", + "color": "lightgreen", + "font": "control_font", + "text": "15.0", + "halign": "Centre", + "fontscale": 1, + "fontstyle": [] + }, + "Image_1": { + "type": "Image", + "x": 5, + "y": 120, + "w": 50, + "h": 60, + "image": "grid_raw", + "xoff": 0, + "yoff": 0 + }, + "gridPower": { + "type": "Label", + "x": 0, + "y": 180, + "w": 60, + "h": 26, + "back_color": "black", + "color": "lightgreen", + "font": "control_font", + "text": "5000", + "halign": "Centre", + "fontscale": 1, + "fontstyle": [] + }, + "pvEnergy": { + "type": "Label", + "x": 65, + "y": 90, + "w": 60, + "h": 26, + "back_color": "black", + "color": "lightgreen", + "font": "control_font", + "text": "8.0", + "halign": "Centre", + "fontscale": 1, + "fontstyle": [] + }, + "Image_2": { + "type": "Image", + "x": 70, + "y": 120, + "w": 50, + "h": 60, + "image": "sun_raw", + "xoff": 0, + "yoff": 0 + }, + "pvPower": { + "type": "Label", + "x": 65, + "y": 180, + "w": 60, + "h": 26, + "back_color": "black", + "color": "lightgreen", + "font": "control_font", + "text": "5000", + "halign": "Centre", + "fontscale": 1, + "fontstyle": [] + }, + "batteryPower": { + "type": "Label", + "x": 130, + "y": 180, + "w": 60, + "h": 26, + "back_color": "black", + "color": "lightgreen", + "font": "control_font", + "text": "5000", + "halign": "Centre", + "fontscale": 1, + "fontstyle": [] + }, + "Image_4": { + "type": "Image", + "x": 200, + "y": 120, + "w": 50, + "h": 60, + "image": "house_raw", + "xoff": 0, + "yoff": 0 + }, + "loadPower": { + "type": "Label", + "x": 195, + "y": 180, + "w": 60, + "h": 26, + "back_color": "black", + "color": "lightgreen", + "font": "control_font", + "text": "1234", + "halign": "Centre", + "fontscale": 1, + "fontstyle": [] + }, + "loadEnergy": { + "type": "Label", + "x": 195, + "y": 90, + "w": 60, + "h": 26, + "back_color": "black", + "color": "lightgreen", + "font": "control_font", + "text": "7.6", + "halign": "Centre", + "fontscale": 1, + "fontstyle": [] + }, + "waterEnergy": { + "type": "Label", + "x": 260, + "y": 90, + "w": 60, + "h": 26, + "back_color": "black", + "color": "lightgreen", + "font": "control_font", + "text": "3.4", + "halign": "Centre", + "fontscale": 1, + "fontstyle": [] + }, + "Image_5": { + "type": "Image", + "x": 265, + "y": 120, + "w": 50, + "h": 60, + "image": "tap_raw", + "xoff": 0, + "yoff": 0 + }, + "waterPower": { + "type": "Label", + "x": 260, + "y": 180, + "w": 60, + "h": 26, + "back_color": "black", + "color": "lightgreen", + "font": "control_font", + "text": "1888", + "halign": "Centre", + "fontscale": 1, + "fontstyle": [] + }, + "battery": { + "type": "FilledRect", + "x": 135, + "y": 100, + "w": 50, + "h": 80, + "color": "orange", + "radius": 2 + }, + "Label_1": { + "type": "Label", + "x": 0, + "y": 213, + "w": 200, + "h": 26, + "back_color": "black", + "color": "white", + "font": "control_font", + "text": "messages....", + "halign": "Centre", + "fontscale": 1, + "fontstyle": [] + }, + "timeClock": { + "type": "Label", + "x": 254, + "y": 216, + "w": 64, + "h": 24, + "back_color": "black", + "color": "white", + "font": "control_font", + "text": "18:37", + "halign": "Centre", + "fontscale": 1, + "fontstyle": [] + } + } +} \ No newline at end of file diff --git a/samples/Graphic_Editor/projects/heatcon/faucet.png b/samples/Graphic_Editor/projects/heatcon/faucet.png new file mode 100644 index 00000000..b332e52c Binary files /dev/null and b/samples/Graphic_Editor/projects/heatcon/faucet.png differ diff --git a/samples/Graphic_Editor/projects/heatcon/grid.png b/samples/Graphic_Editor/projects/heatcon/grid.png new file mode 100644 index 00000000..024aea37 Binary files /dev/null and b/samples/Graphic_Editor/projects/heatcon/grid.png differ diff --git a/samples/Graphic_Editor/projects/heatcon/house.png b/samples/Graphic_Editor/projects/heatcon/house.png new file mode 100644 index 00000000..3d18ef83 Binary files /dev/null and b/samples/Graphic_Editor/projects/heatcon/house.png differ diff --git a/samples/Graphic_Editor/projects/heatcon/sun.png b/samples/Graphic_Editor/projects/heatcon/sun.png new file mode 100644 index 00000000..ea330a4e Binary files /dev/null and b/samples/Graphic_Editor/projects/heatcon/sun.png differ diff --git a/samples/Graphic_Editor/projects/images.ged b/samples/Graphic_Editor/projects/images.ged new file mode 100644 index 00000000..dfb4c4a9 --- /dev/null +++ b/samples/Graphic_Editor/projects/images.ged @@ -0,0 +1,69 @@ +{ + "project": { + "width": 320, + "height": 240, + "scale": 2, + "orientation": 0, + "grid_alignment": 8 + }, + "images": { + "image1": { + "source": "sming.bmp", + "format": "RGB565", + "width": 128, + "height": 128 + } + }, + "layout": { + "Image_1": { + "type": "Image", + "x": 8, + "y": 8, + "w": 72, + "h": 80, + "image": "image1", + "xoff": 27, + "yoff": 24 + }, + "Image_2": { + "type": "Image", + "x": 96, + "y": 56, + "w": 128, + "h": 128, + "image": "image1", + "xoff": 0, + "yoff": 0 + }, + "Image_3": { + "type": "Image", + "x": 8, + "y": 152, + "w": 72, + "h": 80, + "image": "image1", + "xoff": 27, + "yoff": 24 + }, + "Image_4": { + "type": "Image", + "x": 240, + "y": 8, + "w": 72, + "h": 80, + "image": "image1", + "xoff": 27, + "yoff": 24 + }, + "Image_5": { + "type": "Image", + "x": 240, + "y": 152, + "w": 72, + "h": 80, + "image": "image1", + "xoff": 27, + "yoff": 24 + } + } +} \ No newline at end of file diff --git a/samples/Graphic_Editor/projects/sming.bmp b/samples/Graphic_Editor/projects/sming.bmp new file mode 100644 index 00000000..b566de5c Binary files /dev/null and b/samples/Graphic_Editor/projects/sming.bmp differ diff --git a/src/Arch/Host/Virtual.cpp b/src/Arch/Host/Virtual.cpp index ec1338bc..1e81b0d1 100644 --- a/src/Arch/Host/Virtual.cpp +++ b/src/Arch/Host/Virtual.cpp @@ -559,6 +559,16 @@ bool Virtual::begin(const String& ipaddr, uint16_t port, uint16_t width, uint16_ return sizeChanged(); } +bool Virtual::setDisplaySize(uint16_t width, uint16_t height, Orientation orientation) +{ + if(nativeSize.w == width && nativeSize.h == height && orientation == this->orientation) { + return true; + } + nativeSize = Size{width, height}; + this->orientation = orientation; + return sizeChanged(); +} + bool Virtual::sizeChanged() { CommandList list(addrWindow, 32); diff --git a/src/Object.cpp b/src/Object.cpp index 857b3f9e..c3bf0e74 100644 --- a/src/Object.cpp +++ b/src/Object.cpp @@ -50,6 +50,7 @@ String Object::getTypeStr() const Renderer* ReferenceObject::createRenderer(const Location& location) const { Location loc(location); + loc.source += sourceOffset; auto& r = loc.dest; r += pos.topLeft(); r.w -= pos.x; diff --git a/src/include/Arch/Host/Graphics/Display/Virtual.h b/src/include/Arch/Host/Graphics/Display/Virtual.h index 2c14fe52..c32be7c6 100644 --- a/src/include/Arch/Host/Graphics/Display/Virtual.h +++ b/src/include/Arch/Host/Graphics/Display/Virtual.h @@ -64,6 +64,8 @@ class Virtual : public AbstractDisplay return mode; } + bool setDisplaySize(uint16_t width, uint16_t height, Orientation orientation); + /* Device */ String getName() const override diff --git a/src/include/Graphics/Object.h b/src/include/Graphics/Object.h index e30840b8..2af6816b 100644 --- a/src/include/Graphics/Object.h +++ b/src/include/Graphics/Object.h @@ -155,6 +155,11 @@ class ReferenceObject : public ObjectTemplate { } + ReferenceObject(const Object& object, const Rect& pos, const Point& sourceOffset, const Blend* blend = nullptr) + : object(object), pos(pos), sourceOffset(sourceOffset), blend{blend} + { + } + void write(MetaWriter& meta) const override { meta.write("pos", pos); @@ -165,6 +170,7 @@ class ReferenceObject : public ObjectTemplate const Object& object; Rect pos; + Point sourceOffset; const Blend* blend; }; diff --git a/src/include/Graphics/resource.h b/src/include/Graphics/resource.h index c4c7a194..71cdc605 100644 --- a/src/include/Graphics/resource.h +++ b/src/include/Graphics/resource.h @@ -90,7 +90,7 @@ struct TypefaceResource { const GlyphBlock* blocks; }; -struct FontResource { +struct __attribute__((packed)) FontResource { const FSTR::String* name; uint8_t yAdvance; uint8_t descent;