host.controller_config.editor_utils

Shared utilities for the dialog-based curve editors and widgets.

Contains constants, coordinate helpers, grid drawing, undo stack, and math utilities shared across spline_editor, segment_editor, curve_editor_widget, and preview_widget.

  1"""Shared utilities for the dialog-based curve editors and widgets.
  2
  3Contains constants, coordinate helpers, grid drawing, undo stack,
  4and math utilities shared across spline_editor, segment_editor,
  5curve_editor_widget, and preview_widget.
  6"""
  7
  8import math
  9from copy import deepcopy
 10
 11from host.controller_config.colors import (
 12    GRID_AXIS,
 13    GRID_MAJOR,
 14    GRID_MINOR,
 15    LABEL_COLOR,
 16)
 17
 18
 19# ---------------------------------------------------------------------------
 20#   Dialog canvas constants (spline_editor + segment_editor)
 21# ---------------------------------------------------------------------------
 22
 23DIALOG_W = 600
 24DIALOG_H = 600
 25DIALOG_MARGIN = 50
 26DIALOG_PLOT_W = DIALOG_W - 2 * DIALOG_MARGIN
 27DIALOG_PLOT_H = DIALOG_H - 2 * DIALOG_MARGIN
 28
 29# Border outline for editor canvases
 30_BORDER_OUTLINE = "#808080"
 31
 32
 33# ---------------------------------------------------------------------------
 34#   Grid drawing
 35# ---------------------------------------------------------------------------
 36
 37def draw_editor_grid(canvas, d2c_fn, margin, plot_w, plot_h,
 38                     canvas_w, canvas_h,
 39                     x_min=-1.0, x_max=1.0,
 40                     y_min=-1.0, y_max=1.0):
 41    """Draw the grid with axis labels for a dialog editor.
 42
 43    Args:
 44        canvas: tk.Canvas to draw on.
 45        d2c_fn: callable(x, y) -> (cx, cy) mapping data to canvas coords.
 46        margin: pixel margin around the plot area.
 47        plot_w: plot area width in pixels.
 48        plot_h: plot area height in pixels.
 49        canvas_w: total canvas width in pixels.
 50        canvas_h: total canvas height in pixels.
 51        x_min: minimum X data value (default -1.0).
 52        x_max: maximum X data value (default 1.0).
 53        y_min: minimum Y data value (default -1.0).
 54        y_max: maximum Y data value (default 1.0).
 55    """
 56    x_step = nice_grid_step(x_max - x_min)
 57    y_step = nice_grid_step(y_max - y_min)
 58
 59    # Vertical grid lines (X axis)
 60    v = math.ceil(x_min / (x_step / 2)) * (x_step / 2)
 61    while v <= x_max + 1e-9:
 62        cx, _ = d2c_fn(v, 0)
 63        is_axis = abs(v) < 1e-9
 64        is_major = abs(round(v / x_step) * x_step - v) < 1e-9
 65        color = GRID_AXIS if is_axis else (GRID_MAJOR if is_major else GRID_MINOR)
 66        w = 2 if is_axis else 1
 67        canvas.create_line(cx, margin, cx, margin + plot_h,
 68                           fill=color, width=w)
 69        v += x_step / 2
 70
 71    # Horizontal grid lines (Y axis)
 72    v = math.ceil(y_min / (y_step / 2)) * (y_step / 2)
 73    while v <= y_max + 1e-9:
 74        _, cy = d2c_fn(0, v)
 75        is_axis = abs(v) < 1e-9
 76        is_major = abs(round(v / y_step) * y_step - v) < 1e-9
 77        color = GRID_AXIS if is_axis else (GRID_MAJOR if is_major else GRID_MINOR)
 78        w = 2 if is_axis else 1
 79        canvas.create_line(margin, cy, margin + plot_w, cy,
 80                           fill=color, width=w)
 81        v += y_step / 2
 82
 83    # X-axis labels (at major steps)
 84    v = math.ceil(x_min / x_step) * x_step
 85    while v <= x_max + 1e-9:
 86        cx, _ = d2c_fn(v, 0)
 87        canvas.create_text(cx, margin + plot_h + 15,
 88                           text=f"{v:g}", fill=LABEL_COLOR,
 89                           font=("TkDefaultFont", 8))
 90        v += x_step
 91
 92    # Y-axis labels (at major steps)
 93    v = math.ceil(y_min / y_step) * y_step
 94    while v <= y_max + 1e-9:
 95        _, cy = d2c_fn(0, v)
 96        canvas.create_text(margin - 22, cy,
 97                           text=f"{v:g}", fill=LABEL_COLOR,
 98                           font=("TkDefaultFont", 8))
 99        v += y_step
100
101    canvas.create_text(canvas_w / 2, canvas_h - 5,
102                       text="Input", fill=LABEL_COLOR,
103                       font=("TkDefaultFont", 9))
104    canvas.create_text(12, canvas_h / 2, text="Output",
105                       fill=LABEL_COLOR, font=("TkDefaultFont", 9), angle=90)
106    canvas.create_rectangle(margin, margin,
107                            margin + plot_w, margin + plot_h,
108                            outline=_BORDER_OUTLINE)
109
110
111# ---------------------------------------------------------------------------
112#   Undo stack
113# ---------------------------------------------------------------------------
114
115class UndoStack:
116    """Simple deepcopy-based undo stack with a fixed capacity."""
117
118    def __init__(self, max_size=30):
119        self._stack: list = []
120        self._max = max_size
121
122    def push(self, state):
123        """Save a deepcopy of state onto the stack."""
124        self._stack.append(deepcopy(state))
125        if len(self._stack) > self._max:
126            self._stack.pop(0)
127
128    def pop(self):
129        """Restore and return the most recent state, or None if empty."""
130        if not self._stack:
131            return None
132        return self._stack.pop()
133
134    def clear(self):
135        """Remove all entries from the stack."""
136        self._stack.clear()
137
138    def __len__(self):
139        return len(self._stack)
140
141
142# ---------------------------------------------------------------------------
143#   Grid step calculation
144# ---------------------------------------------------------------------------
145
146def nice_grid_step(span: float) -> float:
147    """Choose a nice gridline step for the given data span.
148
149    Aims for approximately 4 gridlines across the span.
150    """
151    if span <= 0:
152        return 0.5
153    raw = span / 4  # aim for ~4 gridlines
154    mag = 10 ** math.floor(math.log10(raw))
155    norm = raw / mag
156    if norm < 1.5:
157        return mag
158    elif norm < 3.5:
159        return 2 * mag
160    elif norm < 7.5:
161        return 5 * mag
162    else:
163        return 10 * mag
DIALOG_W = 600
DIALOG_H = 600
DIALOG_MARGIN = 50
DIALOG_PLOT_W = 500
DIALOG_PLOT_H = 500
def draw_editor_grid( canvas, d2c_fn, margin, plot_w, plot_h, canvas_w, canvas_h, x_min=-1.0, x_max=1.0, y_min=-1.0, y_max=1.0):
 38def draw_editor_grid(canvas, d2c_fn, margin, plot_w, plot_h,
 39                     canvas_w, canvas_h,
 40                     x_min=-1.0, x_max=1.0,
 41                     y_min=-1.0, y_max=1.0):
 42    """Draw the grid with axis labels for a dialog editor.
 43
 44    Args:
 45        canvas: tk.Canvas to draw on.
 46        d2c_fn: callable(x, y) -> (cx, cy) mapping data to canvas coords.
 47        margin: pixel margin around the plot area.
 48        plot_w: plot area width in pixels.
 49        plot_h: plot area height in pixels.
 50        canvas_w: total canvas width in pixels.
 51        canvas_h: total canvas height in pixels.
 52        x_min: minimum X data value (default -1.0).
 53        x_max: maximum X data value (default 1.0).
 54        y_min: minimum Y data value (default -1.0).
 55        y_max: maximum Y data value (default 1.0).
 56    """
 57    x_step = nice_grid_step(x_max - x_min)
 58    y_step = nice_grid_step(y_max - y_min)
 59
 60    # Vertical grid lines (X axis)
 61    v = math.ceil(x_min / (x_step / 2)) * (x_step / 2)
 62    while v <= x_max + 1e-9:
 63        cx, _ = d2c_fn(v, 0)
 64        is_axis = abs(v) < 1e-9
 65        is_major = abs(round(v / x_step) * x_step - v) < 1e-9
 66        color = GRID_AXIS if is_axis else (GRID_MAJOR if is_major else GRID_MINOR)
 67        w = 2 if is_axis else 1
 68        canvas.create_line(cx, margin, cx, margin + plot_h,
 69                           fill=color, width=w)
 70        v += x_step / 2
 71
 72    # Horizontal grid lines (Y axis)
 73    v = math.ceil(y_min / (y_step / 2)) * (y_step / 2)
 74    while v <= y_max + 1e-9:
 75        _, cy = d2c_fn(0, v)
 76        is_axis = abs(v) < 1e-9
 77        is_major = abs(round(v / y_step) * y_step - v) < 1e-9
 78        color = GRID_AXIS if is_axis else (GRID_MAJOR if is_major else GRID_MINOR)
 79        w = 2 if is_axis else 1
 80        canvas.create_line(margin, cy, margin + plot_w, cy,
 81                           fill=color, width=w)
 82        v += y_step / 2
 83
 84    # X-axis labels (at major steps)
 85    v = math.ceil(x_min / x_step) * x_step
 86    while v <= x_max + 1e-9:
 87        cx, _ = d2c_fn(v, 0)
 88        canvas.create_text(cx, margin + plot_h + 15,
 89                           text=f"{v:g}", fill=LABEL_COLOR,
 90                           font=("TkDefaultFont", 8))
 91        v += x_step
 92
 93    # Y-axis labels (at major steps)
 94    v = math.ceil(y_min / y_step) * y_step
 95    while v <= y_max + 1e-9:
 96        _, cy = d2c_fn(0, v)
 97        canvas.create_text(margin - 22, cy,
 98                           text=f"{v:g}", fill=LABEL_COLOR,
 99                           font=("TkDefaultFont", 8))
100        v += y_step
101
102    canvas.create_text(canvas_w / 2, canvas_h - 5,
103                       text="Input", fill=LABEL_COLOR,
104                       font=("TkDefaultFont", 9))
105    canvas.create_text(12, canvas_h / 2, text="Output",
106                       fill=LABEL_COLOR, font=("TkDefaultFont", 9), angle=90)
107    canvas.create_rectangle(margin, margin,
108                            margin + plot_w, margin + plot_h,
109                            outline=_BORDER_OUTLINE)

Draw the grid with axis labels for a dialog editor.

Args: canvas: tk.Canvas to draw on. d2c_fn: callable(x, y) -> (cx, cy) mapping data to canvas coords. margin: pixel margin around the plot area. plot_w: plot area width in pixels. plot_h: plot area height in pixels. canvas_w: total canvas width in pixels. canvas_h: total canvas height in pixels. x_min: minimum X data value (default -1.0). x_max: maximum X data value (default 1.0). y_min: minimum Y data value (default -1.0). y_max: maximum Y data value (default 1.0).

class UndoStack:
116class UndoStack:
117    """Simple deepcopy-based undo stack with a fixed capacity."""
118
119    def __init__(self, max_size=30):
120        self._stack: list = []
121        self._max = max_size
122
123    def push(self, state):
124        """Save a deepcopy of state onto the stack."""
125        self._stack.append(deepcopy(state))
126        if len(self._stack) > self._max:
127            self._stack.pop(0)
128
129    def pop(self):
130        """Restore and return the most recent state, or None if empty."""
131        if not self._stack:
132            return None
133        return self._stack.pop()
134
135    def clear(self):
136        """Remove all entries from the stack."""
137        self._stack.clear()
138
139    def __len__(self):
140        return len(self._stack)

Simple deepcopy-based undo stack with a fixed capacity.

UndoStack(max_size=30)
119    def __init__(self, max_size=30):
120        self._stack: list = []
121        self._max = max_size
def push(self, state):
123    def push(self, state):
124        """Save a deepcopy of state onto the stack."""
125        self._stack.append(deepcopy(state))
126        if len(self._stack) > self._max:
127            self._stack.pop(0)

Save a deepcopy of state onto the stack.

def pop(self):
129    def pop(self):
130        """Restore and return the most recent state, or None if empty."""
131        if not self._stack:
132            return None
133        return self._stack.pop()

Restore and return the most recent state, or None if empty.

def clear(self):
135    def clear(self):
136        """Remove all entries from the stack."""
137        self._stack.clear()

Remove all entries from the stack.

def nice_grid_step(span: float) -> float:
147def nice_grid_step(span: float) -> float:
148    """Choose a nice gridline step for the given data span.
149
150    Aims for approximately 4 gridlines across the span.
151    """
152    if span <= 0:
153        return 0.5
154    raw = span / 4  # aim for ~4 gridlines
155    mag = 10 ** math.floor(math.log10(raw))
156    norm = raw / mag
157    if norm < 1.5:
158        return mag
159    elif norm < 3.5:
160        return 2 * mag
161    elif norm < 7.5:
162        return 5 * mag
163    else:
164        return 10 * mag

Choose a nice gridline step for the given data span.

Aims for approximately 4 gridlines across the span.