# coding=utf-8
"""ToolTableEditor -- unified tool table widget.
One editing surface for core tool-table columns, machine-flavor "extras"
columns (see ``qtpyvcp.plugins.db_tool_table``'s per-machine extras
tables), and user-defined custom columns. Keeps the same public slot
surface as this package's stock ``tool_table.ToolTable`` widget
(saveToolTable/loadToolTable/addTool/deleteSelectedTool/...) so a VCP can
promote this widget in place of the stock one without changing any `.ui`
signal/slot wiring -- only the promoted class changes.
Machine-flavor configuration (which extras columns exist, their labels,
which are strict enums vs. open-vocabulary vs. free text, default
visibility, ...) is hoisted to class attributes -- this class only
implements the mechanism that reads them; a subclass overrides the data.
Any column key listed in ``OPEN_VOCAB_SEED_OPTIONS`` must have its choices
supplied by overriding :meth:`ToolTableEditorModel.openVocabOptionsFor`.
See probe_basic's ``LatheToolModel``/``MillToolModel`` for real per-machine
subclasses (lathe insert/holder vocabulary, mill's ATC bool extra).
Works against either qtpyvcp tooltable plugin, adapting at runtime to
whichever one a config actually wires up (duck-typed via
``getCustomFieldDefs``) rather than requiring a second copy of the `.ui`
with a different widget class promoted:
- ``qtpyvcp.plugins.tool_table:ToolTable`` -- classic file-based .tbl,
core columns only, no extras/custom concept.
- ``qtpyvcp.plugins.db_tool_table:DBToolTable`` -- SQLite-backed, adds
per-machine extras and user-defined custom columns.
"""
from PySide6.QtCore import (Qt, Slot, Signal, Property, QModelIndex, QTimer,
QSortFilterProxyModel)
from PySide6.QtGui import QStandardItemModel, QColor, QBrush, QValidator
from PySide6.QtWidgets import (QTableView, QHeaderView, QAbstractItemView,
QStyledItemDelegate, QDoubleSpinBox, QSpinBox,
QLineEdit, QComboBox, QMessageBox, QMenu,
QInputDialog, QStyle)
from qtpyvcp.actions.machine_actions import issue_mdi
from qtpyvcp.utilities.logger import getLogger
from qtpyvcp.actions import IN_DESIGNER
from qtpyvcp.plugins import getPlugin
LOG = getLogger(__name__)
CUSTOM_PREFIX = 'custom:'
# Custom-column display, by the field's own value_type -- text reads like
# the other text-ish columns (left), float/int/bool read like data rather
# than prose (right for float since that's the numeric-column convention
# everywhere else in this table; centered for int/bool since they're
# short, discrete values rather than magnitudes to compare).
CUSTOM_FIELD_ALIGNMENT = {
'text': Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft,
'float': Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight,
'int': Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignCenter,
'bool': Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignCenter,
}
# Single source of truth for float display precision, table-wide (core
# columns, extras, and custom float fields alike) -- named and centralized
# rather than a bare "4" repeated wherever a float gets formatted, so
# every float column stays uniform on purpose, not by coincidence.
FLOAT_DECIMALS = 4
MAX_COLUMNS = 200 # generous fixed capacity; mirrors the row model's
# existing fixed setRowCount(1000)/max-1000-tools limit
GROUP_LABELS = {'core': 'Core', 'extras': 'Extras', 'custom': 'Custom'}
# Qt's QComboBox default (10) puts a scrollbar inside the popup well before
# any of this table's real option lists are long enough to need one (e.g.
# a lathe's holder_style already has 14+ distinct real values) -- a
# scrollable list popped out of a table cell is one nested-scroll-region
# too many. Generous enough that realistic option counts just show in full
# instead.
COMBO_MAX_VISIBLE_ITEMS = 25
# Dynamic property tagging the bool-valued combo editor (both extras bools,
# e.g. a mill's ATC flag, and bool-typed custom fields), so
# setEditorData/setModelData can special-case it without relying on Qt's
# built-in QVariant(bool)->QString marshaling (confirmed live: it lowercases
# to "true"/"false", not "True"/"False" -- matching combo item text against
# that silently mismatches and leaves the wrong item pre-selected) -- easier
# to just own the round-trip as a real Python bool throughout.
BOOL_EDITOR_PROP = '_tool_table_bool_editor'
# Imported after the constants above (not at module top): both dialogs
# import CUSTOM_PREFIX back from this module, which would be a circular
# import (this module not yet finished defining it) if these imports ran
# before that line.
from .add_column_dialog import AddColumnDialog
from .parameter_names_dialog import parameter_entries, ParameterNamesDialog
class ToolTableEditorModel(QStandardItemModel):
# Machine-flavor configuration, hoisted to class attributes so a
# machine variant is a subclass overriding data, not a fork of the
# logic. Neutral/empty defaults here; the model methods (and
# ToolTableEditorDelegate, via self._model) consult these regardless
# of what a subclass sets them to.
EXTRAS_LABELS = {} # column key -> header label, in display order
TEXT_EXTRAS = frozenset() # extras rendered/edited as free text
BOOL_EXTRAS = frozenset() # extras rendered/edited as True/False
EXTRAS_DEFAULTS = {} # value shown when a tool has no extras row
DEFAULT_VISIBLE_EXTRAS = []
STRICT_ENUM_OPTIONS = {} # DB CHECK-constrained: non-editable pick-lists
OPEN_VOCAB_SEED_OPTIONS = frozenset() # editable combo; see openVocabOptionsFor()
EXTRAS_GROUP_LABEL = 'Extras' # header-menu section title
# Columns that must be filled in, keyed by the value of REQUIREMENT_KEY
# for that row. Cells that are required and still empty are shaded, so
# a part-filled tool is visible at a glance instead of only surfacing
# later when something refuses to run. Empty here: a machine flavor
# that has no such rules simply gets no shading.
REQUIRED_BY_TYPE = {}
REQUIREMENT_KEY = None # column whose value selects the rule set
# Columns where 0 is the unset value rather than a measurement, so a 0
# shades amber like a blank. Registered by the flavor; empty here.
ZERO_MEANS_EMPTY = ()
# Columns required on every tool whatever its type -- core fields, which
# REQUIRED_BY_TYPE does not cover. Text columns only: a numeric core
# field like D or Q can legitimately hold 0 (a sharp tool, an axial
# drill), and the blank test here treats 0 as unset.
REQUIRED_ALWAYS = ()
# Shading for a required-but-empty cell -- amber: an unfinished row is a
# to-do, not an error. A class attribute, and an RGB tuple rather than a
# QColor, so it exists on every construction path (the real one and
# Designer's stub) without depending on __init__ order or a live
# QApplication. data() is called from C++, where a missing attribute
# segfaults instead of raising.
REQUIRED_EMPTY_BG = (255, 214, 102)
# Every column this tool type's geometry is built from, keyed the same
# way as REQUIRED_BY_TYPE. Wider than "required": it also covers fields
# that are read when present but tolerated blank.
#
# A cell holding a value whose column is NOT in its type's set is tinted
# -- the value came in on an import and nothing consumes it for a tool
# of this type. Tinted rather than blanked, so it is clear the data is
# still there. Columns nothing reads for *any* type go in
# UNUSED_COLUMNS and are tinted regardless of type.
#
# Deliberately low contrast -- a shade or two above the alternating
# row backgrounds (roughly 120,120,120 and 90,90,90), against normal
# text at ~244. The value stays legible if you look for it, but recedes
# rather than competing with the data that actually drives something.
# Class attributes and RGB tuples, as data() is called from C++ where a
# missing attribute segfaults.
USED_BY_TYPE = {}
UNUSED_COLUMNS = frozenset()
UNUSED_TEXT_COLOR = (150, 150, 150)
# Core columns forced to render AFTER extras/custom instead of in their
# normal core position -- e.g. a wide free-text Remark column, so
# machine extras sit between the numeric offsets and it. Empty by
# default (keeps Remark in its normal core position); membership
# only -- these stay 'core' for grouping, only their column position
# moves.
TRAILING_CORE_COLUMNS = ()
# Designer-canvas placeholder columns (see _initEmptyStub) -- cosmetic
# only, real columns always come from the live plugin's self.tt.columns.
DESIGNER_STUB_COLUMNS = ['T', 'X', 'Z', 'D', 'R']
dirtyChanged = Signal(bool)
def __init__(self, parent=None):
super(ToolTableEditorModel, self).__init__(parent)
self._dirty = False
if IN_DESIGNER:
self._initEmptyStub()
return
try:
self.status = getPlugin('status')
self.stat = self.status.stat
self.tt = getPlugin('tooltable')
required = ('columns', 'COLUMN_LABELS', 'getToolTable',
'saveToolTable', 'newTool', 'tool_table_changed')
missing = [attr for attr in required if not hasattr(self.tt, attr)]
if missing:
# Something fundamentally incompatible -- not even the base
# (machine-flavor-shared) tool-table interface every
# 'tooltable' plugin is expected to implement. A plugin
# merely lacking the DB extras (see _db_backed below) is
# not this case -- that's a normal, supported backend, not
# an error.
raise TypeError(
"'tooltable' plugin (%s) doesn't implement the base "
"tool-table interface -- missing: %s"
% (type(self.tt).__name__, ', '.join(missing)))
# True for qtpyvcp.plugins.db_tool_table:DBToolTable; False for
# the classic file-based qtpyvcp.plugins.tool_table:ToolTable.
# Both are first-class, supported backends for this one widget
# -- it adapts at runtime to whichever 'tooltable' plugin the
# .ini/yaml config actually wires up, rather than requiring a
# second copy of the .ui with a different widget class
# promoted (Designer bakes the promoted class into the .ui
# statically, so two backends would mean two .ui files that
# inevitably drift out of sync on any unrelated edit).
self._db_backed = hasattr(self.tt, 'getCustomFieldDefs')
self.current_tool_color = QColor(Qt.darkGreen)
self.current_tool_bg = None
self._core_columns = list(self.tt.columns)
self._core_labels = dict(self.tt.COLUMN_LABELS)
if self._db_backed and self._pluginServesOurExtras():
self._extras_columns = list(self.EXTRAS_LABELS)
self._extras_labels = dict(self.EXTRAS_LABELS)
else:
self._extras_columns = []
self._extras_labels = {}
self._custom_columns = []
self._custom_labels = {}
self._custom_value_types = {}
self.row_offset = 1
self._interp_idle = True
self._tool_table = {}
if self._db_backed:
self._refresh_custom_columns()
# Restore whatever the user last had checked visible -- core,
# extras, and custom columns alike -- so a widget restart
# shows the same columns instead of always reverting to the
# hardcoded default. No persisted preference yet (fresh DB, or
# classic file-based backend, which has nowhere to persist
# this at all) falls back to the default set.
persisted = self.tt.getVisibleColumns() if self._db_backed else None
self._visible_columns = (self._filterToKnownColumns(persisted) if persisted
else self._default_visible_columns())
# Fixed capacity, set once, before any view/proxy is attached --
# never touched again (see MAX_COLUMNS and setVisibleColumns
# below for why: calling setColumnCount() again later, after a
# proxy is watching, races its own columnsInserted signal
# against our columnCount() override still reporting the
# pre-growth count).
self.setColumnCount(MAX_COLUMNS)
self.setRowCount(1000)
self._load_full_table()
self.status.tool_in_spindle.notify(self.refreshModel)
self.status.interp_state.notify(self._onInterpStateChanged)
self._reload_signals_suppressed = False
self.tt.tool_table_changed.connect(lambda *_: self._load_full_table())
if self._db_backed:
self.tt.extras_changed.connect(self._onExtrasChanged)
self.tt.fields_changed.connect(self._onFieldsChanged)
except Exception:
# A Python exception escaping this constructor would propagate
# up through QUiLoader's C++ widget-tree building (this widget
# gets built via .ui customwidget promotion) -- PySide6 segfaults
# instead of raising cleanly there, rather than just failing this
# one widget. Fail soft into an empty, read-only stub instead;
# see ToolTableEditor.__init__ for the matching self.tt is-None
# guard.
LOG.exception(
"ToolTableEditorModel: failed to initialize against the "
"'tooltable' plugin -- falling back to an empty table "
"instead of crashing. This means the plugin is missing even "
"the base tool-table interface (columns/getToolTable/"
"saveToolTable/...) -- a DB backend is not required (both "
"qtpyvcp.plugins.tool_table:ToolTable and "
"qtpyvcp.plugins.db_tool_table:DBToolTable work), so check "
"'tooltable' is wired to one of those two, not something "
"else entirely.")
self._initEmptyStub()
def _initEmptyStub(self):
self.status = None
self.stat = None
self.tt = None
self._db_backed = False
self._reload_signals_suppressed = False
# Real-path defaults for ToolTableEditor's currentToolColor/
# currentToolBackground @Property getters, which read these
# unconditionally. Missing here meant Qt Designer's startup widget-
# database scan (grabs every registered custom widget's default
# property values, on a freshly-constructed instance -- independent
# of which .ui is even open) hit an AttributeError from inside a
# C++-invoked property getter and segfaulted instead of raising.
self.current_tool_color = QColor(Qt.darkGreen)
self.current_tool_bg = None
self._core_columns = list(self.DESIGNER_STUB_COLUMNS)
self._extras_columns = []
self._custom_columns = []
self._visible_columns = list(self._core_columns)
self._core_labels = {c: c for c in self._core_columns}
self._extras_labels = {}
self._custom_labels = {}
self._custom_value_types = {}
# _tool_table must exist before setColumnCount/setRowCount below --
# both synchronously invoke our overridden rowCount(), which reads it.
self._tool_table = {}
self.setColumnCount(len(self._visible_columns))
self.setRowCount(10)
self.row_offset = 1
self._interp_idle = True
# ------------------------------------------------------------ columns
def _pluginServesOurExtras(self):
"""True when the DB plugin's configured extras table carries every
column this model wants to show. A mismatch (e.g. this .ui promotes
a mill-flavored widget but the config's yaml left the plugin on
`extras: lathe`) would otherwise stage edits into attributes the
plugin never persists -- silently. Hide the extras columns and say
so instead; core and custom columns keep working either way."""
plugin_extras = getattr(self.tt, '_EXTRAS_COLUMNS', [])
missing = [c for c in self.EXTRAS_LABELS if c not in plugin_extras]
if missing:
LOG.error(
"The 'tooltable' plugin serves extras columns %s, but this "
"widget (%s) renders %s -- missing %s. Check the config "
"yaml's data_plugins.tooltable.kwargs.extras matches the "
"promoted tool table widget. Hiding the extras columns.",
plugin_extras, type(self).__name__,
list(self.EXTRAS_LABELS), missing)
return False
return True
def _refresh_custom_columns(self):
defs = self.tt.getCustomFieldDefs()
self._custom_columns = [CUSTOM_PREFIX + d['name'] for d in defs]
self._custom_labels = {CUSTOM_PREFIX + d['name']: d['label'] for d in defs}
self._custom_value_types = {CUSTOM_PREFIX + d['name']: d['value_type'] for d in defs}
def _order_columns(self, cols):
"""Move any TRAILING_CORE_COLUMNS to the end (after extras/custom),
preserving their relative order -- everything else keeps its order.
No-op when TRAILING_CORE_COLUMNS is empty."""
if not self.TRAILING_CORE_COLUMNS:
return list(cols)
trailing = [c for c in cols if c in self.TRAILING_CORE_COLUMNS]
head = [c for c in cols if c not in self.TRAILING_CORE_COLUMNS]
return head + trailing
def _default_visible_columns(self):
extras = [c for c in self.DEFAULT_VISIBLE_EXTRAS if c in self._extras_columns]
return self._order_columns(list(self._core_columns) + extras)
def allColumns(self):
"""Every known column key, in display order: core, then extras, then
custom -- with any TRAILING_CORE_COLUMNS moved to the very end so
"Show All Columns" and the default order agree.
Capped at MAX_COLUMNS -- the model's real (fixed) column count; see
setVisibleColumns. An overflow here would mean ~150+ custom fields
defined, at which point the newest ones are silently unavailable
rather than crashing; raising MAX_COLUMNS is the fix if that's ever
a real shop's use case.
"""
cols = self._order_columns(
list(self._core_columns) + list(self._extras_columns)
+ list(self._custom_columns))
return cols[:MAX_COLUMNS]
def visibleColumns(self):
return list(self._visible_columns)
def _filterToKnownColumns(self, columns):
"""columns, minus any key that no longer exists (a removed custom
column, or one from a differently-configured core set) -- falls
back to just the core columns if that leaves nothing at all.
Also normalizes to allColumns()' own canonical order regardless of
what order `columns` arrives in, so a column that gets hidden and
re-shown always lands back in its original position instead of at
the end (the header menu's re-show path builds the list in toggle
order via cols.append(key), not display order)."""
all_cols = self.allColumns()
wanted = set(columns)
ordered = [c for c in all_cols if c in wanted]
return ordered or list(self._core_columns)
def setVisibleColumns(self, columns):
# Toggling column *visibility* only ever changes _visible_columns
# (what columnCount() reports) -- it never touches the model's real
# (fixed, MAX_COLUMNS) column count. An earlier version grew the
# real count here via setColumnCount() as new custom fields showed
# up; calling that *after* a proxy/view was already attached raced
# its columnsInserted signal against our columnCount() override
# still reporting the pre-growth total ("QSortFilterProxyModel:
# invalid inserted rows reported by source model"). Fixed capacity
# sidesteps this the same way the row model already does.
visible = self._filterToKnownColumns(columns)
# A column holding a required-and-empty value cannot be hidden. Its
# amber shading is the only warning the operator gets, and off
# screen it warns nobody -- the failure then surfaces as a refused
# G-code generation naming a field they cannot see. Enforced here
# rather than at the call sites so every path obeys it: the header
# menu, Reset Default Columns, and the persisted set restored at
# startup.
forced = [c for c in self.columnsRequiringValues() if c not in visible]
if forced:
visible = self._filterToKnownColumns(visible + forced)
self.beginResetModel()
self._visible_columns = visible
self.endResetModel()
if self._db_backed:
# Persist every change, not just user-initiated ones (e.g. a
# custom column disappearing out from under an existing
# selection): the persisted set should always mirror what's
# actually currently shown, with no special-casing over who
# triggered the change.
self.tt.setVisibleColumns(visible)
# ------------------------------------------------- required-value reveal
def columnsRequiringValues(self):
"""Every column holding a required-and-empty cell, hidden or not.
A column the user has turned off can still contain a value the
machine needs. Left alone, the amber shading that would have said so
is simply not on screen, and the first anyone hears of it is a
refused G-code generation naming a field they cannot see.
These columns cannot be hidden -- see setVisibleColumns() -- and the
header menu shows them locked. Fill the value in and the lock lifts.
"""
table = getattr(self, '_tool_table', None) or {}
if not table:
return []
tools = [t for t in table if t != 0]
return [c for c in self.allColumns()
if any(self._is_required_and_empty(c, t) for t in tools)]
def columnsRequiredByRules(self):
"""Every column the rules require for the tools present, filled or not.
Wider than columnsRequiringValues(), which only names the ones
currently blank. A required column that every row happens to have
filled is still a column the operator has to be able to SEE -- it
drives geometry or a safety check, and they cannot change what is not
on screen.
This is what lets a rules change reach an existing setup. The visible
set is persisted per user, so a saved selection made before a column
became required would otherwise win forever and quietly hide it.
Revealed, not locked: hide it again and re-save if you want it gone.
"""
table = getattr(self, '_tool_table', None) or {}
if not table or not self.REQUIRED_BY_TYPE or not self.REQUIREMENT_KEY:
return list(self.REQUIRED_ALWAYS)
keys = set(self.REQUIRED_ALWAYS)
keys.add(self.REQUIREMENT_KEY)
for tnum, row_data in table.items():
if tnum == 0 or not isinstance(row_data, dict):
continue
rule = self.rowRuleKey(row_data)
if rule:
keys.update(self.REQUIRED_BY_TYPE.get(rule, ()))
return [c for c in self.allColumns() if c in keys]
def toolsMissingValue(self, key):
"""Tool numbers whose `key` is required and blank. For the tooltip."""
table = getattr(self, '_tool_table', None) or {}
return [t for t in sorted(table)
if t != 0 and self._is_required_and_empty(key, t)]
def revealColumnsWithMissingValues(self):
"""Bring every required column into view. Returns what was added.
setVisibleColumns() forces in the required-and-EMPTY ones by itself
and locks them. This adds the rest of the required set -- the ones
already filled -- which are revealed but stay hideable.
Without this a saved column selection outlives the rules it was made
under: add a column to a type's requirements and every existing setup
keeps it hidden, so the operator is asked for a value in a cell that
is not on screen.
"""
before = set(self._visible_columns)
wanted = list(self._visible_columns)
wanted += [c for c in self.columnsRequiredByRules() if c not in before]
self.setVisibleColumns(wanted)
return [c for c in self._visible_columns if c not in before]
def columnLabel(self, key):
if key in self._core_labels:
return self._core_labels[key]
if key in self._extras_labels:
return self._extras_labels[key]
return self._custom_labels.get(key, key)
# Optional callable(row_data, base_key) -> rule key, supplied through
# registerRules(). See rowRuleKey().
RULE_KEY_HOOK = None
@classmethod
def registerRules(cls, required_by_type=None, used_by_type=None,
required_always=None, unused_columns=None,
rule_key=None, zero_means_empty=None):
"""Publish the tool-data rules this table should shade against.
The table knows *how* to shade a cell; it does not know which cells
matter. That is a fact about whatever consumes the tool data -- a
conversational addon's G-code builders and collision checks, say --
so the consumer registers it here rather than this module carrying
a copy of rules it cannot verify.
Everything is optional and anything omitted is left as-is. Register
nothing and the table simply does not shade: `_is_required_and_empty`
and `_is_unused_value` both no-op on empty rules, so a VCP with no
such consumer gets a plain tool table with no extra code path.
Args:
required_by_type: {rule key: (column, ...)} that must be filled.
used_by_type: {rule key: (column, ...)} the tool's geometry is
built from -- wider than required; anything outside its
type's set is greyed as "present but not read".
required_always: (column, ...) required whatever the type.
unused_columns: columns no rule set ever reads.
rule_key: callable(row_data, base_key) -> key, for when the type
alone is too coarse to pick a rule set.
"""
if required_by_type is not None:
cls.REQUIRED_BY_TYPE = dict(required_by_type)
if used_by_type is not None:
cls.USED_BY_TYPE = dict(used_by_type)
if required_always is not None:
cls.REQUIRED_ALWAYS = tuple(required_always)
if unused_columns is not None:
cls.UNUSED_COLUMNS = frozenset(unused_columns)
if rule_key is not None:
cls.RULE_KEY_HOOK = staticmethod(rule_key)
if zero_means_empty is not None:
cls.ZERO_MEANS_EMPTY = tuple(zero_means_empty)
def rowRuleKey(self, row_data):
"""Which entry of USED_BY_TYPE / REQUIRED_BY_TYPE applies to a row.
Defaults to REQUIREMENT_KEY's value, refined by RULE_KEY_HOOK when
one is registered -- the type alone is sometimes too coarse. An
internal and an external grooving tool are both type 'grooving',
but they read different holder dimensions, and only the holder
style says which is which.
"""
if not self.REQUIREMENT_KEY:
return ''
key = str(row_data.get(self.REQUIREMENT_KEY) or '').strip().lower()
if self.RULE_KEY_HOOK is not None:
return self.RULE_KEY_HOOK(row_data, key)
return key
def columnGroup(self, key):
if key in self._core_columns:
return 'core'
if key in self._extras_columns:
return 'extras'
return 'custom'
def openVocabOptionsFor(self, key, row_data):
"""Choices to seed an OPEN_VOCAB_SEED_OPTIONS combo editor for
`key`, given the row's other already-entered data (e.g. a lathe's
insert-shape choices depend on the row's tool type). No-op base --
any subclass that lists columns in OPEN_VOCAB_SEED_OPTIONS must
override this to supply their choices."""
return []
# ------------------------------------------------------------ loading
def _load_full_table(self):
core = self.tt.getToolTable() # {0: NO_TOOL, tnum: {letter: value}}
table = {}
for tnum, core_row in core.items():
row = dict(core_row)
if tnum != 0 and self._db_backed:
extras = self.tt.getToolExtras(tnum) or {}
for key in self._extras_columns:
value = extras.get(key)
if value is None:
# no extras row yet (or an unset cell): show the
# machine flavor's default (e.g. mill atc = True)
# instead of a blank that reads as "no value".
value = self.EXTRAS_DEFAULTS.get(key)
row[key] = value
values = self.tt.getCustomFieldValues(tnum)
for key in self._custom_columns:
row[key] = values.get(key[len(CUSTOM_PREFIX):])
table[tnum] = row
self.beginResetModel()
self._tool_table = table
self.endResetModel()
self._set_dirty(False)
# A required value can sit in a column the user has turned off,
# where its amber shading is invisible and the only symptom is a
# refused G-code generation naming a field they cannot see.
self._revealRequiredColumns()
LOG.debug("ToolTableEditorModel reload: tools=%s", len(table) - 1)
def _revealRequiredColumns(self):
"""Pull in any hidden column now holding a required-and-empty cell."""
revealed = self.revealColumnsWithMissingValues()
if revealed:
LOG.info("Revealed hidden column(s) with required values missing: %s",
', '.join(revealed))
def _onExtrasChanged(self, tool_no):
# saveAllToolExtras() emits this once per tool in the batch (a
# correct, real per-tool signal -- kept that way in case anything
# ever wants that granularity). But saveToolTable() below already
# does its own single authoritative _load_full_table() right after
# the whole batch commits, so reacting here too, once per tool,
# would turn one Save into N redundant full reloads -- suppressed
# for the duration of that batch call.
if not self._reload_signals_suppressed:
self._load_full_table()
def _onFieldsChanged(self):
self._refresh_custom_columns()
# keep whatever core/extras columns were visible; drop any custom
# column that no longer exists, but don't force newly-added ones
# into view (the user picks those from the header menu).
# setVisibleColumns() grows column capacity itself, atomically, if
# a new custom field means there's more room needed.
kept = [c for c in self._visible_columns
if not c.startswith(CUSTOM_PREFIX) or c in self._custom_columns]
self.setVisibleColumns(kept)
self._load_full_table()
def refreshModel(self):
self.beginResetModel()
self.endResetModel()
def _onInterpStateChanged(self, interp_state):
import linuxcnc
was_idle = self._interp_idle
self._interp_idle = (interp_state == linuxcnc.INTERP_IDLE)
if was_idle != self._interp_idle:
self.beginResetModel()
self.endResetModel()
# ------------------------------------------------------------ Qt model
def columnCount(self, parent=None):
return len(self._visible_columns)
def rowCount(self, parent=None):
return max(0, len(self._tool_table) - 1)
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
return self.columnLabel(self._visible_columns[section])
if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Vertical:
# Row header shows the tool number instead of a plain row
# index -- it's Qt's own always-visible-through-horizontal-
# scroll widget, so this is what keeps T in view without
# needing a frozen/pinned column.
return self.toolDataFromRow(section)['T']
return QStandardItemModel.headerData(self, section, orientation, role)
def _tool_no_for_row(self, row):
return sorted(self._tool_table)[row + self.row_offset]
def flags(self, index):
col_key = self._visible_columns[index.column()]
if col_key == 'T':
return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
if not self._interp_idle:
# Every column locks while a program runs, not just core --
# a custom/extras value could still be referenced by a
# subroutine or other mid-program logic, so editing any of it
# during machine operation is unsafe practice regardless of
# whether LinuxCNC's own interpreter reads that column live.
return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
# A derived cell states what the row already says. Offering an editor
# would show a pick-list whose choices are either overwritten on the
# next edit or, where the display has no stored equivalent, cannot be
# saved at all.
row_data = (getattr(self, '_tool_table', None) or {}).get(
self._tool_no_for_row(index.row())) or {}
if self.isDerivedCell(row_data, col_key):
return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
return (Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable |
Qt.ItemFlag.ItemIsEditable)
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
row, col = index.row(), index.column()
key = self._visible_columns[col]
tnum = self._tool_no_for_row(row)
if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole):
row_data = self._tool_table[tnum]
value = row_data.get(self.storageKeyFor(row_data, key))
if role == Qt.ItemDataRole.DisplayRole:
return self.displayValueFor(row_data, key, value)
return value
elif role == Qt.ItemDataRole.TextAlignmentRole:
if self.columnGroup(key) == 'custom':
value_type = self._custom_value_types.get(key)
return CUSTOM_FIELD_ALIGNMENT.get(
value_type, Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
if key in ('R',) or key in self.TEXT_EXTRAS:
return Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft
elif key in ('T', 'P', 'Q') or key in self.BOOL_EXTRAS:
return Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignCenter
else:
return Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight
elif role == Qt.ItemDataRole.ForegroundRole:
if self.stat.tool_in_spindle == tnum:
return QBrush(self.current_tool_color)
if self._is_unused_value(key, tnum):
return QBrush(QColor(*self.UNUSED_TEXT_COLOR))
return QStandardItemModel.data(self, index, role)
elif role == Qt.ItemDataRole.BackgroundRole:
# Amber first, ahead of the current-tool tint. Both are
# backgrounds and only one can win; the missing-value warning is
# the one that has to. Tested the other way round and the tool in
# the spindle -- the single most likely row to be looked at -- was
# the one row whose amber never showed.
if self._is_required_and_empty(key, tnum):
return QBrush(QColor(*self.REQUIRED_EMPTY_BG))
if (self.current_tool_bg is not None
and self.stat.tool_in_spindle == tnum):
return QBrush(self.current_tool_bg)
return QStandardItemModel.data(self, index, role)
return QStandardItemModel.data(self, index, role)
def _is_unused_value(self, key, tnum):
"""True when this cell holds a value nothing reads for this tool.
Two ways to qualify: the column is dead for every type
(UNUSED_COLUMNS), or it is simply not part of *this* type's
geometry -- a groove width on a turning tool, say. Empty cells are
never tinted; there is nothing to explain about a blank.
"""
row_data = (getattr(self, '_tool_table', None) or {}).get(tnum) or {}
if key not in self.UNUSED_COLUMNS:
# USED_BY_TYPE describes extras only. Core columns are
# LinuxCNC's own fields -- always live, whatever the tool type
# -- and custom columns are the user's, so neither is ours to
# call unused. Without this guard every core column falls
# outside the type's set and the whole table tints.
if self.columnGroup(key) != 'extras':
return False
if not (self.USED_BY_TYPE and self.REQUIREMENT_KEY):
return False
row_type = self.rowRuleKey(row_data)
# No type yet: nothing is known to be unused, so tint nothing.
if not row_type or row_type not in self.USED_BY_TYPE:
return False
if key == self.REQUIREMENT_KEY or key in self.USED_BY_TYPE[row_type]:
return False
value = row_data.get(key)
if value is None:
return False
if isinstance(value, str):
return bool(value.strip())
return bool(value)
def _is_required_and_empty(self, key, tnum):
"""True when `key` is required for this tool's type and still blank.
Driven off REQUIREMENT_KEY's current value, so the shading follows
the row as it is filled in: set the type and that type's fields
light up; fill one and it clears.
"""
row_data = (getattr(self, '_tool_table', None) or {}).get(tnum) or {}
# Checked on the column as displayed, before the alias is resolved:
# a column whose warning is shown on another cell must not shade too.
if self.requirementCarriedElsewhere(row_data, key):
return False
# An aliased cell shades on the column it actually stores into, so the
# amber lands where the operator types rather than on a hidden column.
key = self.storageKeyFor(row_data, key)
# Core fields required on every tool, independent of type.
if key in self.REQUIRED_ALWAYS:
value = row_data.get(key)
if value is None or not str(value).strip():
return True
# A core column cannot be blank -- clearing one leaves 0, not an
# empty cell -- so for the columns where 0 is not a legal value,
# 0 IS the empty state and has to shade like one. Listed rather
# than assumed: 0 is real data on D (a point threading insert has
# no nose radius) and on I/J (drills and taps), which is exactly
# why those are required per-type instead of always.
if key in self.ZERO_MEANS_EMPTY:
try:
return float(value) == 0.0
except (TypeError, ValueError):
return False
return False
if not self.REQUIRED_BY_TYPE or not self.REQUIREMENT_KEY:
return False
row_type = self.rowRuleKey(row_data)
# The type cell itself is required before anything else can be known.
if key == self.REQUIREMENT_KEY:
return not row_type
if key not in self.REQUIRED_BY_TYPE.get(row_type, ()):
return False
value = row_data.get(key)
if value is None:
return True
if isinstance(value, str):
return not value.strip()
return not value # 0 / 0.0 count as unset for these fields
def setData(self, index, value, role):
key = self._visible_columns[index.column()]
tnum = self._tool_no_for_row(index.row())
required_before = set(self.columnsRequiringValues())
store = self.storageKeyFor(self._tool_table[tnum], key)
self._tool_table[tnum][store] = value
self._set_dirty(True)
# Some columns are not a question for the operator -- they are a
# statement about what the row already says. Filling them in as soon
# as the row can answer them means the operator reads what to enter
# instead of guessing at a blank cell.
derived = self.deriveValuesForRow(self._tool_table[tnum])
for derived_key, derived_value in (derived or {}).items():
if derived_key == key or derived_key not in self.allColumns():
continue # never fight the edit that just happened
if self._tool_table[tnum].get(derived_key) == derived_value:
continue
self._tool_table[tnum][derived_key] = derived_value
# Changing the type changes which of the *other* columns are
# required, so the whole row has to repaint -- not just the cell
# that was edited.
if key == self.REQUIREMENT_KEY and self.columnCount():
row = index.row()
self.dataChanged.emit(self.index(row, 0),
self.index(row, self.columnCount() - 1),
[Qt.ItemDataRole.BackgroundRole])
# That same edit can make a column required that is currently hidden
# -- set a tool to threading and its pitch range becomes required, or
# change holder_style and the rule key flips to a type wanting
# different fields. Until now the reveal only ran on a full reload,
# so the amber shading stayed off screen until the next save or
# restart and the first anyone heard of it was a refused G-code
# generation naming a field they could not see.
#
# Deferred: revealing resets the model, which must not happen inside
# the delegate's commit that called us.
if set(self.columnsRequiringValues()) - required_before:
QTimer.singleShot(0, self._revealRequiredColumns)
return True
def isDerivedCell(self, row_data, key):
"""True when this cell is worked out from the row, not typed into it.
Derived cells are shown but not editable -- see flags(). Nothing is
derived in the base table.
"""
return False
def displayValueFor(self, row_data, key, value):
"""What a cell SHOWS, where that differs from what it stores.
Display only -- EditRole still hands back the stored value, so an
editor opens on the real data and nothing round-trips through the
label. For a column whose whole job is to say what a number means,
this lets it say something the database has no column value for.
"""
return value
def requirementCarriedElsewhere(self, row_data, key):
"""True when this column's amber is shown on a DIFFERENT cell.
The pair to storageKeyFor: if one cell is aliased onto another
column, only one of the two should shade, or the operator sees the
same missing value flagged twice and the hidden one is pinned open
for a warning that is already on screen.
"""
return False
def storageKeyFor(self, row_data, key):
"""Which stored column a displayed cell actually reads and writes.
Identity here -- the base table shows what it stores. A flavor can
alias one cell onto another column so a value has ONE home in the
database while still appearing under the heading that makes sense for
that row's tool type.
"""
return key
def deriveValuesForRow(self, row_data):
"""Columns this row can fill in for itself. {column: value}.
Overridden per flavor -- the base table derives nothing, so a VCP
that wires up no rules gets a plain editor with no surprise writes.
Only ever called from setData, and never allowed to overwrite the
cell the operator just edited.
"""
return {}
def _set_dirty(self, dirty):
"""Track unsaved edits staged in memory since the last save/reload;
emits only on an actual state change so listeners (the Save button's
color indicator) aren't hit on every single keystroke."""
dirty = bool(dirty)
if dirty == self._dirty:
return
self._dirty = dirty
self.dirtyChanged.emit(self._dirty)
# ------------------------------------------------------------ actions
def toolDataFromRow(self, row):
tnum = self._tool_no_for_row(row)
return self._tool_table[tnum]
def distinctColumnValues(self, key):
"""Distinct non-empty values currently loaded for `key`, sorted --
used to seed open-vocabulary combo editors with whatever this
shop's own data already uses, alongside a fixed seed list."""
values = set()
for tnum, row in self._tool_table.items():
if tnum == 0:
continue
value = row.get(key)
if value:
values.add(value)
return sorted(values)
def distinctColumnValuesForType(self, key, tool_type):
"""Like distinctColumnValues, but scoped to rows whose `type`
matches `tool_type` -- e.g. only holder_style values this shop has
actually used on grooving tools, not every holder_style value ever
used on any tool."""
t = (tool_type or '').strip().lower()
values = set()
for tnum, row in self._tool_table.items():
if tnum == 0:
continue
if (row.get('type') or '').strip().lower() != t:
continue
value = row.get(key)
if value:
values.add(value)
return sorted(values)
def addTool(self):
try:
tnum = max(k for k in self._tool_table if k != 0) + 1
except ValueError:
tnum = 1
row = len(self._tool_table) - 1
if row == 1000:
return False # max 1000 tools, matches the core widget's limit
self.beginInsertRows(QModelIndex(), row, row)
new_row = self.tt.newTool(tnum=tnum)
for key in self._extras_columns:
new_row.setdefault(key, self.EXTRAS_DEFAULTS.get(key))
for key in self._custom_columns:
new_row.setdefault(key, None)
self._tool_table[tnum] = new_row
self.endInsertRows()
return True
def removeTool(self, row):
tnum = self._tool_no_for_row(row)
self.beginRemoveRows(QModelIndex(), row, row)
del self._tool_table[tnum]
self.endRemoveRows()
return True
def duplicateTool(self, row):
"""Copy one tool -- core, extras, and custom data alike, since
`row` here is already the flattened dict _load_full_table builds --
into the first unused tool number: fills a gap left by a deleted
tool (e.g. deleting T3 out of 1,2,3,4 means the next duplicate
reuses 3) rather than only ever growing past the current max, the
way addTool()'s max+1 does. Same in-memory staging as addTool():
the new row exists only in _tool_table until the next Save.
Pocket resets to -1 (unassigned) rather than copying the source's:
silently cloning a turret/carousel pocket assignment would leave two
tools claiming the same physical slot, a real ATC collision risk,
not just a cosmetic default worth preserving.
Returns the new tool number, or None if the table is already full.
"""
if len(self._tool_table) - 1 >= 1000:
return None # max 1000 tools, matches addTool's limit
tnum = self._tool_no_for_row(row)
new_tnum = 1
while new_tnum in self._tool_table:
new_tnum += 1
new_row = dict(self._tool_table[tnum])
new_row['T'] = new_tnum
new_row['P'] = -1
# Rows display in tool-number sorted order (_tool_no_for_row), not
# insertion order -- a gap-filled tool number can land in the
# middle of the table, not just at the end, so the insertion index
# Qt is told about has to reflect where it will actually sort to.
new_row_index = self.rowForToolNumber(new_tnum, _pending_insert=True)
self.beginInsertRows(QModelIndex(), new_row_index, new_row_index)
self._tool_table[new_tnum] = new_row
self.endInsertRows()
self._set_dirty(True)
return new_tnum
def rowForToolNumber(self, tnum, _pending_insert=False):
"""Inverse of _tool_no_for_row: which row `tnum` occupies (or will
occupy, if `_pending_insert` -- tnum isn't in _tool_table yet)."""
keys = list(self._tool_table)
if _pending_insert:
keys = keys + [tnum]
return sorted(keys).index(tnum) - self.row_offset
def renumberTool(self, row, new_tool_no):
"""Commits immediately (unlike core-column edits, which stage in
memory until Save) -- the DB backend makes this a single UPDATE
that can't lose extras/custom data, so there's no reason to defer
it.
The classic file-based ToolTable plugin has no equivalent atomic
op -- but also no extras/custom data that a delete+recreate could
lose, so that's exactly what it falls back to here.
"""
tnum = self._tool_no_for_row(row)
new_tool_no = int(new_tool_no)
if hasattr(self.tt, 'renumberTool'):
self.tt.renumberTool(tnum, new_tool_no)
else:
table = self.tt.getToolTable()
if new_tool_no != 0 and new_tool_no in table:
raise ValueError('tool %s already exists' % new_tool_no)
core = dict(table[tnum])
core['T'] = new_tool_no
del table[tnum]
table[new_tool_no] = core
self.tt.saveToolTable(table)
self._load_full_table()
return True
def saveToolTable(self):
# Snapshot before the core save: self.tt.saveToolTable() below emits
# tool_table_changed synchronously, which our own _load_full_table
# slot (connected in __init__) reacts to immediately -- that would
# overwrite self._tool_table with freshly-reloaded (pre-extras-save)
# data before the extras/custom loop below ever runs, silently
# discarding those in-memory edits.
snapshot = {tnum: dict(row) for tnum, row in self._tool_table.items()}
if self._db_backed:
# `row` here is core+extras+custom all flattened into one dict
# (see _load_full_table) -- filter down to just the core subset
# before handing it to the core-only saveToolTable() call.
core_table = {tnum: {k: v for k, v in row.items() if k in self._core_columns}
for tnum, row in snapshot.items()}
else:
# Nothing to filter out here: with no extras/custom columns in
# this mode, `row` already *is* the classic plugin's own tool
# dict, untouched -- which includes keys beyond
# self._core_columns that the *configured display columns*
# don't cover but the plugin's own saveToolTable() still
# unconditionally requires (e.g. 'P', inserted into its column
# list internally regardless of what's configured, for the
# .tbl file format). Filtering to self._core_columns here
# stripped 'P' right back out and crashed with KeyError('P').
core_table = snapshot
self.tt.saveToolTable(core_table)
if not self._db_backed:
# The classic plugin's saveToolTable() only writes the .tbl
# file and tells the LinuxCNC *task* to reload it
# (CMD.load_tool_table(), an NML command) -- it does not
# refresh its own Python-side self.TOOL_TABLE cache (what
# getToolTable() below actually returns). That only happens
# asynchronously, ~50ms later, via a QFileSystemWatcher
# noticing the file changed -- relying on that alone races
# _load_full_table() below, which could read stale (pre-save)
# data if it runs first. Force the refresh synchronously
# instead, the same way the DB-backed plugin's own
# saveToolTable() already does internally (it calls its own
# loadToolTable() as part of the same method).
self.tt.loadToolTable()
# One batched transaction each for extras/custom values, not one
# commit per tool (or per tool per custom column) -- see
# saveAllToolExtras/setCustomFieldValues docstrings: the per-tool
# loop this replaced made a single-cell edit take ~1.6s against a
# real 23-tool fixture (47 individual disk-sync'd commits). N/A at
# all for the classic file-based backend -- no extras/custom
# concept there, core_table above is the whole save.
if self._db_backed:
extras_by_tool = {}
custom_by_tool = {}
for tnum, row in snapshot.items():
if tnum == 0:
continue
extras_by_tool[tnum] = {k: row.get(k) for k in self._extras_columns}
if self._custom_columns:
custom_by_tool[tnum] = {key[len(CUSTOM_PREFIX):]: row.get(key)
for key in self._custom_columns}
self._reload_signals_suppressed = True
try:
if extras_by_tool:
self.tt.saveAllToolExtras(extras_by_tool)
if custom_by_tool:
self.tt.setCustomFieldValues(custom_by_tool)
finally:
self._reload_signals_suppressed = False
self._load_full_table() # authoritative final state after everything committed
# Writing the table and telling LinuxCNC to re-read it (self.tt.
# saveToolTable() above) does NOT, on its own, re-apply the
# *currently active* tool's offset -- LinuxCNC only recomputes that
# on a tool change or an explicit G43, not just because the
# underlying table file/row changed. If the tool actually in the
# spindle is what got edited, the DRO keeps showing its old offset
# until G43 is reissued. Same recipe qtpyvcp's own classic
# ToolTable plugin already uses after homing (reload_tool():
# "M61 Q<n> G43") -- just not currently wired to fire after a
# table save the way it is after homing.
if self._interp_idle:
tnum = self.stat.tool_in_spindle
if tnum:
issue_mdi("M61 Q%s G43" % tnum)
return True
def clearToolTable(self):
self.beginRemoveRows(QModelIndex(), 0, 100)
self._tool_table = {0: self._tool_table[0]}
self.endRemoveRows()
return True
def loadToolTable(self):
self._load_full_table()
return True
class _NullableSpinBoxMixin(object):
"""Lets a spin box be left blank, so a nullable column can be cleared
back to NULL rather than only ever down to 0.
A stock QAbstractSpinBox has no concept of "no value": clearing the
text leaves it in the Intermediate validation state, and interpretText()
(fired on focus-out, before the delegate reads the value) silently
restores whatever was there before. Qt's usual escape hatch,
setSpecialValueText(), is not one here -- an *empty* special text
disables the feature outright (QAbstractSpinBoxPrivate only matches it
when specialValueText.size() > 0), so a blank-looking sentinel can't be
expressed that way.
Instead: minimum() doubles as the "no value" sentinel, and the three
text<->value hooks are overridden to map it to/from empty text. That
round trip is closed -- '' -> valueFromText -> minimum ->
textFromValue -> '' -- so interpretText() re-blanking the field is
harmless rather than something to race.
Only for columns the database actually allows to be NULL (extras and
custom fields). Core letters are NOT NULL with a 0.0/0 default, where a
blank cell would be a display fiction the schema can't store.
"""
def validate(self, text, pos):
if not text.strip():
return (QValidator.State.Acceptable, text, pos)
return super(_NullableSpinBoxMixin, self).validate(text, pos)
def valueFromText(self, text):
if not text.strip():
return self.minimum()
return super(_NullableSpinBoxMixin, self).valueFromText(text)
def textFromValue(self, value):
if value <= self.minimum():
return ''
return super(_NullableSpinBoxMixin, self).textFromValue(value)
def stepBy(self, steps):
# Stepping off the sentinel would otherwise land on minimum + 1
# step (-99999.9999), which is nobody's intent; a blank cell that
# gets nudged means "start at zero".
if self.isEmpty():
self.setValue(0)
return
super(_NullableSpinBoxMixin, self).stepBy(steps)
def isEmpty(self):
return self.value() <= self.minimum()
def setEmpty(self):
self.setValue(self.minimum())
class NullableDoubleSpinBox(_NullableSpinBoxMixin, QDoubleSpinBox):
pass
class NullableSpinBox(_NullableSpinBoxMixin, QSpinBox):
pass
class ToolTableEditorDelegate(QStyledItemDelegate):
def __init__(self, model, table=None):
super(ToolTableEditorDelegate, self).__init__()
self._model = model
self._table = table
self._padding = ' ' * 2
def _sourceRowForIndex(self, index):
"""Map a (possibly proxy) row index back to the model's own row
numbering. Needed because some editors' choices depend on the
row's *data* (type/insert_shape), not just the column: index.row()
is the view's visual (post-sort) row, which only matches the
model's internal row order when the table isn't currently sorted
by some other column."""
proxy = getattr(self._table, 'proxy_model', None)
if proxy is not None and index.model() is proxy:
index = proxy.mapToSource(index)
return index.row()
def _isRequiredAndEmpty(self, index):
"""True when this cell is one of the amber ones."""
model = self._model
try:
key = model._visible_columns[index.column()]
tnum = model._tool_no_for_row(self._sourceRowForIndex(index))
except Exception:
return False
return bool(model._is_required_and_empty(key, tnum))
def initStyleOption(self, option, index):
"""Keep the amber visible under a selection.
Selecting a row paints the highlight straight over BackgroundRole, so
the one cell the operator most needs to see goes blue like every other
cell in the row -- and the amber, which the manual describes as the
only warning that a value is missing, disappears exactly when they
click the row to look at it.
Dropping State_Selected for that cell alone leaves the rest of the row
selected normally, and takes the highlight's text color with it, so
the value stays dark-on-amber and readable rather than white-on-amber.
"""
super(ToolTableEditorDelegate, self).initStyleOption(option, index)
if not self._isRequiredAndEmpty(index):
return
option.state &= ~QStyle.StateFlag.State_Selected
option.backgroundBrush = QBrush(QColor(*self._model.REQUIRED_EMPTY_BG))
option.palette.setColor(option.palette.ColorRole.Text, QColor(26, 26, 26))
option.palette.setColor(option.palette.ColorRole.HighlightedText,
QColor(26, 26, 26))
def displayText(self, value, locale):
if isinstance(value, float):
# Explicit, named setting (not a bare magic number) so every
# float column -- core, extras, or a custom float field --
# displays at the same precision on purpose, not by accident.
return f"{value:.{FLOAT_DECIMALS}f}"
if value is None:
return ''
return f"{self._padding}{value}"
def sizeHint(self, option, index):
# ResizeToContents sizes columns off this; the base implementation
# has no margin, so content butts right up against the divider.
size = super(ToolTableEditorDelegate, self).sizeHint(option, index)
size.setWidth(size.width() + 10) # 5px each side
return size
def createEditor(self, parent, option, index):
key = self._model._visible_columns[index.column()]
group = self._model.columnGroup(key)
if key in self._model.STRICT_ENUM_OPTIONS:
# DB CHECK-constrained: a strict pick-list, not editable free
# text -- the user cannot enter a value the database would
# reject on save.
editor = QComboBox(parent)
editor.setFrame(False)
editor.setMaxVisibleItems(COMBO_MAX_VISIBLE_ITEMS)
editor.addItems(self._model.STRICT_ENUM_OPTIONS[key])
self._popOpenOnceShown(editor)
return editor
if key in self._model.BOOL_EXTRAS:
# Boolean extras (e.g. a mill's ATC storable flag): same
# owned-round-trip True/False combo as bool custom fields --
# see BOOL_EDITOR_PROP for why this isn't a bare checkbox
# relying on Qt's QVariant(bool) marshaling.
editor = QComboBox(parent)
editor.setFrame(False)
editor.addItems(['True', 'False'])
editor.setProperty(BOOL_EDITOR_PROP, True)
self._popOpenOnceShown(editor)
return editor
if key in self._model.OPEN_VOCAB_SEED_OPTIONS:
# Open vocabulary in practice -- editable combo, but *which*
# choices are offered is discriminated by this row's own data
# rather than dumping every value ever used for any tool into
# one list -- see ToolTableEditorModel.openVocabOptionsFor.
row_data = self._model.toolDataFromRow(self._sourceRowForIndex(index))
options = self._model.openVocabOptionsFor(key, row_data)
current_value = row_data.get(key)
if not options:
# e.g. a lathe's drill/tap rows have no insert-shape/holder
# concept at all -- a combo with nothing valid to offer is
# worse than plain free text.
editor = QLineEdit(parent)
editor.setFrame(False)
return editor
editor = QComboBox(parent)
editor.setFrame(False)
editor.setEditable(True)
# Qt auto-attaches a QCompleter to every editable combobox, which
# pops its own inline autocomplete list -- a second popup
# mechanism fighting our explicit showPopup() below for focus
# (the seeded item list below is already the intended picker;
# there's no free-text-filter feature here to complete against).
editor.setCompleter(None)
editor.setMaxVisibleItems(COMBO_MAX_VISIBLE_ITEMS)
seen = set()
for value in options + ([current_value] if current_value else []):
if value and value not in seen:
seen.add(value)
editor.addItem(value)
# 200ms, not 0: opening the popup this way (rather than Qt's own
# native double-click handling, which non-editable combos like
# a strict-enum column get for free) races the initiating
# double-click's own trailing mouse-release event still
# draining through the queue if fired too soon (confirmed live
# -- 60ms wasn't enough, 200ms is stable).
self._popOpenOnceShown(editor, delay_ms=200)
return editor
if group == 'custom':
# Typing "3.5" into an int-typed custom column would otherwise
# be accepted at the cell (plain QLineEdit for every custom
# column regardless of its declared value_type), only to blow
# up later -- qtpyvcp's _cast_custom_value() does int(raw) on
# the next load, which raises ValueError on a non-integer
# string instead of truncating. Guard at entry instead: pick
# the editor widget from the field's own value_type so an
# invalid entry can't be typed in the first place.
value_type = self._model._custom_value_types.get(key)
if value_type == 'int':
# Nullable: custom values round-trip through
# _cast_custom_value(), which maps None to None (no row
# value), so a cleared cell is storable as such.
editor = NullableSpinBox(parent)
editor.setFrame(False)
editor.setAlignment(Qt.AlignmentFlag.AlignCenter)
editor.setRange(-999999, 999999)
return editor
if value_type == 'float':
editor = NullableDoubleSpinBox(parent)
editor.setFrame(False)
editor.setAlignment(Qt.AlignmentFlag.AlignCenter)
editor.setDecimals(FLOAT_DECIMALS)
editor.setRange(-100000, 100000)
return editor
if value_type == 'bool':
editor = QComboBox(parent)
editor.setFrame(False)
editor.addItems(['True', 'False'])
editor.setProperty(BOOL_EDITOR_PROP, True)
self._popOpenOnceShown(editor)
return editor
# text (or an unrecognized/future value_type): free text, same
# as always.
editor = QLineEdit(parent)
editor.setFrame(False)
return editor
if key == 'R' or key in self._model.TEXT_EXTRAS:
editor = QLineEdit(parent)
editor.setFrame(False)
return editor
if key in ('T', 'P', 'Q'):
editor = QSpinBox(parent)
editor.setFrame(False)
editor.setAlignment(Qt.AlignmentFlag.AlignCenter)
editor.setMaximum(9 if key == 'Q' else 99999)
return editor
# everything else (X/Z/D/I/J offsets, numeric extras) is a float.
# Extras are nullable, so they can be cleared back to blank -- a
# tool that has no Max DOC reads differently from one measured at
# zero, and the yellow "required but missing" highlight is only
# honest if a value can actually be taken back out. The core
# letters are NOT NULL with a 0.0 default and stay plain.
if group == 'extras':
editor = NullableDoubleSpinBox(parent)
else:
editor = QDoubleSpinBox(parent)
editor.setFrame(False)
editor.setAlignment(Qt.AlignmentFlag.AlignCenter)
editor.setDecimals(FLOAT_DECIMALS)
editor.setRange(-100000, 100000)
return editor
def setEditorData(self, editor, index):
if isinstance(editor, QComboBox) and editor.property(BOOL_EDITOR_PROP):
value = bool(index.data(Qt.ItemDataRole.EditRole))
editor.setCurrentIndex(editor.findText('True' if value else 'False'))
return
if isinstance(editor, _NullableSpinBoxMixin):
# Qt's own implementation would push a NULL through
# QVariant->setValue() as 0, i.e. open an unset cell already
# showing a fabricated zero -- which is exactly the value the
# user is trying not to have.
value = index.data(Qt.ItemDataRole.EditRole)
try:
# Coerced through the spin box's own numeric type (float
# for the double box, int for the plain one) so a legacy
# row carrying the number as text still opens on its value
# rather than raising TypeError inside setValue's C++ slot.
editor.setValue(type(editor.minimum())(value))
except (TypeError, ValueError):
editor.setEmpty() # None, '', or unparseable -> blank
return
super(ToolTableEditorDelegate, self).setEditorData(editor, index)
def setModelData(self, editor, model, index):
if isinstance(editor, QComboBox) and editor.property(BOOL_EDITOR_PROP):
model.setData(index, editor.currentText() == 'True',
Qt.ItemDataRole.EditRole)
return
if isinstance(editor, _NullableSpinBoxMixin):
editor.interpretText() # commit in-progress typing to value()
model.setData(index,
None if editor.isEmpty() else editor.value(),
Qt.ItemDataRole.EditRole)
return
super(ToolTableEditorDelegate, self).setModelData(editor, model, index)
@staticmethod
def _popOpenOnceShown(combo, delay_ms=0):
"""Open the dropdown the instant the editor appears, instead of
requiring a second tap on the (tiny, easy-to-miss on a touchscreen)
arrow button after the first double-click/tap creates it.
delay_ms matters for editable combos specifically: non-editable
ones (e.g. a strict-enum column) open fine at 0ms because Qt's own
native mouseDoubleClickEvent handling opens those synchronously as
part of the same double-click, which correctly accounts for the
initiating click's mouse button still being logically "down".
Editable combos never auto-open natively, so *our* showPopup()
call is the only thing opening them -- firing it before that
button-down state has fully drained through the event queue races
Qt's popup grab into treating the eventual release as "dismiss",
closing it almost immediately (confirmed live; see callers for the
delay each editor type actually needs).
"""
def _fire():
# The popup defaults to the combo's own width (i.e. the cell/
# column's width), which the item padding above then eats into --
# long entries were getting elided even though the column
# itself is wide enough to show them as plain (non-editing)
# cell text. Widen the popup to actually fit its widest item
# instead of inheriting the cell's (possibly narrower) width.
view = combo.view()
if view is not None:
hint = view.sizeHintForColumn(0)
if hint > 0:
view.setMinimumWidth(hint + 24)
combo.showPopup()
QTimer.singleShot(delay_ms, _fire)