diff --git a/app_state.py b/app_state.py index 632e798..f3e1ecd 100644 --- a/app_state.py +++ b/app_state.py @@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it. import configparser import os -VERSION = "0.0.0.130" +VERSION = "0.0.0.131" PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties") DEFAULT_GRID_SIZE = 10 DEFAULT_ADD_TIMESTAMP = True diff --git a/main.py b/main.py index a903d0d..6692f0b 100644 --- a/main.py +++ b/main.py @@ -70,6 +70,9 @@ PALETTE_REQUESTED_ROWS = 5 # Below this the shortfall is deep enough to shrink the tree past a usable few rows, and # ttk has no way to hold a pane at a floor once the room is simply not there. MIN_WINDOW_HEIGHT = 300 +# The editor itself, plus room for the Page Editor's widgets/properties pane. +DEFAULT_WINDOW_WIDTH = 880 + ox_page_view.RIGHT_PANE_WIDTH +DEFAULT_WINDOW_HEIGHT = 600 # How many visited views Back and Forward can walk through. A session touches few of # them; the cap only stops an unbounded list. @@ -368,11 +371,34 @@ def show_ox_page_view(comp): on_close=dismiss_ox_page_view, ) ox_page_panel.frame.pack(fill=tk.BOTH, expand=True) + _show_page_editor_tools(ox_page_panel) record_visit(VIEW_PAGE, comp) update_zoom_label() app_state.set_status(f"Page Editor: {comp.get('name', '?')}") +def _show_page_editor_tools(panel): + """Lays the Page Editor's toggles out on the toolbar, after a separator of their own.""" + _hide_page_editor_tools() + ttk.Separator(page_editor_tools, orient=tk.VERTICAL).pack( + side=tk.LEFT, fill=tk.Y, padx=8, pady=2) + for index, (icon, tip, variable, command) in enumerate(panel.toolbar_tools()): + if variable is not None: + button = ttk.Checkbutton(page_editor_tools, text=icon, width=3, + style="Toolbutton", variable=variable, command=command) + else: + button = ttk.Button(page_editor_tools, text=icon, width=3, command=command) + button.pack(side=tk.LEFT, padx=(3 if index else 0, 0)) + _ToolTip(button, tip) + page_editor_tools.pack(side=tk.LEFT) + + +def _hide_page_editor_tools(): + for child in page_editor_tools.winfo_children(): + child.destroy() + page_editor_tools.pack_forget() + + def dismiss_ox_page_view(restore_canvas=True): """Takes the OxPage editor down and gives the pane back.""" global ox_page_panel @@ -381,6 +407,7 @@ def dismiss_ox_page_view(restore_canvas=True): ox_page_panel.destroy() ox_page_panel = None + _hide_page_editor_tools() update_zoom_label() if restore_canvas: @@ -2386,7 +2413,7 @@ def _live_tree_expand(comp_id, tree_item_id): window = tk.Tk() app_state.window = window window.title(app_state.base_window_title()) -window.geometry("950x600") +window.geometry(f"{DEFAULT_WINDOW_WIDTH}x{DEFAULT_WINDOW_HEIGHT}") # Width is left alone; only the height needs a floor, to keep the tree pane off zero. window.minsize(1, MIN_WINDOW_HEIGHT) @@ -2588,6 +2615,9 @@ _btn_print = ttk.Button(toolbar, text="🖨", width=3, command=wiresheet.print_s _btn_print.pack(side=tk.LEFT) _ToolTip(_btn_print, "Print Wiresheet (Ctrl+P)") +# Filled with the Page Editor's own tools while it is open, and empty (unpacked) otherwise. +page_editor_tools = ttk.Frame(toolbar) + paned_window = ttk.PanedWindow(window, orient=tk.HORIZONTAL) paned_window.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) diff --git a/ox_page_view.py b/ox_page_view.py index 9713b4d..857eeb0 100644 --- a/ox_page_view.py +++ b/ox_page_view.py @@ -53,6 +53,8 @@ DEFAULT_ZOOM = 0.5 ZOOM_LEVELS = (0.25, 0.33, 0.5, 0.67, 0.8, 1.0, 1.25, 1.5, 2.0, 3.0) CANVAS_PADDING = 16 +# About what the widget tree / properties pane asks for (300px sheet + scrollbar). +RIGHT_PANE_WIDTH = 320 WIDGET_OUTLINE_WIDTH = 1 SELECTED_OUTLINE = "#1f4e79" @@ -146,8 +148,12 @@ GRID_STEP = 10 # device pixels GRID_MIN_SPACING = 4 # canvas pixels; a denser grid is left out rather than drawn solid GRID_SHADE = 0.82 # grid line = page colour scaled by this (lifted on dark pages) -# Whether the 10x10 grid is drawn, held for the session like the Sedona sheet's toggles. -show_grid = True +# Page Editor toggles, held for the session like the Sedona sheet's: whether the page +# can be changed (edit mode, which is also when the 10x10 grid is drawn), whether a +# drag lands on the grid, and whether the tree/properties pane shows. +edit_mode = False +snap_to_grid = True +show_right_pane = True def _grid_colour(page_hex): @@ -200,6 +206,7 @@ class OxPageViewPanel: self._build_header() self._build_body() self._build_footer() + self._apply_right_pane() self.refresh() @property @@ -233,10 +240,9 @@ class OxPageViewPanel: tk.Label(header, textvariable=self._widget_count_var, font=plain_font, fg="#555555", bg=HEADER_BG).pack(side=tk.RIGHT) - self._grid_var = tk.BooleanVar(value=show_grid) - tk.Checkbutton(header, text="Grid", variable=self._grid_var, font=plain_font, - bg=HEADER_BG, activebackground=HEADER_BG, - command=self._on_grid_toggled).pack(side=tk.RIGHT, padx=(0, 12)) + self._edit_var = tk.BooleanVar(value=edit_mode) + self._snap_var = tk.BooleanVar(value=snap_to_grid) + self._right_pane_var = tk.BooleanVar(value=show_right_pane) def _build_body(self): body = ttk.PanedWindow(self.frame, orient=tk.HORIZONTAL) @@ -249,12 +255,15 @@ class OxPageViewPanel: # Workbench's layout: the widget tree above, the selected widget's sheet below. side = ttk.PanedWindow(body, orient=tk.VERTICAL) body.add(side, weight=0) + self._body = body + self._side = side tree_frame = ttk.Frame(side) self._build_widget_tree(tree_frame) side.add(tree_frame, weight=1) self._props = OxWidgetPropertiesPanel(side, self._commit_props, self._repaint_all) + self._props.set_editable(self._edit_var.get()) side.add(self._props.frame, weight=3) def _build_widget_tree(self, parent): @@ -319,7 +328,7 @@ class OxPageViewPanel: self._painter = WidgetPainter(c, sx, sy, z, bg_colour) self._painter.page_image(_get_prop(self.comp, "imageFileName", ""), self._screen_w, self._screen_h) - if self._grid_var.get(): + if self._edit_var.get(): self._draw_grid(sx, sy, _grid_colour(bg_colour)) if self._frame_image: @@ -364,10 +373,48 @@ class OxPageViewPanel: for gy in range(GRID_STEP, self._screen_h, GRID_STEP): c.create_line(sx, sy + gy * z, right, sy + gy * z, fill=colour, tags=("grid",)) - def _on_grid_toggled(self): - global show_grid - show_grid = self._grid_var.get() + def _on_edit_toggled(self): + global edit_mode + edit_mode = self._edit_var.get() + if not edit_mode: + self._props.flush_edits() + self._props.set_editable(edit_mode) self.refresh() + app_state.set_status("Page Editor: edit mode — the page can be changed." + if edit_mode else "Page Editor: view mode — nothing can be changed.") + + def _on_snap_toggled(self): + global snap_to_grid + snap_to_grid = self._snap_var.get() + + def _on_right_pane_toggled(self): + global show_right_pane + show_right_pane = self._right_pane_var.get() + self._apply_right_pane() + + def _apply_right_pane(self): + """Takes the widget tree and properties sheet out of the split, or puts them back.""" + showing = str(self._side) in self._body.panes() + if self._right_pane_var.get() and not showing: + self._body.add(self._side, weight=0) + elif not self._right_pane_var.get() and showing: + self._props.flush_edits() + self._body.forget(self._side) + + # ── main toolbar ────────────────────────────────────────────── + + def toolbar_tools(self): + """The Page Editor's own toolbar buttons: (icon, tooltip, variable, command). + + `main` lays them out on its toolbar while this editor is open and takes them + down with it. A button with a variable is a toggle; add new tools here. + """ + return [ + ("✏", "Edit Mode", self._edit_var, self._on_edit_toggled), + ("⌗", "Snap to Grid", self._snap_var, self._on_snap_toggled), + ("◨", "Widgets / Properties Pane", self._right_pane_var, + self._on_right_pane_toggled), + ] def _draw_widget(self, widget): """Paints the widget as the device would, then lays an empty box over it. @@ -526,9 +573,12 @@ class OxPageViewPanel: widget = self._widget_at(cx, cy) if widget is not None: self._select_widget(widget) - self._drag["tag"] = self._widget_items[widget] - self._drag["x"] = cx - self._drag["y"] = cy + if not self._edit_var.get(): + self._drag["tag"] = None + return + box = self._widget_boxes[widget] + self._drag.update(tag=self._widget_items[widget], x=cx, y=cy, + origin=(box[0], box[1]), pos=(box[0], box[1])) else: self._select_widget(None) self._drag["tag"] = None @@ -538,19 +588,27 @@ class OxPageViewPanel: return c = self._canvas + z = self._zoom cx = c.canvasx(event.x) cy = c.canvasy(event.y) - dx = cx - self._drag["x"] - dy = cy - self._drag["y"] + # Worked out from where the drag began rather than step by step, so snapping + # never accumulates rounding and the widget lands exactly where it is drawn. + ox, oy = self._drag["origin"] + new_x = ox + (cx - self._drag["x"]) / z + new_y = oy + (cy - self._drag["y"]) / z + if self._snap_var.get(): + new_x = round(new_x / GRID_STEP) * GRID_STEP + new_y = round(new_y / GRID_STEP) * GRID_STEP + new_x = int(round(max(0, min(new_x, self._screen_w)))) + new_y = int(round(max(0, min(new_y, self._screen_h)))) - tag = self._drag["tag"] - c.move(tag, dx, dy) + px, py = self._drag["pos"] + if (new_x, new_y) != (px, py): + c.move(self._drag["tag"], (new_x - px) * z, (new_y - py) * z) + self._drag["pos"] = (new_x, new_y) c.delete("selection_handle") - self._drag["x"] = cx - self._drag["y"] = cy - def _on_canvas_release(self, event): tag = self._drag["tag"] if not tag: @@ -562,17 +620,9 @@ class OxPageViewPanel: return self._drag["tag"] = None - c = self._canvas - z = self._zoom - bbox = c.bbox(f"{tag} && ox_widget_rect") moved = False - if bbox: - new_x = int((bbox[0] - self._screen_canvas_x * z) / z) - new_y = int((bbox[1] - self._screen_canvas_y * z) / z) - - new_x = max(0, min(new_x, self._screen_w)) - new_y = max(0, min(new_y, self._screen_h)) - + new_x, new_y = self._drag["pos"] + if (new_x, new_y) != self._drag["origin"]: moved = self._commit_props(widget, {"x": str(new_x), "y": str(new_y)}) # A commit re-renders through refresh(); a drag that went nowhere still has to diff --git a/ox_widget_properties.py b/ox_widget_properties.py index fb1d16f..c580c6e 100644 --- a/ox_widget_properties.py +++ b/ox_widget_properties.py @@ -574,6 +574,10 @@ class OxWidgetPropertiesPanel: self.repaint = repaint self.comp = None self.rows = [] + # Off until the Page Editor's edit mode is switched on: every field is then shown + # but none of them takes input, and no write reaches `commit`. + self.editable = False + self._controls = [] # (widget, state when editable) for every input on the sheet self.frame = ttk.Frame(parent) self._name_font = tkfont.Font(family="Segoe UI", size=9) @@ -637,6 +641,7 @@ class OxWidgetPropertiesPanel: for child in self._sheet.winfo_children(): child.destroy() self.rows = [] + self._controls = [] self._status_var.set("") if self.comp is None: @@ -649,9 +654,36 @@ class OxWidgetPropertiesPanel: for grid_row, prop in enumerate(build_props(self.comp)): self.rows.append(self._build_row(grid_row, prop)) + self._apply_editable() self.reload() self._canvas.yview_moveto(0) + def set_editable(self, editable): + """Edit mode on or off: the fields take input, or only show their values.""" + self.editable = editable + self._apply_editable() + + def _apply_editable(self): + for widget, editable_state in self._controls: + state = editable_state if self.editable else "disabled" + # Combobox first: it is an Entry too, and "readonly" would still let it pick. + if isinstance(widget, ttk.Combobox): + widget.configure(state=state) + elif isinstance(widget, tk.Entry): + widget.configure(state="normal" if self.editable else "readonly") + else: + widget.state(["!disabled"] if self.editable else ["disabled"]) + if self.comp is not None: + self._status_var.set("" if self.editable else + "View mode — switch on edit mode (✏) to change values.") + + def _guarded(self, command): + """`command`, run only while the sheet is editable.""" + def run(*args): + if self.editable: + return command(*args) + return run + def flush_edits(self): """Commits a typed value still sitting in its field. @@ -695,7 +727,9 @@ class OxWidgetPropertiesPanel: return label def _dots(self, cell, command): - ttk.Button(cell, text="…", width=3, command=command).pack(side=tk.RIGHT, padx=1) + button = ttk.Button(cell, text="…", width=3, command=self._guarded(command)) + button.pack(side=tk.RIGHT, padx=1) + self._controls.append((button, None)) def _build_readonly(self, cell, row): self._value_label(cell, row, READONLY_FG) @@ -712,6 +746,10 @@ class OxWidgetPropertiesPanel: def commit(_event=None): text = var.get() + if not self.editable: + if text != row["loaded"]: + var.set(row["loaded"]) + return if text != row["loaded"]: # Taken as loaded first: the write repaints, and the repaint must not # find this same edit still pending and commit it a second time. @@ -739,6 +777,7 @@ class OxWidgetPropertiesPanel: highlightbackground=SHEET_BG) entry.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=2, pady=1) self._bind_commit(entry, row) + self._controls.append((entry, None)) def _build_image(self, cell, row): combo = ttk.Combobox(cell, textvariable=row["var"], font=self._name_font, @@ -746,6 +785,7 @@ class OxWidgetPropertiesPanel: combo.pack(fill=tk.BOTH, expand=True, padx=1) commit = self._bind_commit(combo, row) combo.bind("<>", commit) + self._controls.append((combo, "normal")) def _build_choice(self, cell, row): prop, var = row["prop"], row["var"] @@ -759,6 +799,7 @@ class OxWidgetPropertiesPanel: combo.bind("<>", selected) row["load"] = lambda: var.set(prop.read()) + self._controls.append((combo, "readonly")) def _build_colour(self, cell, row): prop, var = row["prop"], row["var"] @@ -781,14 +822,14 @@ class OxWidgetPropertiesPanel: self._dots(cell, choose) self._value_label(cell, row, READONLY_FG) - swatch.bind("", choose) + swatch.bind("", self._guarded(choose)) row["load"] = load def _build_dialog(self, cell, row): opener = getattr(self, f"_open_{row['prop'].dialog}_dialog") self._dots(cell, opener) label = self._value_label(cell, row) - label.bind("", lambda e: opener()) + label.bind("", self._guarded(lambda e: opener())) row["load"] = lambda: row["var"].set(row["prop"].read()) def _build_link(self, cell, row): @@ -815,6 +856,9 @@ class OxWidgetPropertiesPanel: # ── writing ─────────────────────────────────────────────────── def _write(self, row, text): + if not self.editable: + row["load"]() + return values, error = row["prop"].write(text) if error: self._status_var.set(f"{row['prop'].label}: {error}")