1877 lines
76 KiB
Python
1877 lines
76 KiB
Python
"""
|
|
Memory browser: browse / filter / unfold / recall / save / export an
|
|
Assembly's memory (see `eco.elements.memory.Memory`) from Qt, ipywidgets, or
|
|
Tk -- the GUI counterpart of the terminal `some_assembly.memory()` picker.
|
|
|
|
This module never reimplements `Memory`'s own logic. `memorize()`,
|
|
`recall()`, `get_memory()`, `get_recall_dict()`, `ancestor_memories()`,
|
|
`get_ancestor_recall_dict()` (`eco/elements/memory.py`) stay the single
|
|
source of truth for what gets read from / written to disk (and to elog);
|
|
everything here only calls them and renders the results, so the on-disk
|
|
memory format and every existing `.memory.*` call site are completely
|
|
unaffected by this module's existence.
|
|
|
|
from eco.widgets.memory_widget import make_memory_browser
|
|
make_memory_browser(my_assembly) # picks Qt/ipywidgets/Tk, like Assembly.widget()
|
|
|
|
Also reachable via a "memories" button on the assembly display widget itself
|
|
(`eco.widgets.display_qt`/`display_widget`/`display_tk`), shown only when the
|
|
assembly actually has a `.memory` (i.e. `eco.elements.memory.global_memory_dir`
|
|
is set) -- assemblies without one see no change at all.
|
|
|
|
Besides an object's own stored memories, the browser can optionally also
|
|
show memories stored on an *ancestor* Assembly (a "show parent memories too"
|
|
toggle) that happen to also cover this object's own components -- see
|
|
`Memory.ancestor_memories`/`get_ancestor_recall_dict`. Such rows are tagged
|
|
and colour-coded per ancestor, with a legend, and are otherwise handled
|
|
identically to the object's own memories (loaded, diffed, and recalled the
|
|
same way -- only ever through *this* object's own `.memory`, applying just
|
|
the slice of the parent's snapshot that belongs to this object).
|
|
|
|
Saving a new memory can optionally be narrowed down to hand-picked items
|
|
instead of capturing everything the chosen selection resolves to -- an
|
|
opt-in "choose items…" checkbox (unchecked by default) next to Save reveals
|
|
a per-item checklist (via `Memory.get_memorize_candidates`), and only the
|
|
checked items are passed on to `Memory.memorize(pick_items=[...])`. Same
|
|
idea as the terminal's own `memorize(pick_items=True)`, just via an inline
|
|
GUI checklist instead of a `simple_term_menu` prompt, since a terminal menu
|
|
can't run inside a GUI event loop.
|
|
|
|
A memory can optionally be tagged as a named preset (`Memory.memorize`'s
|
|
`preset_varname`, reachable afterwards as `<assembly>.memory.presets.<name>`)
|
|
-- an optional "preset name" field next to Save does this at creation time;
|
|
an existing stored entry's tag can also be set, changed, or removed
|
|
afterwards (`Memory.set_preset_name`) via the "Preset tag" field shown once
|
|
that entry is loaded from the overview list. Tagged entries show their
|
|
preset name in the overview list like any other field -- a preset is just a
|
|
regular memory entry with that one extra tag, never a separate store.
|
|
"""
|
|
import itertools
|
|
import traceback
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from eco.elements.memory import name2obj
|
|
|
|
_JSON_FILETYPES_QT = "JSON (*.json);;All files (*)"
|
|
_JSON_FILETYPES_TK = [("JSON", "*.json"), ("all files", "*")]
|
|
|
|
# pastel palette for colour-coding overview rows sourced from a parent's
|
|
# memory (one colour per distinct ancestor, cycled in first-seen order) --
|
|
# light enough to work as a background under plain black text in Qt/Tk/
|
|
# ipywidgets' default (light) themes.
|
|
_ANCESTOR_PALETTE = [
|
|
"#ffe0b2", "#c8e6c9", "#bbdefb", "#e1bee7", "#ffccbc", "#d7ccc8",
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# shared, toolkit-agnostic data model -- pure python, reused by all backends
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _overview_rows(memory):
|
|
"""[{key, date, message, groups}, ...] for every stored memory, newest
|
|
first -- the same index data `Memory.__str__`/`Memory.__call__` already
|
|
render, just as structured rows instead of a formatted string."""
|
|
memory.setup_path()
|
|
mem = memory._memories()
|
|
rows = []
|
|
for key, content in mem.items():
|
|
try:
|
|
date = datetime.fromisoformat(key)
|
|
except Exception:
|
|
date = None
|
|
cats = content.get("categories", memory.categories)
|
|
groups = sorted(set(itertools.chain.from_iterable(cats.values())))
|
|
rows.append(
|
|
{
|
|
"key": key,
|
|
"date": date,
|
|
"message": content.get("message", ""),
|
|
"groups": groups,
|
|
"presetname": content.get("presetname") or "",
|
|
}
|
|
)
|
|
rows.reverse()
|
|
return rows
|
|
|
|
|
|
def _ancestor_overview_rows(memory, selection=None):
|
|
"""[{key, date, message, groups, ancestor, ancestor_name}, ...] -- one
|
|
row per memory stored *on an ancestor Assembly* (see
|
|
`Memory.ancestor_memories`) that actually covers this object (i.e.
|
|
`Memory.get_ancestor_recall_dict` for it is non-empty). Ancestors in
|
|
nearest-first order; each ancestor's own entries newest-first. Purely
|
|
additive/opt-in browsing -- never touches this object's own store, and
|
|
reads every ancestor's every stored entry to check relevance, so this is
|
|
only meant to be called on demand (a "show parent memories too" toggle),
|
|
not on every refresh."""
|
|
rows = []
|
|
for ancestor, ancestor_memory in memory.ancestor_memories():
|
|
ancestor_name = ancestor.alias.get_full_name()
|
|
for row in _overview_rows(ancestor_memory):
|
|
try:
|
|
entry = ancestor_memory.get_memory(key=row["key"])
|
|
sliced = memory.get_ancestor_recall_dict(
|
|
ancestor, entry, selection=selection
|
|
)
|
|
except Exception:
|
|
continue
|
|
if not sliced:
|
|
continue
|
|
tagged = dict(row)
|
|
tagged["ancestor"] = ancestor
|
|
tagged["ancestor_name"] = ancestor_name
|
|
rows.append(tagged)
|
|
return rows
|
|
|
|
|
|
def _ancestor_color_map(ancestor_names):
|
|
"""Stable {ancestor_full_name: hex_color} assignment, cycling
|
|
`_ANCESTOR_PALETTE` in first-seen order -- used to colour overview rows
|
|
sourced from a parent's memory, with a legend mapping colour back to
|
|
ancestor name."""
|
|
names = []
|
|
for n in ancestor_names:
|
|
if n not in names:
|
|
names.append(n)
|
|
return {n: _ANCESTOR_PALETTE[i % len(_ANCESTOR_PALETTE)] for i, n in enumerate(names)}
|
|
|
|
|
|
def _available_recall_groups(memory, mem_dict):
|
|
"""Recall-eligible selections (see `Memory.categories["recall"]`) that
|
|
a loaded, per-entry memory dict actually captured -- may be a subset of
|
|
`memory.categories["recall"]` (an older/foreign memory can predate a
|
|
group, or use a since-renamed name; see `Memory.get_recall_dict`'s
|
|
docstring for a real example), and is deliberately narrower than "every
|
|
selection this entry has" (`memory.entry_selection_names`): track-only
|
|
groups (e.g. plain "display") are bookkeeping, not meant to be offered
|
|
as a recall target in this per-group checkbox list."""
|
|
return [g for g in memory.categories["recall"] if g in memory.entry_selection_names(mem_dict)]
|
|
|
|
|
|
def _filter_overview_rows(rows, tag):
|
|
"""Narrow `_overview_rows`/`_ancestor_overview_rows` output to entries
|
|
whose stored `categories` include `tag` -- the "which memories are even
|
|
listed" filter, distinct from `_diff_rows`'s group filter which only
|
|
ever applies to rows within one already-opened memory. `tag` of `None`
|
|
or "all" returns every row unfiltered."""
|
|
if not tag or tag == "all":
|
|
return rows
|
|
return [r for r in rows if tag in r["groups"]]
|
|
|
|
|
|
def _known_selection_tags(rows):
|
|
"""Every distinct group tag seen across a set of overview rows, sorted,
|
|
always including "settings" (today's universal default) even when this
|
|
particular assembly has no stored memories yet -- so a selection filter
|
|
control always has a sensible option to land on."""
|
|
tags = set()
|
|
for r in rows:
|
|
tags.update(r["groups"])
|
|
tags.add("settings")
|
|
return sorted(tags)
|
|
|
|
|
|
def _diff_str(present, memory_value, changed, available):
|
|
"""Numeric-delta string mirroring `Memory.get_memory_difference_str`'s
|
|
own "difference" column (`memory_value - present`, "+g"-formatted),
|
|
reused here as plain data instead of an ANSI/HTML table string."""
|
|
if not available:
|
|
return "?"
|
|
if not changed:
|
|
return "=="
|
|
try:
|
|
return f"{memory_value - present:+g}"
|
|
except TypeError:
|
|
return "changed"
|
|
|
|
|
|
def _diff_rows(memory, mem_dict, selection=None):
|
|
"""[{name, present, memory, changed, available, group, diff}, ...] for
|
|
a loaded, per-entry memory dict, reusing `Memory.get_recall_dict` for
|
|
the selection merge -- the same present-vs-recall comparison
|
|
`Memory.get_memory_difference_str` does internally, exposed as data
|
|
instead of a formatted table string."""
|
|
rec = memory.get_recall_dict(mem_dict, selection=selection)
|
|
rows = []
|
|
for name, recall_value in rec.items():
|
|
try:
|
|
present_value = name2obj(memory.obj_parent(), name).get_current_value()
|
|
available = True
|
|
except Exception:
|
|
present_value = None
|
|
available = False
|
|
changed = available and (present_value != recall_value)
|
|
rows.append(
|
|
{
|
|
"name": name,
|
|
"present": present_value,
|
|
"memory": recall_value,
|
|
"changed": changed,
|
|
"available": available,
|
|
"group": name.split(".", 1)[0],
|
|
"diff": _diff_str(present_value, recall_value, changed, available),
|
|
}
|
|
)
|
|
return rows
|
|
|
|
|
|
def _group_rows(rows):
|
|
"""Group `_diff_rows` output by top-level sub-component (same key
|
|
`Assembly.get_display_str()`/`section_row_styles` use), preserving
|
|
first-seen order -- the "unfold sub-components" grouping."""
|
|
groups = {}
|
|
for row in rows:
|
|
groups.setdefault(row["group"], []).append(row)
|
|
return groups
|
|
|
|
|
|
def _foldable_sections(rows):
|
|
"""Group `_diff_rows` output for rendering: a group with more than one
|
|
row becomes a real foldable section; a group with exactly one row is
|
|
reported as a bare row instead -- spending a whole extra fold/unfold
|
|
header row on a single value is pure overhead for no benefit (same
|
|
"singleton groups don't get special treatment" rule
|
|
`eco.utilities.tables.section_row_styles` already applies to the
|
|
terminal table, just never carried over here until now). Returns an
|
|
ordered list of `("section", group_name, rows)` / `("row", None, row)`
|
|
tuples, in first-seen order."""
|
|
grouped = _group_rows(rows)
|
|
out = []
|
|
for name, group_rows in grouped.items():
|
|
if len(group_rows) > 1:
|
|
out.append(("section", name, group_rows))
|
|
else:
|
|
out.append(("row", None, group_rows[0]))
|
|
return out
|
|
|
|
|
|
def _default_export_name(memory, label):
|
|
base = memory.obj_parent().alias.get_full_name()
|
|
safe = "".join(c if (c.isalnum() or c in "._-") else "_" for c in str(label))
|
|
return f"{base}_{safe}.json"
|
|
|
|
|
|
def _export_to_file(mem_dict, path):
|
|
"""Write a loaded memory dict to an arbitrary file, in exactly the same
|
|
on-disk shape `Memory.memorize()` writes for every stored entry
|
|
(`AdjustableFS`'s `{"value": ...}` wrapper) -- so the exported file can
|
|
later be handed straight back to `Memory.recall(input_obj=path)` /
|
|
`Memory.get_memory(input_obj=path)`, both of which already read that
|
|
format directly. Purely additive: never touches the internal memory
|
|
store, which `memorize()` keeps writing to on its own."""
|
|
from eco.elements.adjustable import AdjustableFS
|
|
|
|
path = Path(path)
|
|
target = AdjustableFS(path, default_value=mem_dict)
|
|
# AdjustableFS only writes `default_value` when the file didn't already
|
|
# exist; write explicitly so exporting to an existing path also works.
|
|
target._write_value(mem_dict)
|
|
return path
|
|
|
|
|
|
def _export_label(state):
|
|
"""Best-effort label for the currently loaded memory, for a default
|
|
export filename -- whichever of its own stored key, the ancestor it was
|
|
sourced from, or the file it was loaded from is actually set."""
|
|
if state.get("key"):
|
|
return state["key"]
|
|
if state.get("source_ancestor_name"):
|
|
return state["source_ancestor_name"]
|
|
if state.get("path"):
|
|
return Path(state["path"]).stem
|
|
return "memory"
|
|
|
|
|
|
def _format_value(value):
|
|
import enum
|
|
|
|
if isinstance(value, enum.Enum):
|
|
return value.name
|
|
return str(value)
|
|
|
|
|
|
def _recall_error_status(recall_error):
|
|
"""Status text for a `Memory.recall()` call that raised. `recall()` sets
|
|
every changed value first and only then waits for each to confirm -- an
|
|
exception here (e.g. a slow/flaky device's confirmation read timing out)
|
|
does not mean nothing happened; some or all of the underlying
|
|
`set_target_value` calls may already have landed. Callers refresh the
|
|
display regardless of this exception, so it reflects reality either
|
|
way; this message just avoids a flat, potentially misleading "failed"."""
|
|
return (
|
|
f"recall command sent, but confirming completion failed "
|
|
f"({recall_error}) -- check values below, the change may still "
|
|
f"have applied"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Qt backend
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class MemoryBrowserQt:
|
|
def __init__(self, assembly, parent=None):
|
|
self.assembly = assembly
|
|
self.memory = assembly.memory
|
|
self.parent = parent
|
|
self.window = None
|
|
self._state = {
|
|
"key": None, "path": None, "mem": None, "rows": [], "checked": {},
|
|
"source_ancestor_name": None,
|
|
}
|
|
self._group_checks = {}
|
|
self._build()
|
|
|
|
def _build(self):
|
|
from qtpy import QtWidgets, QtCore
|
|
|
|
self.window = QtWidgets.QWidget(self.parent)
|
|
self.window.setWindowTitle(
|
|
f"Memories - {self.assembly.alias.get_full_name()}"
|
|
)
|
|
if self.parent is None:
|
|
self.window.setAttribute(QtCore.Qt.WA_QuitOnClose, False)
|
|
# so this.window really is destroyed (not just hidden) when closed
|
|
# via its own native close (X) button -- otherwise the destroyed
|
|
# hook below never fires there, and a caller's "already open?
|
|
# just raise it" check (see AxisPTZStreamQt._open_memories) keeps
|
|
# seeing a stale, non-None .window that's actually closed
|
|
self.window.setAttribute(QtCore.Qt.WA_DeleteOnClose, True)
|
|
outer = QtWidgets.QVBoxLayout(self.window)
|
|
|
|
# --- overview list -------------------------------------------------
|
|
outer.addWidget(QtWidgets.QLabel("<b>Stored memories</b>"))
|
|
|
|
overview_filter_row = QtWidgets.QHBoxLayout()
|
|
overview_filter_row.addWidget(QtWidgets.QLabel("Selection:"))
|
|
self.overview_selection_combo = QtWidgets.QComboBox()
|
|
self.overview_selection_combo.setEditable(True)
|
|
self.overview_selection_combo.currentTextChanged.connect(self._refresh_overview)
|
|
overview_filter_row.addWidget(self.overview_selection_combo)
|
|
self.show_parents_cb = QtWidgets.QCheckBox("show parent memories too")
|
|
self.show_parents_cb.stateChanged.connect(self._refresh_overview)
|
|
overview_filter_row.addWidget(self.show_parents_cb)
|
|
overview_filter_row.addStretch(1)
|
|
outer.addLayout(overview_filter_row)
|
|
|
|
self.list = QtWidgets.QTreeWidget()
|
|
self.list.setMaximumHeight(160)
|
|
self.list.setHeaderLabels(["Date", "Message", "Groups", "Preset"])
|
|
self.list.setRootIsDecorated(False)
|
|
self.list.setAlternatingRowColors(True)
|
|
self.list.itemClicked.connect(self._on_pick_stored)
|
|
outer.addWidget(self.list)
|
|
|
|
self.legend_label = QtWidgets.QLabel("")
|
|
self.legend_label.setWordWrap(True)
|
|
outer.addWidget(self.legend_label)
|
|
|
|
load_row = QtWidgets.QHBoxLayout()
|
|
load_file_btn = QtWidgets.QPushButton("Load from file…")
|
|
load_file_btn.clicked.connect(self._on_load_file)
|
|
refresh_btn = QtWidgets.QPushButton("Refresh list")
|
|
refresh_btn.clicked.connect(self._refresh_overview)
|
|
load_row.addWidget(load_file_btn)
|
|
load_row.addWidget(refresh_btn)
|
|
load_row.addStretch(1)
|
|
delete_entry_btn = QtWidgets.QPushButton("Delete this memory entry…")
|
|
delete_entry_btn.setStyleSheet("color: #c62828;")
|
|
delete_entry_btn.setToolTip(
|
|
"permanently remove the WHOLE loaded memory entry -- every "
|
|
"value it captured, not just its preset tag"
|
|
)
|
|
delete_entry_btn.clicked.connect(self._on_delete_entry)
|
|
load_row.addWidget(delete_entry_btn)
|
|
outer.addLayout(load_row)
|
|
|
|
preset_tag_row = QtWidgets.QHBoxLayout()
|
|
preset_tag_row.addWidget(QtWidgets.QLabel("Preset tag (for loaded entry):"))
|
|
self.preset_tag_edit = QtWidgets.QLineEdit()
|
|
self.preset_tag_edit.setPlaceholderText("empty clears the tag")
|
|
preset_tag_row.addWidget(self.preset_tag_edit)
|
|
apply_preset_btn = QtWidgets.QPushButton("Apply")
|
|
apply_preset_btn.setToolTip(
|
|
"set/change/clear just the preset name tag on the loaded entry "
|
|
"-- does not touch its captured values or delete it"
|
|
)
|
|
apply_preset_btn.clicked.connect(self._on_apply_preset_name)
|
|
preset_tag_row.addWidget(apply_preset_btn)
|
|
preset_tag_row.addStretch(1)
|
|
outer.addLayout(preset_tag_row)
|
|
|
|
# --- filters ---------------------------------------------------
|
|
self.filter_box = QtWidgets.QGroupBox("Filters (within an opened memory)")
|
|
filter_layout = QtWidgets.QVBoxLayout(self.filter_box)
|
|
self.group_row = QtWidgets.QHBoxLayout()
|
|
self.group_row.addWidget(QtWidgets.QLabel("Keyword groups:"))
|
|
filter_layout.addLayout(self.group_row)
|
|
self.changed_only_cb = QtWidgets.QCheckBox("show changed only")
|
|
self.changed_only_cb.setChecked(True)
|
|
self.changed_only_cb.stateChanged.connect(self._refresh_detail)
|
|
filter_layout.addWidget(self.changed_only_cb)
|
|
outer.addWidget(self.filter_box)
|
|
|
|
# --- detail tree -----------------------------------------------
|
|
outer.addWidget(QtWidgets.QLabel("<b>Details</b> (checkbox selects for recall)"))
|
|
self.tree = QtWidgets.QTreeWidget()
|
|
self.tree.setHeaderLabels(["name", "present", "memory", "diff", "status"])
|
|
self.tree.itemChanged.connect(self._on_tree_item_changed)
|
|
outer.addWidget(self.tree)
|
|
|
|
select_row = QtWidgets.QHBoxLayout()
|
|
select_all_btn = QtWidgets.QPushButton("select all")
|
|
select_all_btn.clicked.connect(lambda: self._set_all_checked(True))
|
|
select_none_btn = QtWidgets.QPushButton("select none")
|
|
select_none_btn.clicked.connect(lambda: self._set_all_checked(False))
|
|
recall_btn = QtWidgets.QPushButton("Recall selected values")
|
|
recall_btn.clicked.connect(self._on_recall)
|
|
export_btn = QtWidgets.QPushButton("Export to file…")
|
|
export_btn.clicked.connect(self._on_export)
|
|
select_row.addWidget(select_all_btn)
|
|
select_row.addWidget(select_none_btn)
|
|
select_row.addWidget(recall_btn)
|
|
select_row.addWidget(export_btn)
|
|
select_row.addStretch(1)
|
|
outer.addLayout(select_row)
|
|
|
|
# --- save new memory ---------------------------------------------
|
|
save_box = QtWidgets.QGroupBox("Save current state as a new memory")
|
|
save_vbox = QtWidgets.QVBoxLayout(save_box)
|
|
save_layout = QtWidgets.QHBoxLayout()
|
|
self.message_edit = QtWidgets.QLineEdit()
|
|
self.message_edit.setPlaceholderText("message for this memory…")
|
|
save_layout.addWidget(self.message_edit)
|
|
save_layout.addWidget(QtWidgets.QLabel("selection:"))
|
|
self.save_selection_combo = QtWidgets.QComboBox()
|
|
self.save_selection_combo.setEditable(True)
|
|
self.save_selection_combo.addItems(["settings", "all"])
|
|
self.save_selection_combo.setCurrentText("settings")
|
|
self.save_selection_combo.setToolTip(
|
|
"which status_collection selection to capture -- \"settings\" "
|
|
"(default), \"all\" (everything currently registered), or a "
|
|
"custom tag"
|
|
)
|
|
save_layout.addWidget(self.save_selection_combo)
|
|
self.elog_cb = QtWidgets.QCheckBox("post to elog")
|
|
self.elog_cb.setChecked(True)
|
|
save_btn = QtWidgets.QPushButton("Save")
|
|
save_btn.clicked.connect(self._on_save)
|
|
save_layout.addWidget(self.elog_cb)
|
|
save_layout.addWidget(save_btn)
|
|
save_vbox.addLayout(save_layout)
|
|
|
|
preset_layout = QtWidgets.QHBoxLayout()
|
|
preset_layout.addWidget(QtWidgets.QLabel("preset name:"))
|
|
self.save_preset_edit = QtWidgets.QLineEdit()
|
|
self.save_preset_edit.setPlaceholderText("optional -- leave empty for a plain memory")
|
|
self.save_preset_edit.setToolTip(
|
|
"tag this memory as a named preset, reachable afterwards as "
|
|
"<assembly>.memory.presets.<name>(). Reusing an existing name "
|
|
"redirects that preset to this new entry; the old entry stays "
|
|
"around as a plain memory."
|
|
)
|
|
preset_layout.addWidget(self.save_preset_edit)
|
|
save_vbox.addLayout(preset_layout)
|
|
|
|
# opt-in, non-default item picker: unchecked, this row/list stays
|
|
# hidden and Save captures everything the selection resolves to,
|
|
# exactly as before this existed.
|
|
pick_row = QtWidgets.QHBoxLayout()
|
|
self.pick_items_cb = QtWidgets.QCheckBox("choose items…")
|
|
self.pick_items_cb.setToolTip(
|
|
"narrow this save down to hand-picked items instead of "
|
|
"capturing everything the selection above resolves to"
|
|
)
|
|
self.pick_items_cb.stateChanged.connect(self._on_toggle_pick_items)
|
|
pick_row.addWidget(self.pick_items_cb)
|
|
refresh_items_btn = QtWidgets.QPushButton("Refresh items")
|
|
refresh_items_btn.clicked.connect(self._refresh_pick_items)
|
|
pick_row.addWidget(refresh_items_btn)
|
|
pick_all_btn = QtWidgets.QPushButton("all")
|
|
pick_all_btn.clicked.connect(lambda: self._set_all_pick_items(True))
|
|
pick_row.addWidget(pick_all_btn)
|
|
pick_none_btn = QtWidgets.QPushButton("none")
|
|
pick_none_btn.clicked.connect(lambda: self._set_all_pick_items(False))
|
|
pick_row.addWidget(pick_none_btn)
|
|
pick_row.addStretch(1)
|
|
save_vbox.addLayout(pick_row)
|
|
|
|
self.pick_items_list = QtWidgets.QListWidget()
|
|
self.pick_items_list.setMaximumHeight(140)
|
|
self.pick_items_list.setVisible(False)
|
|
save_vbox.addWidget(self.pick_items_list)
|
|
outer.addWidget(save_box)
|
|
|
|
close_btn = QtWidgets.QPushButton("Close")
|
|
close_btn.clicked.connect(self.window.close)
|
|
outer.addWidget(close_btn)
|
|
|
|
self.status_label = QtWidgets.QLabel("")
|
|
outer.addWidget(self.status_label)
|
|
|
|
self._refresh_overview()
|
|
self.window.resize(780, 760)
|
|
# so a stale, already-deleted window can't be mistaken for a still-
|
|
# open one (e.g. by a caller's "already open, just raise it" check
|
|
# -- see eco.widgets.camera_stream_qt.AxisPTZStreamQt._open_memories)
|
|
# when the user closes this via the window's own native close (X)
|
|
# button rather than through this class's own Close button/API
|
|
self.window.destroyed.connect(lambda *a: setattr(self, "window", None))
|
|
self.window.show()
|
|
|
|
def _set_status(self, text, error=False):
|
|
color = "#c62828" if error else "#2e7d32"
|
|
self.status_label.setText(f"<span style='color:{color}'>{text}</span>")
|
|
|
|
def _refresh_overview(self, *_a):
|
|
# *_a swallows whatever positional arg the triggering Qt signal
|
|
# passes (a bool from a button, a str from the selection combo) --
|
|
# neither is used, both re-read live state.
|
|
from qtpy import QtWidgets, QtGui
|
|
|
|
own_rows = _overview_rows(self.memory)
|
|
all_rows = list(own_rows)
|
|
if self.show_parents_cb.isChecked():
|
|
all_rows += _ancestor_overview_rows(self.memory)
|
|
tags = _known_selection_tags(all_rows)
|
|
combo = self.overview_selection_combo
|
|
current = combo.currentText() or "settings"
|
|
combo.blockSignals(True)
|
|
combo.clear()
|
|
combo.addItems(["all"] + tags)
|
|
idx = combo.findText(current)
|
|
if idx >= 0:
|
|
combo.setCurrentIndex(idx)
|
|
else:
|
|
combo.setEditText(current)
|
|
combo.blockSignals(False)
|
|
|
|
self._overview = _filter_overview_rows(all_rows, current)
|
|
ancestor_names = [r["ancestor_name"] for r in self._overview if "ancestor_name" in r]
|
|
color_map = _ancestor_color_map(ancestor_names)
|
|
self.list.clear()
|
|
for row in self._overview:
|
|
date_str = row["date"].strftime("%Y-%m-%d %H:%M") if row["date"] else "?"
|
|
groups = ",".join(row["groups"])
|
|
message = row["message"]
|
|
ancestor_name = row.get("ancestor_name")
|
|
if ancestor_name:
|
|
message = f"[{ancestor_name}] {message}"
|
|
item = QtWidgets.QTreeWidgetItem(
|
|
[date_str, message, groups, row.get("presetname") or ""]
|
|
)
|
|
if ancestor_name:
|
|
for col in range(4):
|
|
item.setBackground(col, QtGui.QColor(color_map[ancestor_name]))
|
|
self.list.addTopLevelItem(item)
|
|
for col in range(4):
|
|
self.list.resizeColumnToContents(col)
|
|
|
|
if color_map:
|
|
self.legend_label.setText(
|
|
"Parent memory colours: " + "; ".join(
|
|
f'<span style="background-color:{c};"> {n} </span>'
|
|
for n, c in color_map.items()
|
|
)
|
|
)
|
|
else:
|
|
self.legend_label.setText("")
|
|
|
|
def _on_pick_stored(self, item, column=0):
|
|
idx = self.list.indexOfTopLevelItem(item)
|
|
row = self._overview[idx]
|
|
ancestor = row.get("ancestor")
|
|
try:
|
|
if ancestor is not None:
|
|
entry = ancestor.memory.get_memory(key=row["key"])
|
|
sliced = self.memory.get_ancestor_recall_dict(
|
|
ancestor, entry, selection=None
|
|
)
|
|
self._load_mem(
|
|
{"settings": sliced}, key=None, path=None,
|
|
source_ancestor_name=row["ancestor_name"],
|
|
)
|
|
self.preset_tag_edit.setText("")
|
|
else:
|
|
mem = self.memory.get_memory(key=row["key"])
|
|
self._load_mem(mem, key=row["key"], path=None)
|
|
self.preset_tag_edit.setText(row.get("presetname") or "")
|
|
except Exception as e:
|
|
self._set_status(f"could not load memory: {e}", error=True)
|
|
|
|
def _on_load_file(self):
|
|
from qtpy import QtWidgets
|
|
|
|
path, _ = QtWidgets.QFileDialog.getOpenFileName(
|
|
self.window, "Load memory from file", "", _JSON_FILETYPES_QT
|
|
)
|
|
if not path:
|
|
return
|
|
try:
|
|
mem = self.memory.get_memory(input_obj=path)
|
|
except Exception as e:
|
|
self._set_status(f"could not load {path}: {e}", error=True)
|
|
return
|
|
self._load_mem(mem, key=None, path=path)
|
|
self.preset_tag_edit.setText("")
|
|
|
|
def _on_apply_preset_name(self):
|
|
key = self._state.get("key")
|
|
if key is None:
|
|
self._set_status(
|
|
"select a stored memory from the list above first "
|
|
"(not a file-loaded or parent-sourced one)", error=True,
|
|
)
|
|
return
|
|
name = self.preset_tag_edit.text().strip() or None
|
|
try:
|
|
self.memory.set_preset_name(key=key, name=name)
|
|
self._set_status(f"preset tag {'set to ' + name if name else 'cleared'} for {key}")
|
|
self._refresh_overview()
|
|
except Exception as e:
|
|
self._set_status(f"could not update preset tag: {e}", error=True)
|
|
|
|
def _on_delete_entry(self):
|
|
from qtpy import QtWidgets
|
|
|
|
key = self._state.get("key")
|
|
if key is None:
|
|
self._set_status(
|
|
"select a stored memory from the list above first "
|
|
"(not a file-loaded or parent-sourced one)", error=True,
|
|
)
|
|
return
|
|
reply = QtWidgets.QMessageBox.question(
|
|
self.window,
|
|
"Confirm delete",
|
|
f"Permanently remove this stored memory entry ({key})? "
|
|
"This cannot be undone from here.",
|
|
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
|
|
)
|
|
if reply != QtWidgets.QMessageBox.Yes:
|
|
return
|
|
try:
|
|
self.memory.clear_memory(key=key)
|
|
except Exception as e:
|
|
self._set_status(f"delete failed: {e}", error=True)
|
|
return
|
|
self._state = {
|
|
"key": None, "path": None, "mem": None, "rows": [], "checked": {},
|
|
"source_ancestor_name": None,
|
|
}
|
|
self.tree.clear()
|
|
while self.group_row.count() > 1:
|
|
child = self.group_row.takeAt(1)
|
|
if child.widget():
|
|
child.widget().deleteLater()
|
|
self._group_checks = {}
|
|
self.preset_tag_edit.setText("")
|
|
self._set_status(f"deleted stored memory {key}")
|
|
self._refresh_overview()
|
|
|
|
def _load_mem(self, mem, key, path, source_ancestor_name=None):
|
|
self._state = {
|
|
"key": key, "path": path, "mem": mem, "rows": [], "checked": {},
|
|
"source_ancestor_name": source_ancestor_name,
|
|
}
|
|
groups = _available_recall_groups(self.memory, mem)
|
|
# rebuild the group-filter checkboxes for this memory's own groups
|
|
while self.group_row.count() > 1:
|
|
child = self.group_row.takeAt(1)
|
|
if child.widget():
|
|
child.widget().deleteLater()
|
|
self._group_checks = {}
|
|
from qtpy import QtWidgets
|
|
|
|
for g in groups:
|
|
cb = QtWidgets.QCheckBox(g)
|
|
cb.setChecked(True)
|
|
cb.stateChanged.connect(self._refresh_detail)
|
|
self.group_row.addWidget(cb)
|
|
self._group_checks[g] = cb
|
|
if source_ancestor_name:
|
|
self._set_status(
|
|
f"loaded memory from parent '{source_ancestor_name}' "
|
|
f"(only this object's own components)"
|
|
)
|
|
else:
|
|
self._set_status(f"loaded {'file ' + path if path else 'memory ' + key}")
|
|
self._refresh_detail()
|
|
|
|
def _selected_selection(self):
|
|
if not self._group_checks:
|
|
return None
|
|
checked = [g for g, cb in self._group_checks.items() if cb.isChecked()]
|
|
if not checked or len(checked) == len(self._group_checks):
|
|
return None
|
|
return checked
|
|
|
|
def _set_all_checked(self, value):
|
|
for r in self._state.get("rows", []):
|
|
self._state["checked"][r["name"]] = value
|
|
self._refresh_detail()
|
|
|
|
def _on_tree_item_changed(self, item, column):
|
|
if getattr(self, "_suppress_item_changed", False) or column != 0:
|
|
return
|
|
from qtpy import QtCore
|
|
|
|
name = item.data(0, QtCore.Qt.UserRole)
|
|
if name is None:
|
|
return
|
|
self._state["checked"][name] = item.checkState(0) == QtCore.Qt.Checked
|
|
|
|
def _add_leaf_item(self, container, r, top_level=False):
|
|
from qtpy import QtWidgets, QtCore
|
|
|
|
status = "not found" if not r["available"] else ("CHANGED" if r["changed"] else "==")
|
|
present = _format_value(r["present"]) if r["available"] else "?"
|
|
item = QtWidgets.QTreeWidgetItem(
|
|
[r["name"], present, _format_value(r["memory"]), r["diff"], status]
|
|
)
|
|
item.setFlags(item.flags() | QtCore.Qt.ItemIsUserCheckable)
|
|
item.setCheckState(
|
|
0, QtCore.Qt.Checked if self._state["checked"].get(r["name"], True) else QtCore.Qt.Unchecked
|
|
)
|
|
item.setData(0, QtCore.Qt.UserRole, r["name"])
|
|
if r["changed"]:
|
|
for col in range(5):
|
|
item.setForeground(col, QtWidgets.QApplication.palette().link())
|
|
if top_level:
|
|
self.tree.addTopLevelItem(item)
|
|
else:
|
|
container.addChild(item)
|
|
|
|
def _refresh_detail(self, *_a):
|
|
mem = self._state.get("mem")
|
|
self.tree.clear()
|
|
if mem is None:
|
|
return
|
|
rows = _diff_rows(self.memory, mem, selection=self._selected_selection())
|
|
self._state["rows"] = rows
|
|
for r in rows:
|
|
self._state["checked"].setdefault(r["name"], True)
|
|
show_changed_only = self.changed_only_cb.isChecked()
|
|
from qtpy import QtWidgets
|
|
|
|
self._suppress_item_changed = True
|
|
for kind, group_name, payload in _foldable_sections(rows):
|
|
if kind == "section":
|
|
visible_rows = [r for r in payload if (r["changed"] or not show_changed_only)]
|
|
if not visible_rows:
|
|
continue
|
|
parent_item = QtWidgets.QTreeWidgetItem([group_name, "", "", "", ""])
|
|
self.tree.addTopLevelItem(parent_item)
|
|
for r in visible_rows:
|
|
self._add_leaf_item(parent_item, r)
|
|
parent_item.setExpanded(True)
|
|
else:
|
|
r = payload
|
|
if r["changed"] or not show_changed_only:
|
|
self._add_leaf_item(self.tree, r, top_level=True)
|
|
self._suppress_item_changed = False
|
|
for col in range(5):
|
|
self.tree.resizeColumnToContents(col)
|
|
|
|
def _on_recall(self):
|
|
from qtpy import QtWidgets
|
|
|
|
if self._state.get("mem") is None:
|
|
self._set_status("load a memory first", error=True)
|
|
return
|
|
checked = self._state["checked"]
|
|
selected_rows = [r for r in self._state["rows"] if checked.get(r["name"], True)]
|
|
if not selected_rows:
|
|
self._set_status("no rows selected to recall", error=True)
|
|
return
|
|
n_changed = sum(1 for r in selected_rows if r["changed"])
|
|
reply = QtWidgets.QMessageBox.question(
|
|
self.window,
|
|
"Confirm recall",
|
|
f"Recall {len(selected_rows)} selected value(s)? {n_changed} will actually change.",
|
|
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
|
|
)
|
|
if reply != QtWidgets.QMessageBox.Yes:
|
|
return
|
|
filtered = {r["name"]: r["memory"] for r in selected_rows}
|
|
try:
|
|
self.memory.recall(input_obj={"settings": filtered}, force=True, selection="settings")
|
|
recall_error = None
|
|
except Exception as e:
|
|
recall_error = e
|
|
try:
|
|
self._refresh_detail()
|
|
except Exception:
|
|
pass # _diff_rows already guards each row's own read individually
|
|
if recall_error is None:
|
|
self._set_status("recall complete")
|
|
else:
|
|
self._set_status(_recall_error_status(recall_error), error=True)
|
|
|
|
def _on_export(self):
|
|
from qtpy import QtWidgets
|
|
|
|
mem = self._state.get("mem")
|
|
if mem is None:
|
|
self._set_status("load a memory first", error=True)
|
|
return
|
|
default_name = _default_export_name(self.memory, _export_label(self._state))
|
|
path, _ = QtWidgets.QFileDialog.getSaveFileName(
|
|
self.window, "Export memory to file", default_name, _JSON_FILETYPES_QT
|
|
)
|
|
if not path:
|
|
return
|
|
try:
|
|
_export_to_file(mem, path)
|
|
self._set_status(f"exported to {path}")
|
|
except Exception as e:
|
|
self._set_status(f"export failed: {e}", error=True)
|
|
|
|
def _on_toggle_pick_items(self, _state):
|
|
self.pick_items_list.setVisible(self.pick_items_cb.isChecked())
|
|
if self.pick_items_cb.isChecked():
|
|
self._refresh_pick_items()
|
|
else:
|
|
self.pick_items_list.clear()
|
|
|
|
def _refresh_pick_items(self):
|
|
from qtpy import QtWidgets, QtCore
|
|
|
|
if not self.pick_items_cb.isChecked():
|
|
return
|
|
selection = self.save_selection_combo.currentText().strip() or "settings"
|
|
try:
|
|
pairs = self.memory.get_memorize_candidates(selection=selection)
|
|
except Exception as e:
|
|
self._set_status(f"could not list items: {e}", error=True)
|
|
return
|
|
multi_group = len({p[0] for p in pairs}) > 1
|
|
self.pick_items_list.clear()
|
|
for group, name, value in pairs:
|
|
text = f"[{group}] {name} = {value}" if multi_group else f"{name} = {value}"
|
|
item = QtWidgets.QListWidgetItem(text)
|
|
item.setFlags(item.flags() | QtCore.Qt.ItemIsUserCheckable)
|
|
item.setCheckState(QtCore.Qt.Checked)
|
|
item.setData(QtCore.Qt.UserRole, name)
|
|
self.pick_items_list.addItem(item)
|
|
|
|
def _set_all_pick_items(self, value):
|
|
from qtpy import QtCore
|
|
|
|
state = QtCore.Qt.Checked if value else QtCore.Qt.Unchecked
|
|
for i in range(self.pick_items_list.count()):
|
|
self.pick_items_list.item(i).setCheckState(state)
|
|
|
|
def _on_save(self):
|
|
from qtpy import QtCore
|
|
|
|
message = self.message_edit.text().strip()
|
|
if not message:
|
|
self._set_status("enter a message before saving", error=True)
|
|
return
|
|
selection = self.save_selection_combo.currentText().strip() or "settings"
|
|
kwargs = {}
|
|
if self.pick_items_cb.isChecked():
|
|
names = [
|
|
self.pick_items_list.item(i).data(QtCore.Qt.UserRole)
|
|
for i in range(self.pick_items_list.count())
|
|
if self.pick_items_list.item(i).checkState() == QtCore.Qt.Checked
|
|
]
|
|
if not names:
|
|
self._set_status("no items selected -- uncheck 'choose items' to save everything, or check at least one", error=True)
|
|
return
|
|
kwargs["pick_items"] = names
|
|
preset_name = self.save_preset_edit.text().strip() or None
|
|
try:
|
|
self.memory.memorize(
|
|
message=message, force_message=False, to_elog=self.elog_cb.isChecked(),
|
|
selection=selection, preset_varname=preset_name, **kwargs,
|
|
)
|
|
self.message_edit.clear()
|
|
self.save_preset_edit.clear()
|
|
self._set_status("saved new memory")
|
|
self._refresh_overview()
|
|
except Exception as e:
|
|
self._set_status(f"save failed: {e}\n{traceback.format_exc(limit=2)}", error=True)
|
|
|
|
|
|
def make_memory_browser_qt(assembly, parent=None):
|
|
return MemoryBrowserQt(assembly, parent=parent)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ipywidgets backend
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def make_memory_browser_ipywidgets(assembly):
|
|
import ipywidgets as widgets
|
|
|
|
memory = assembly.memory
|
|
state = {
|
|
"key": None, "path": None, "mem": None, "rows": [], "checked": {},
|
|
"source_ancestor_name": None,
|
|
}
|
|
|
|
title = widgets.HTML(
|
|
f"<b>Memories - {assembly.alias.get_full_name()}</b>"
|
|
)
|
|
|
|
overview_selection_combo = widgets.Combobox(
|
|
value="settings",
|
|
options=["all", "settings"],
|
|
placeholder="settings",
|
|
description="Selection:",
|
|
ensure_option=False,
|
|
layout=widgets.Layout(width="220px"),
|
|
)
|
|
show_parents_cb = widgets.Checkbox(value=False, description="show parent memories too")
|
|
overview_style_html = widgets.HTML("") # injected <style> for ancestor row colours
|
|
overview_box = widgets.VBox([], layout=widgets.Layout(
|
|
max_height="180px", overflow_y="auto", border="1px solid #ccc",
|
|
))
|
|
legend_html = widgets.HTML("")
|
|
|
|
refresh_btn = widgets.Button(description="Refresh list")
|
|
load_path_text = widgets.Text(
|
|
placeholder="path to a memory .json file to load",
|
|
layout=widgets.Layout(width="70%"),
|
|
)
|
|
load_file_btn = widgets.Button(description="Load from file")
|
|
|
|
preset_tag_text = widgets.Text(
|
|
placeholder="preset name for the loaded entry (empty clears it)",
|
|
layout=widgets.Layout(width="50%"),
|
|
)
|
|
apply_preset_btn = widgets.Button(description="Apply preset tag")
|
|
delete_entry_btn = widgets.Button(
|
|
description="Delete entry", button_style="danger",
|
|
tooltip=(
|
|
"permanently remove this stored memory entry (not the whole "
|
|
"device's memory -- just this one loaded snapshot)"
|
|
),
|
|
)
|
|
_delete_armed = {"key": None}
|
|
|
|
group_filter_box = widgets.HBox([widgets.Label("Keyword groups:")])
|
|
changed_only_cb = widgets.Checkbox(value=True, description="show changed only")
|
|
|
|
detail_box = widgets.VBox([])
|
|
select_all_btn = widgets.Button(description="select all")
|
|
select_none_btn = widgets.Button(description="select none")
|
|
status_html = widgets.HTML("")
|
|
|
|
recall_btn = widgets.Button(description="Recall selected values", button_style="warning")
|
|
export_path_text = widgets.Text(
|
|
placeholder="path to export the loaded memory to",
|
|
layout=widgets.Layout(width="70%"),
|
|
)
|
|
export_btn = widgets.Button(description="Export to file")
|
|
|
|
message_text = widgets.Text(
|
|
placeholder="message for this memory…", layout=widgets.Layout(width="60%")
|
|
)
|
|
save_selection_combo = widgets.Combobox(
|
|
value="settings",
|
|
options=["all", "settings"],
|
|
placeholder="settings",
|
|
description="selection:",
|
|
ensure_option=False,
|
|
layout=widgets.Layout(width="220px"),
|
|
tooltip=(
|
|
'which status_collection selection to capture -- "settings" '
|
|
'(default), "all" (everything currently registered), or a '
|
|
"custom tag"
|
|
),
|
|
)
|
|
elog_cb = widgets.Checkbox(value=True, description="post to elog")
|
|
save_btn = widgets.Button(description="Save new memory", button_style="success")
|
|
save_preset_text = widgets.Text(
|
|
placeholder="preset name (optional) -- leave empty for a plain memory",
|
|
layout=widgets.Layout(width="60%"),
|
|
tooltip=(
|
|
"tag this memory as a named preset, reachable afterwards as "
|
|
"<assembly>.memory.presets.<name>(). Reusing an existing name "
|
|
"redirects that preset to this new entry; the old entry stays "
|
|
"around as a plain memory."
|
|
),
|
|
)
|
|
|
|
# opt-in, non-default item picker: unchecked, this box stays empty and
|
|
# Save captures everything the selection resolves to, exactly as
|
|
# before this existed.
|
|
pick_items_cb = widgets.Checkbox(
|
|
value=False, description="choose items…",
|
|
tooltip=(
|
|
"narrow this save down to hand-picked items instead of "
|
|
"capturing everything the selection above resolves to"
|
|
),
|
|
)
|
|
refresh_items_btn = widgets.Button(description="Refresh items")
|
|
pick_all_items_btn = widgets.Button(description="all", layout=widgets.Layout(width="50px"))
|
|
pick_none_items_btn = widgets.Button(description="none", layout=widgets.Layout(width="60px"))
|
|
items_pick_box = widgets.VBox([])
|
|
pick_item_checks = {}
|
|
|
|
group_checks = {}
|
|
|
|
def _set_status(text, error=False):
|
|
color = "#c62828" if error else "#2e7d32"
|
|
status_html.value = f"<span style='color:{color}'>{text}</span>"
|
|
|
|
def _reset_delete_arm():
|
|
_delete_armed["key"] = None
|
|
delete_entry_btn.description = "Delete entry"
|
|
|
|
def _load_row(row):
|
|
ancestor = row.get("ancestor")
|
|
try:
|
|
if ancestor is not None:
|
|
entry = ancestor.memory.get_memory(key=row["key"])
|
|
sliced = memory.get_ancestor_recall_dict(ancestor, entry, selection=None)
|
|
_load_mem(
|
|
{"settings": sliced}, key=None, path=None,
|
|
source_ancestor_name=row["ancestor_name"],
|
|
)
|
|
preset_tag_text.value = ""
|
|
else:
|
|
mem = memory.get_memory(key=row["key"])
|
|
_load_mem(mem, key=row["key"], path=None)
|
|
preset_tag_text.value = row.get("presetname") or ""
|
|
_reset_delete_arm()
|
|
except Exception as e:
|
|
_set_status(f"could not load memory: {e}", error=True)
|
|
|
|
def _on_apply_preset_name(_b):
|
|
key = state.get("key")
|
|
if key is None:
|
|
_set_status(
|
|
"select a stored memory from the list above first "
|
|
"(not a file-loaded or parent-sourced one)", error=True,
|
|
)
|
|
return
|
|
name = preset_tag_text.value.strip() or None
|
|
try:
|
|
memory.set_preset_name(key=key, name=name)
|
|
_set_status(f"preset tag {'set to ' + name if name else 'cleared'} for {key}")
|
|
_refresh_overview()
|
|
except Exception as e:
|
|
_set_status(f"could not update preset tag: {e}", error=True)
|
|
|
|
def _on_delete_entry(_b):
|
|
key = state.get("key")
|
|
if key is None:
|
|
_set_status(
|
|
"select a stored memory from the list above first "
|
|
"(not a file-loaded or parent-sourced one)", error=True,
|
|
)
|
|
return
|
|
if _delete_armed["key"] != key:
|
|
_delete_armed["key"] = key
|
|
delete_entry_btn.description = "Click again to confirm delete"
|
|
_set_status(f"click 'Delete entry' again to permanently remove {key}", error=True)
|
|
return
|
|
try:
|
|
memory.clear_memory(key=key)
|
|
except Exception as e:
|
|
_set_status(f"delete failed: {e}", error=True)
|
|
return
|
|
state.update({
|
|
"key": None, "path": None, "mem": None, "checked": {},
|
|
"source_ancestor_name": None,
|
|
})
|
|
detail_box.children = ()
|
|
group_filter_box.children = (widgets.Label("Keyword groups:"),)
|
|
group_checks.clear()
|
|
preset_tag_text.value = ""
|
|
_reset_delete_arm()
|
|
_set_status(f"deleted stored memory {key}")
|
|
_refresh_overview()
|
|
|
|
def _refresh_overview(_b=None):
|
|
own_rows = _overview_rows(memory)
|
|
all_rows = list(own_rows)
|
|
if show_parents_cb.value:
|
|
all_rows += _ancestor_overview_rows(memory)
|
|
overview_selection_combo.options = ["all"] + _known_selection_tags(all_rows)
|
|
tag = overview_selection_combo.value or "settings"
|
|
rows = _filter_overview_rows(all_rows, tag)
|
|
|
|
ancestor_names = [r["ancestor_name"] for r in rows if "ancestor_name" in r]
|
|
color_map = _ancestor_color_map(ancestor_names)
|
|
style_rules = []
|
|
row_widgets = []
|
|
for row in rows:
|
|
date_str = row["date"].strftime("%Y-%m-%d %H:%M") if row["date"] else "?"
|
|
groups = ",".join(row["groups"])
|
|
ancestor_name = row.get("ancestor_name")
|
|
label = f"{date_str} {row['message']} [{groups}]"
|
|
if row.get("presetname"):
|
|
label += f" ★ {row['presetname']}"
|
|
if ancestor_name:
|
|
label = f"[{ancestor_name}] {label}"
|
|
btn = widgets.Button(
|
|
description=label,
|
|
layout=widgets.Layout(width="100%"),
|
|
)
|
|
if ancestor_name:
|
|
cls = "eco-mem-anc-" + str(abs(hash(ancestor_name)) % 1000000)
|
|
btn.add_class(cls)
|
|
style_rules.append(
|
|
f".{cls} {{ background-color: {color_map[ancestor_name]} !important; }}"
|
|
)
|
|
btn.on_click(lambda _b, r=row: _load_row(r))
|
|
row_widgets.append(btn)
|
|
overview_box.children = tuple(row_widgets) if row_widgets else (
|
|
widgets.Label("(no stored memories)"),
|
|
)
|
|
overview_style_html.value = "<style>" + "".join(style_rules) + "</style>"
|
|
if color_map:
|
|
legend_html.value = "Parent memory colours: " + "; ".join(
|
|
f'<span style="background-color:{c};"> {n} </span>'
|
|
for n, c in color_map.items()
|
|
)
|
|
else:
|
|
legend_html.value = ""
|
|
|
|
def _make_row_widget(r):
|
|
cb = widgets.Checkbox(
|
|
value=state["checked"].get(r["name"], True), indent=False,
|
|
layout=widgets.Layout(width="30px"),
|
|
)
|
|
|
|
def _on_cb_change(change, name=r["name"]):
|
|
if change.get("name") == "value":
|
|
state["checked"][name] = change["new"]
|
|
|
|
cb.observe(_on_cb_change, names="value")
|
|
if not r["available"]:
|
|
status = "not found"
|
|
elif r["changed"]:
|
|
status = "<b style='color:#c62828'>CHANGED</b>"
|
|
else:
|
|
status = "=="
|
|
present = _format_value(r["present"]) if r["available"] else "?"
|
|
label = widgets.HTML(
|
|
f"<span>{r['name']}</span> present: <b>{present}</b> "
|
|
f"memory: <b>{_format_value(r['memory'])}</b> diff: {r['diff']} "
|
|
f" {status}"
|
|
)
|
|
return widgets.HBox([cb, label])
|
|
|
|
def _refresh_detail(_change=None):
|
|
mem = state.get("mem")
|
|
if mem is None:
|
|
detail_box.children = ()
|
|
return
|
|
checked_groups = [g for g, cb in group_checks.items() if cb.value]
|
|
selection = (
|
|
None
|
|
if (not checked_groups or len(checked_groups) == len(group_checks))
|
|
else checked_groups
|
|
)
|
|
rows = _diff_rows(memory, mem, selection=selection)
|
|
state["rows"] = rows
|
|
for r in rows:
|
|
state["checked"].setdefault(r["name"], True)
|
|
show_changed_only = changed_only_cb.value
|
|
|
|
sections = []
|
|
for kind, group_name, payload in _foldable_sections(rows):
|
|
if kind == "section":
|
|
visible = [r for r in payload if (r["changed"] or not show_changed_only)]
|
|
if not visible:
|
|
continue
|
|
body = widgets.VBox([_make_row_widget(r) for r in visible])
|
|
acc = widgets.Accordion(children=[body])
|
|
acc.set_title(0, f"{group_name} ({len(visible)})")
|
|
acc.selected_index = 0
|
|
sections.append(acc)
|
|
else:
|
|
r = payload
|
|
if r["changed"] or not show_changed_only:
|
|
sections.append(_make_row_widget(r))
|
|
detail_box.children = tuple(sections) if sections else (widgets.HTML("(no rows)"),)
|
|
|
|
def _load_mem(mem, key, path, source_ancestor_name=None):
|
|
state.update({
|
|
"key": key, "path": path, "mem": mem, "checked": {},
|
|
"source_ancestor_name": source_ancestor_name,
|
|
})
|
|
groups = _available_recall_groups(memory, mem)
|
|
group_checks.clear()
|
|
boxes = [widgets.Label("Keyword groups:")]
|
|
for g in groups:
|
|
cb = widgets.Checkbox(value=True, description=g, indent=False,
|
|
layout=widgets.Layout(width="140px"))
|
|
cb.observe(_refresh_detail, names="value")
|
|
group_checks[g] = cb
|
|
boxes.append(cb)
|
|
group_filter_box.children = tuple(boxes)
|
|
if source_ancestor_name:
|
|
_set_status(
|
|
f"loaded memory from parent '{source_ancestor_name}' "
|
|
f"(only this object's own components)"
|
|
)
|
|
else:
|
|
_set_status(f"loaded {'file ' + path if path else 'memory ' + key}")
|
|
_refresh_detail()
|
|
|
|
def _on_load_file(_b):
|
|
path = load_path_text.value.strip()
|
|
if not path:
|
|
_set_status("enter a file path to load", error=True)
|
|
return
|
|
try:
|
|
mem = memory.get_memory(input_obj=path)
|
|
except Exception as e:
|
|
_set_status(f"could not load {path}: {e}", error=True)
|
|
return
|
|
_load_mem(mem, key=None, path=path)
|
|
preset_tag_text.value = ""
|
|
_reset_delete_arm()
|
|
|
|
def _select_all(_b):
|
|
for r in state.get("rows", []):
|
|
state["checked"][r["name"]] = True
|
|
_refresh_detail()
|
|
|
|
def _select_none(_b):
|
|
for r in state.get("rows", []):
|
|
state["checked"][r["name"]] = False
|
|
_refresh_detail()
|
|
|
|
def _on_recall(_b):
|
|
if state.get("mem") is None:
|
|
_set_status("load a memory first", error=True)
|
|
return
|
|
checked = state["checked"]
|
|
selected_rows = [r for r in state["rows"] if checked.get(r["name"], True)]
|
|
if not selected_rows:
|
|
_set_status("no rows selected to recall", error=True)
|
|
return
|
|
filtered = {r["name"]: r["memory"] for r in selected_rows}
|
|
try:
|
|
memory.recall(input_obj={"settings": filtered}, force=True, selection="settings")
|
|
recall_error = None
|
|
except Exception as e:
|
|
recall_error = e
|
|
try:
|
|
_refresh_detail()
|
|
except Exception:
|
|
pass # _diff_rows already guards each row's own read individually
|
|
if recall_error is None:
|
|
_set_status("recall complete")
|
|
else:
|
|
_set_status(_recall_error_status(recall_error), error=True)
|
|
|
|
def _on_export(_b):
|
|
mem = state.get("mem")
|
|
if mem is None:
|
|
_set_status("load a memory first", error=True)
|
|
return
|
|
path = export_path_text.value.strip() or _default_export_name(memory, _export_label(state))
|
|
try:
|
|
_export_to_file(mem, path)
|
|
_set_status(f"exported to {path}")
|
|
except Exception as e:
|
|
_set_status(f"export failed: {e}", error=True)
|
|
|
|
def _make_pick_item_row(group, name, value, multi_group):
|
|
cb = widgets.Checkbox(value=True, indent=False, layout=widgets.Layout(width="30px"))
|
|
pick_item_checks[name] = cb
|
|
prefix = f"[{group}] " if multi_group else ""
|
|
label = widgets.HTML(f"<span>{prefix}{name}</span> = {_format_value(value)}")
|
|
return widgets.HBox([cb, label])
|
|
|
|
def _refresh_pick_items(_b=None):
|
|
if not pick_items_cb.value:
|
|
return
|
|
selection = save_selection_combo.value.strip() or "settings"
|
|
try:
|
|
pairs = memory.get_memorize_candidates(selection=selection)
|
|
except Exception as e:
|
|
_set_status(f"could not list items: {e}", error=True)
|
|
return
|
|
multi_group = len({p[0] for p in pairs}) > 1
|
|
pick_item_checks.clear()
|
|
items_pick_box.children = tuple(
|
|
_make_pick_item_row(g, n, v, multi_group) for g, n, v in pairs
|
|
) or (widgets.Label("(no items)"),)
|
|
|
|
def _on_toggle_pick_items(change):
|
|
if change.get("name") != "value":
|
|
return
|
|
if change["new"]:
|
|
_refresh_pick_items()
|
|
else:
|
|
pick_item_checks.clear()
|
|
items_pick_box.children = ()
|
|
|
|
def _set_all_pick_items(value):
|
|
for cb in pick_item_checks.values():
|
|
cb.value = value
|
|
|
|
def _on_save(_b):
|
|
message = message_text.value.strip()
|
|
if not message:
|
|
_set_status("enter a message before saving", error=True)
|
|
return
|
|
selection = save_selection_combo.value.strip() or "settings"
|
|
kwargs = {}
|
|
if pick_items_cb.value:
|
|
names = [name for name, cb in pick_item_checks.items() if cb.value]
|
|
if not names:
|
|
_set_status(
|
|
"no items selected -- uncheck 'choose items' to save "
|
|
"everything, or check at least one", error=True,
|
|
)
|
|
return
|
|
kwargs["pick_items"] = names
|
|
preset_name = save_preset_text.value.strip() or None
|
|
try:
|
|
memory.memorize(
|
|
message=message, force_message=False, to_elog=elog_cb.value,
|
|
selection=selection, preset_varname=preset_name, **kwargs,
|
|
)
|
|
message_text.value = ""
|
|
save_preset_text.value = ""
|
|
_set_status("saved new memory")
|
|
_refresh_overview()
|
|
except Exception as e:
|
|
_set_status(f"save failed: {e}", error=True)
|
|
|
|
overview_selection_combo.observe(_refresh_overview, names="value")
|
|
show_parents_cb.observe(_refresh_overview, names="value")
|
|
refresh_btn.on_click(_refresh_overview)
|
|
load_file_btn.on_click(_on_load_file)
|
|
apply_preset_btn.on_click(_on_apply_preset_name)
|
|
delete_entry_btn.on_click(_on_delete_entry)
|
|
changed_only_cb.observe(_refresh_detail, names="value")
|
|
select_all_btn.on_click(_select_all)
|
|
select_none_btn.on_click(_select_none)
|
|
recall_btn.on_click(_on_recall)
|
|
export_btn.on_click(_on_export)
|
|
save_btn.on_click(_on_save)
|
|
pick_items_cb.observe(_on_toggle_pick_items, names="value")
|
|
refresh_items_btn.on_click(_refresh_pick_items)
|
|
pick_all_items_btn.on_click(lambda _b: _set_all_pick_items(True))
|
|
pick_none_items_btn.on_click(lambda _b: _set_all_pick_items(False))
|
|
|
|
_refresh_overview()
|
|
|
|
box = widgets.VBox(
|
|
[
|
|
title,
|
|
widgets.HTML("<b>Stored memories</b>"),
|
|
widgets.HBox([overview_selection_combo, show_parents_cb]),
|
|
overview_style_html,
|
|
overview_box,
|
|
legend_html,
|
|
widgets.HBox([refresh_btn, load_path_text, load_file_btn]),
|
|
widgets.HBox([preset_tag_text, apply_preset_btn, delete_entry_btn]),
|
|
group_filter_box,
|
|
changed_only_cb,
|
|
widgets.HTML("<b>Details</b> (checkbox selects for recall)"),
|
|
detail_box,
|
|
widgets.HBox([select_all_btn, select_none_btn]),
|
|
widgets.HBox([recall_btn, export_path_text, export_btn]),
|
|
widgets.HTML("<b>Save current state as a new memory</b>"),
|
|
widgets.HBox([message_text, save_selection_combo, elog_cb, save_btn]),
|
|
widgets.HBox([save_preset_text]),
|
|
widgets.HBox([
|
|
pick_items_cb, refresh_items_btn, pick_all_items_btn, pick_none_items_btn,
|
|
]),
|
|
items_pick_box,
|
|
status_html,
|
|
]
|
|
)
|
|
return box
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tk backend
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class MemoryBrowserTk:
|
|
def __init__(self, assembly, parent=None):
|
|
self.assembly = assembly
|
|
self.memory = assembly.memory
|
|
self.parent = parent
|
|
self.top = None
|
|
self._state = {
|
|
"key": None, "path": None, "mem": None, "rows": [], "checked": {},
|
|
"source_ancestor_name": None,
|
|
}
|
|
self._group_vars = {}
|
|
self._build()
|
|
|
|
def _build(self):
|
|
import tkinter as tk
|
|
from tkinter import ttk
|
|
|
|
self.top = tk.Toplevel(self.parent) if self.parent is not None else tk.Tk()
|
|
self.top.title(f"Memories - {self.assembly.alias.get_full_name()}")
|
|
|
|
ttk.Label(self.top, text="Stored memories", font=("TkDefaultFont", 9, "bold")).pack(
|
|
anchor="w", padx=4, pady=(4, 0)
|
|
)
|
|
overview_filter_row = ttk.Frame(self.top)
|
|
overview_filter_row.pack(fill="x", padx=4)
|
|
ttk.Label(overview_filter_row, text="Selection:").pack(side="left")
|
|
self.overview_selection_var = tk.StringVar(value="settings")
|
|
self.overview_selection_combo = ttk.Combobox(
|
|
overview_filter_row, textvariable=self.overview_selection_var,
|
|
values=["all", "settings"], width=20,
|
|
)
|
|
self.overview_selection_combo.pack(side="left", padx=(4, 0))
|
|
self.overview_selection_combo.bind(
|
|
"<<ComboboxSelected>>", lambda _evt: self._refresh_overview()
|
|
)
|
|
self.overview_selection_combo.bind(
|
|
"<Return>", lambda _evt: self._refresh_overview()
|
|
)
|
|
self.show_parents_var = tk.BooleanVar(value=False)
|
|
ttk.Checkbutton(
|
|
overview_filter_row, text="show parent memories too",
|
|
variable=self.show_parents_var, command=self._refresh_overview,
|
|
).pack(side="left", padx=(8, 0))
|
|
|
|
list_frame = ttk.Frame(self.top)
|
|
list_frame.pack(fill="x", padx=4)
|
|
self.overview_list = tk.Listbox(list_frame, height=7)
|
|
self.overview_list.pack(side="left", fill="x", expand=True)
|
|
self.overview_list.bind("<<ListboxSelect>>", self._on_pick_stored)
|
|
vsb = ttk.Scrollbar(list_frame, orient="vertical", command=self.overview_list.yview)
|
|
vsb.pack(side="right", fill="y")
|
|
self.overview_list.config(yscrollcommand=vsb.set)
|
|
|
|
self.legend_label = ttk.Label(self.top, text="", wraplength=680)
|
|
self.legend_label.pack(anchor="w", padx=4)
|
|
|
|
load_row = ttk.Frame(self.top)
|
|
load_row.pack(fill="x", padx=4, pady=2)
|
|
ttk.Button(load_row, text="Refresh list", command=self._refresh_overview).pack(
|
|
side="left"
|
|
)
|
|
ttk.Button(load_row, text="Load from file…", command=self._on_load_file).pack(
|
|
side="left", padx=(4, 0)
|
|
)
|
|
|
|
preset_tag_row = ttk.Frame(self.top)
|
|
preset_tag_row.pack(fill="x", padx=4, pady=2)
|
|
ttk.Label(preset_tag_row, text="Preset tag (for loaded entry):").pack(side="left")
|
|
self.preset_tag_var = tk.StringVar()
|
|
ttk.Entry(preset_tag_row, textvariable=self.preset_tag_var, width=20).pack(
|
|
side="left", padx=(4, 4)
|
|
)
|
|
ttk.Button(
|
|
preset_tag_row, text="Apply", command=self._on_apply_preset_name
|
|
).pack(side="left")
|
|
ttk.Button(
|
|
preset_tag_row, text="Delete entry", command=self._on_delete_entry
|
|
).pack(side="left", padx=(8, 0))
|
|
|
|
filter_box = ttk.LabelFrame(self.top, text="Filters (within an opened memory)")
|
|
filter_box.pack(fill="x", padx=4, pady=4)
|
|
self.group_row = ttk.Frame(filter_box)
|
|
self.group_row.pack(fill="x", padx=4, pady=2)
|
|
ttk.Label(self.group_row, text="Keyword groups:").pack(side="left")
|
|
self.changed_only_var = tk.BooleanVar(value=True)
|
|
ttk.Checkbutton(
|
|
filter_box, text="show changed only", variable=self.changed_only_var,
|
|
command=self._refresh_detail,
|
|
).pack(anchor="w", padx=4)
|
|
|
|
ttk.Label(
|
|
self.top, text="Details (checkbox selects for recall)",
|
|
font=("TkDefaultFont", 9, "bold"),
|
|
).pack(anchor="w", padx=4, pady=(4, 0))
|
|
|
|
detail_frame = ttk.Frame(self.top)
|
|
detail_frame.pack(fill="both", expand=True, padx=4)
|
|
detail_canvas = tk.Canvas(detail_frame, highlightthickness=0, height=260)
|
|
detail_vsb = ttk.Scrollbar(detail_frame, orient="vertical", command=detail_canvas.yview)
|
|
self.detail_inner = ttk.Frame(detail_canvas)
|
|
detail_inner_id = detail_canvas.create_window((0, 0), window=self.detail_inner, anchor="nw")
|
|
|
|
def _on_inner_configure(_evt=None):
|
|
detail_canvas.configure(scrollregion=detail_canvas.bbox("all"))
|
|
|
|
def _on_canvas_configure(evt):
|
|
detail_canvas.itemconfig(detail_inner_id, width=evt.width)
|
|
|
|
self.detail_inner.bind("<Configure>", _on_inner_configure)
|
|
detail_canvas.bind("<Configure>", _on_canvas_configure)
|
|
detail_canvas.configure(yscrollcommand=detail_vsb.set)
|
|
detail_canvas.pack(side="left", fill="both", expand=True)
|
|
detail_vsb.pack(side="right", fill="y")
|
|
|
|
select_row = ttk.Frame(self.top)
|
|
select_row.pack(fill="x", padx=4, pady=(4, 0))
|
|
ttk.Button(select_row, text="select all", command=lambda: self._set_all_checked(True)).pack(
|
|
side="left"
|
|
)
|
|
ttk.Button(
|
|
select_row, text="select none", command=lambda: self._set_all_checked(False)
|
|
).pack(side="left", padx=(4, 0))
|
|
|
|
action_row = ttk.Frame(self.top)
|
|
action_row.pack(fill="x", padx=4, pady=4)
|
|
ttk.Button(action_row, text="Recall selected values", command=self._on_recall).pack(
|
|
side="left"
|
|
)
|
|
ttk.Button(action_row, text="Export to file…", command=self._on_export).pack(
|
|
side="left", padx=(4, 0)
|
|
)
|
|
|
|
save_box = ttk.LabelFrame(self.top, text="Save current state as a new memory")
|
|
save_box.pack(fill="x", padx=4, pady=4)
|
|
self.message_var = tk.StringVar()
|
|
ttk.Entry(save_box, textvariable=self.message_var, width=50).pack(
|
|
side="left", padx=4, pady=4
|
|
)
|
|
ttk.Label(save_box, text="selection:").pack(side="left")
|
|
self.save_selection_var = tk.StringVar(value="settings")
|
|
ttk.Combobox(
|
|
save_box, textvariable=self.save_selection_var,
|
|
values=["all", "settings"], width=16,
|
|
).pack(side="left", padx=(4, 4))
|
|
self.elog_var = tk.BooleanVar(value=True)
|
|
ttk.Checkbutton(save_box, text="post to elog", variable=self.elog_var).pack(
|
|
side="left", padx=4
|
|
)
|
|
ttk.Label(save_box, text="preset:").pack(side="left", padx=(8, 0))
|
|
self.save_preset_var = tk.StringVar()
|
|
ttk.Entry(save_box, textvariable=self.save_preset_var, width=16).pack(
|
|
side="left", padx=(2, 4)
|
|
)
|
|
ttk.Button(save_box, text="Save", command=self._on_save).pack(side="left", padx=4)
|
|
|
|
self.status_label = ttk.Label(self.top, text="")
|
|
self.status_label.pack(anchor="w", padx=4, pady=(0, 4))
|
|
|
|
self.top.protocol("WM_DELETE_WINDOW", self.top.destroy)
|
|
self._refresh_overview()
|
|
|
|
def _set_status(self, text, error=False):
|
|
self.status_label.config(text=text, foreground="#c62828" if error else "#2e7d32")
|
|
|
|
def _refresh_overview(self):
|
|
own_rows = _overview_rows(self.memory)
|
|
all_rows = list(own_rows)
|
|
if self.show_parents_var.get():
|
|
all_rows += _ancestor_overview_rows(self.memory)
|
|
self.overview_selection_combo["values"] = ["all"] + _known_selection_tags(all_rows)
|
|
tag = self.overview_selection_var.get() or "settings"
|
|
self._overview = _filter_overview_rows(all_rows, tag)
|
|
ancestor_names = [r["ancestor_name"] for r in self._overview if "ancestor_name" in r]
|
|
color_map = _ancestor_color_map(ancestor_names)
|
|
self.overview_list.delete(0, "end")
|
|
for i, row in enumerate(self._overview):
|
|
date_str = row["date"].strftime("%Y-%m-%d %H:%M") if row["date"] else "?"
|
|
groups = ",".join(row["groups"])
|
|
ancestor_name = row.get("ancestor_name")
|
|
label = f"{date_str} {row['message']} [{groups}]"
|
|
if row.get("presetname"):
|
|
label += f" ★ {row['presetname']}"
|
|
if ancestor_name:
|
|
label = f"[{ancestor_name}] {label}"
|
|
self.overview_list.insert("end", label)
|
|
if ancestor_name:
|
|
self.overview_list.itemconfig(i, {"bg": color_map[ancestor_name]})
|
|
if color_map:
|
|
self.legend_label.config(
|
|
text="Parent memory colours: " + "; ".join(
|
|
f"{n} = {c}" for n, c in color_map.items()
|
|
)
|
|
)
|
|
else:
|
|
self.legend_label.config(text="")
|
|
|
|
def _on_pick_stored(self, _evt=None):
|
|
sel = self.overview_list.curselection()
|
|
if not sel:
|
|
return
|
|
row = self._overview[sel[0]]
|
|
ancestor = row.get("ancestor")
|
|
try:
|
|
if ancestor is not None:
|
|
entry = ancestor.memory.get_memory(key=row["key"])
|
|
sliced = self.memory.get_ancestor_recall_dict(
|
|
ancestor, entry, selection=None
|
|
)
|
|
self._load_mem(
|
|
{"settings": sliced}, key=None, path=None,
|
|
source_ancestor_name=row["ancestor_name"],
|
|
)
|
|
self.preset_tag_var.set("")
|
|
else:
|
|
mem = self.memory.get_memory(key=row["key"])
|
|
self._load_mem(mem, key=row["key"], path=None)
|
|
self.preset_tag_var.set(row.get("presetname") or "")
|
|
except Exception as e:
|
|
self._set_status(f"could not load memory: {e}", error=True)
|
|
|
|
def _on_load_file(self):
|
|
from tkinter import filedialog
|
|
|
|
path = filedialog.askopenfilename(
|
|
parent=self.top, title="Load memory from file", filetypes=_JSON_FILETYPES_TK
|
|
)
|
|
if not path:
|
|
return
|
|
try:
|
|
mem = self.memory.get_memory(input_obj=path)
|
|
except Exception as e:
|
|
self._set_status(f"could not load {path}: {e}", error=True)
|
|
return
|
|
self._load_mem(mem, key=None, path=path)
|
|
self.preset_tag_var.set("")
|
|
|
|
def _on_apply_preset_name(self):
|
|
key = self._state.get("key")
|
|
if key is None:
|
|
self._set_status(
|
|
"select a stored memory from the list above first "
|
|
"(not a file-loaded or parent-sourced one)", error=True,
|
|
)
|
|
return
|
|
name = self.preset_tag_var.get().strip() or None
|
|
try:
|
|
self.memory.set_preset_name(key=key, name=name)
|
|
self._set_status(f"preset tag {'set to ' + name if name else 'cleared'} for {key}")
|
|
self._refresh_overview()
|
|
except Exception as e:
|
|
self._set_status(f"could not update preset tag: {e}", error=True)
|
|
|
|
def _on_delete_entry(self):
|
|
from tkinter import messagebox
|
|
|
|
key = self._state.get("key")
|
|
if key is None:
|
|
self._set_status(
|
|
"select a stored memory from the list above first "
|
|
"(not a file-loaded or parent-sourced one)", error=True,
|
|
)
|
|
return
|
|
if not messagebox.askyesno(
|
|
"Confirm delete",
|
|
f"Permanently remove this stored memory entry ({key})? "
|
|
"This cannot be undone from here.",
|
|
parent=self.top,
|
|
):
|
|
return
|
|
try:
|
|
self.memory.clear_memory(key=key)
|
|
except Exception as e:
|
|
self._set_status(f"delete failed: {e}", error=True)
|
|
return
|
|
self._state = {
|
|
"key": None, "path": None, "mem": None, "rows": [], "checked": {},
|
|
"source_ancestor_name": None,
|
|
}
|
|
for child in self.detail_inner.winfo_children():
|
|
child.destroy()
|
|
for child in self.group_row.winfo_children()[1:]:
|
|
child.destroy()
|
|
self._group_vars = {}
|
|
self.preset_tag_var.set("")
|
|
self._set_status(f"deleted stored memory {key}")
|
|
self._refresh_overview()
|
|
|
|
def _load_mem(self, mem, key, path, source_ancestor_name=None):
|
|
import tkinter as tk
|
|
from tkinter import ttk
|
|
|
|
self._state = {
|
|
"key": key, "path": path, "mem": mem, "rows": [], "checked": {},
|
|
"source_ancestor_name": source_ancestor_name,
|
|
}
|
|
for child in self.group_row.winfo_children()[1:]:
|
|
child.destroy()
|
|
self._group_vars = {}
|
|
for g in _available_recall_groups(self.memory, mem):
|
|
var = tk.BooleanVar(value=True)
|
|
cb = ttk.Checkbutton(
|
|
self.group_row, text=g, variable=var, command=self._refresh_detail
|
|
)
|
|
cb.pack(side="left", padx=(4, 0))
|
|
self._group_vars[g] = var
|
|
if source_ancestor_name:
|
|
self._set_status(
|
|
f"loaded memory from parent '{source_ancestor_name}' "
|
|
f"(only this object's own components)"
|
|
)
|
|
else:
|
|
self._set_status(f"loaded {'file ' + path if path else 'memory ' + key}")
|
|
self._refresh_detail()
|
|
|
|
def _selected_selection(self):
|
|
if not self._group_vars:
|
|
return None
|
|
checked = [g for g, v in self._group_vars.items() if v.get()]
|
|
if not checked or len(checked) == len(self._group_vars):
|
|
return None
|
|
return checked
|
|
|
|
def _set_all_checked(self, value):
|
|
for r in self._state.get("rows", []):
|
|
self._state["checked"][r["name"]] = value
|
|
self._refresh_detail()
|
|
|
|
def _add_detail_row(self, r):
|
|
import tkinter as tk
|
|
from tkinter import ttk
|
|
|
|
row_frame = ttk.Frame(self.detail_inner)
|
|
row_frame.pack(fill="x", anchor="w")
|
|
var = tk.BooleanVar(value=self._state["checked"].get(r["name"], True))
|
|
|
|
def _on_toggle(name=r["name"], v=var):
|
|
self._state["checked"][name] = v.get()
|
|
|
|
ttk.Checkbutton(row_frame, variable=var, command=_on_toggle).pack(side="left")
|
|
status = "not found" if not r["available"] else ("CHANGED" if r["changed"] else "==")
|
|
present = _format_value(r["present"]) if r["available"] else "?"
|
|
text = (
|
|
f"{r['name']} present: {present} memory: {_format_value(r['memory'])} "
|
|
f"diff: {r['diff']} {status}"
|
|
)
|
|
lbl = ttk.Label(row_frame, text=text)
|
|
if r["changed"]:
|
|
lbl.configure(foreground="#1565c0")
|
|
lbl.pack(side="left")
|
|
|
|
def _refresh_detail(self):
|
|
from tkinter import ttk
|
|
|
|
for child in self.detail_inner.winfo_children():
|
|
child.destroy()
|
|
mem = self._state.get("mem")
|
|
if mem is None:
|
|
return
|
|
rows = _diff_rows(self.memory, mem, selection=self._selected_selection())
|
|
self._state["rows"] = rows
|
|
for r in rows:
|
|
self._state["checked"].setdefault(r["name"], True)
|
|
show_changed_only = self.changed_only_var.get()
|
|
|
|
for kind, group_name, payload in _foldable_sections(rows):
|
|
if kind == "section":
|
|
visible = [r for r in payload if (r["changed"] or not show_changed_only)]
|
|
if not visible:
|
|
continue
|
|
ttk.Label(
|
|
self.detail_inner, text=group_name, font=("TkDefaultFont", 9, "bold"),
|
|
).pack(anchor="w", pady=(6, 0))
|
|
for r in visible:
|
|
self._add_detail_row(r)
|
|
else:
|
|
r = payload
|
|
if r["changed"] or not show_changed_only:
|
|
self._add_detail_row(r)
|
|
|
|
def _on_recall(self):
|
|
from tkinter import messagebox
|
|
|
|
if self._state.get("mem") is None:
|
|
self._set_status("load a memory first", error=True)
|
|
return
|
|
checked = self._state["checked"]
|
|
selected_rows = [r for r in self._state["rows"] if checked.get(r["name"], True)]
|
|
if not selected_rows:
|
|
self._set_status("no rows selected to recall", error=True)
|
|
return
|
|
n_changed = sum(1 for r in selected_rows if r["changed"])
|
|
if not messagebox.askyesno(
|
|
"Confirm recall",
|
|
f"Recall {len(selected_rows)} selected value(s)? {n_changed} will actually change.",
|
|
parent=self.top,
|
|
):
|
|
return
|
|
filtered = {r["name"]: r["memory"] for r in selected_rows}
|
|
try:
|
|
self.memory.recall(input_obj={"settings": filtered}, force=True, selection="settings")
|
|
recall_error = None
|
|
except Exception as e:
|
|
recall_error = e
|
|
try:
|
|
self._refresh_detail()
|
|
except Exception:
|
|
pass # _diff_rows already guards each row's own read individually
|
|
if recall_error is None:
|
|
self._set_status("recall complete")
|
|
else:
|
|
self._set_status(_recall_error_status(recall_error), error=True)
|
|
|
|
def _on_export(self):
|
|
from tkinter import filedialog
|
|
|
|
mem = self._state.get("mem")
|
|
if mem is None:
|
|
self._set_status("load a memory first", error=True)
|
|
return
|
|
default_name = _default_export_name(self.memory, _export_label(self._state))
|
|
path = filedialog.asksaveasfilename(
|
|
parent=self.top,
|
|
title="Export memory to file",
|
|
initialfile=default_name,
|
|
defaultextension=".json",
|
|
filetypes=_JSON_FILETYPES_TK,
|
|
)
|
|
if not path:
|
|
return
|
|
try:
|
|
_export_to_file(mem, path)
|
|
self._set_status(f"exported to {path}")
|
|
except Exception as e:
|
|
self._set_status(f"export failed: {e}", error=True)
|
|
|
|
def _on_save(self):
|
|
message = self.message_var.get().strip()
|
|
if not message:
|
|
self._set_status("enter a message before saving", error=True)
|
|
return
|
|
selection = self.save_selection_var.get().strip() or "settings"
|
|
preset_name = self.save_preset_var.get().strip() or None
|
|
try:
|
|
self.memory.memorize(
|
|
message=message, force_message=False, to_elog=self.elog_var.get(),
|
|
selection=selection, preset_varname=preset_name,
|
|
)
|
|
self.message_var.set("")
|
|
self.save_preset_var.set("")
|
|
self._set_status("saved new memory")
|
|
self._refresh_overview()
|
|
except Exception as e:
|
|
self._set_status(f"save failed: {e}", error=True)
|
|
|
|
|
|
def make_memory_browser_tk(assembly, parent=None):
|
|
return MemoryBrowserTk(assembly, parent=parent)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# dispatcher, mirrors Assembly.widget()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def make_memory_browser(assembly, parent=None):
|
|
"""Build the memory browser in whichever backend `assembly.widget()`
|
|
would use: ipywidgets in a notebook, else Qt, falling back to Tk if Qt
|
|
isn't importable."""
|
|
from eco.utilities.utilities import is_notebook
|
|
|
|
if is_notebook():
|
|
return make_memory_browser_ipywidgets(assembly)
|
|
try:
|
|
return make_memory_browser_qt(assembly, parent=parent)
|
|
except ImportError:
|
|
return make_memory_browser_tk(assembly, parent=parent)
|