Skip to content

Commit b165d08

Browse files
committed
feat: implement canvas state management and visual state for valves
1 parent c3ca705 commit b165d08

File tree

17 files changed

+757
-104
lines changed

17 files changed

+757
-104
lines changed

AGENTS.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,35 @@ Pychron is a long-lived scientific application with mixed concerns:
1919

2020
Favor conservative, low-regression changes over broad rewrites.
2121

22+
Repo Map
23+
========
24+
25+
Start with the user-facing subsystem and only expand outward as needed.
26+
27+
- application/bootstrap and plugin wiring:
28+
- `launchers/`
29+
- `pychron/applications`
30+
- `pychron/envisage`
31+
- experiment execution and scripting:
32+
- `pychron/experiment`
33+
- `pychron/pyscripts`
34+
- data reduction, processing, and pipeline work:
35+
- `pychron/pipeline`
36+
- `pychron/processing`
37+
- DVC and repository-backed persistence:
38+
- `pychron/dvc`
39+
- hardware, instruments, and control surfaces:
40+
- `pychron/hardware`
41+
- `pychron/lasers`
42+
- `pychron/extraction_line`
43+
- `pychron/spectrometer`
44+
- `pychron/furnace`
45+
- shared foundations used across many subsystems:
46+
- `pychron/core`
47+
- `pychron/database`
48+
- `pychron/paths.py`
49+
- `pychron/globals.py`
50+
2251
Working Rules
2352
=============
2453

@@ -28,28 +57,69 @@ Working Rules
2857
- Expect the repo to contain legacy modules, experimental files, and partial
2958
migrations; patch the active path you are changing instead of trying to
3059
normalize the whole tree in one pass.
60+
- If a subsystem repeatedly needs specialized guidance, add a nested
61+
`AGENTS.md` in that directory instead of overloading the root file. Only do
62+
this when the local conventions are stable and materially different from the
63+
repo-wide defaults.
3164

3265
Code Changes
3366
============
3467

3568
- Prefer focused edits over opportunistic refactors.
69+
- Add type annotations to any function you touch. If a full signature annotation
70+
would force broader churn, annotate the parameters and return value needed for
71+
the current edit and keep the rest of the change scoped.
72+
- When debugging a bug or regression, add targeted instrumentation early to
73+
confirm control flow, state transitions, inputs, and external responses before
74+
attempting speculative fixes.
3675
- When replacing debug `print()` calls in runtime code, use the repo's existing
3776
logging style:
3877
- `Loggable` subclasses use `self.debug(...)`, `self.warning(...)`, etc.
3978
- other modules typically use `new_logger(...)` or a local logger
79+
- Prefer narrow, hypothesis-driven instrumentation near the suspected failure
80+
boundary first: UI actions, task/plugin entrypoints, hardware I/O, experiment
81+
state changes, and DVC/database calls.
82+
- Remove temporary instrumentation before finishing unless it provides ongoing
83+
operational value. Do not broaden a debugging task into repo-wide logging
84+
cleanup or leave noisy logs in hot paths without justification.
4085
- Preserve `if __name__ == "__main__":` demo blocks unless the task is to remove
4186
them. Do not treat demo code as runtime code.
4287
- Keep imports modern in active Qt code. Avoid reintroducing `PySide`,
4388
`traitsui.qt4`, or `pyface.ui.qt4` in touched files.
4489
- Use ASCII unless the file already requires other characters.
4590

91+
Triage Workflow
92+
===============
93+
94+
- Identify the user-facing subsystem first, then trace inward to the smallest
95+
active module that implements the behavior.
96+
- Check for nearby tests before editing. Prefer colocated tests under
97+
`pychron/*/tests`, `test/`, or a subsystem-specific test package before
98+
reaching for broad suites.
99+
- Favor the path already exercised by launchers, plugins, tasks, or currently
100+
referenced docs. Do not normalize parallel legacy code paths unless the task
101+
explicitly requires it.
102+
- Use documentation to disambiguate architecture, startup flow, or workflow
103+
expectations, but do not widen the implementation scope just because related
104+
docs exist.
105+
- When adding a nested `AGENTS.md`, keep it limited to local setup, testing,
106+
or architectural traps. Do not duplicate root policies unless the local file
107+
is intentionally narrowing them for that subtree.
108+
46109
Testing
47110
=======
48111

49112
- Run the narrowest useful verification for the files you touched.
50113
- Good default checks:
51114
- `python -m py_compile <files>`
52115
- `python -m unittest <module>`
116+
- Testing heuristics for this repo:
117+
- prefer module-level or package-level checks before full-suite runs
118+
- search for colocated tests under `pychron/*/tests` before using aggregate
119+
runners such as `pychron/test_suite.py`
120+
- if a change touches Qt, Traits, hardware, or external services, say what is
121+
unavailable and fall back to source-level verification when runtime checks
122+
are not feasible
53123
- If a subsystem depends on Qt, Traits, or hardware services that are unavailable,
54124
say so explicitly and fall back to source-level verification.
55125

@@ -59,6 +129,11 @@ Docs And Workflow
59129
- Keep `README.md` high signal. It should explain what the repo is, where docs
60130
live, and how development works now.
61131
- Prefer documenting branch/release policy in `docs/dev_guide/`.
132+
- Useful orientation documents:
133+
- `README.md`
134+
- `docs/dev_guide/index.rst`
135+
- `docs/dev_guide/running_pychron.rst`
136+
- `docs/dev_guide/git_workflow.rst`
62137
- When release workflow changes, keep these files aligned:
63138
- `docs/dev_guide/git_workflow.rst`
64139
- `docs/dev_guide/repository_settings.rst`

pychron/canvas/canvas2D/extraction_line_canvas2D.py

Lines changed: 86 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@
1717
# ============= enthought library imports =======================
1818
import os
1919

20+
from enable.api import Interactor
2021
from enable.enable_traits import Pointer
2122
from pyface.action.menu_manager import MenuManager
23+
from pyface.qt.QtCore import QPoint
2224
from pyface.qt.QtGui import QToolTip
2325
from traits.api import Any, Str, on_trait_change, Bool, List
2426
from traitsui.menu import Action
@@ -60,6 +62,17 @@ class ExtractionLineAction(Action):
6062
chamber = Str
6163

6264

65+
class ExtractionLineMenuTool(Interactor):
66+
parent = Any
67+
68+
def normal_right_down(self, event):
69+
if event.handled:
70+
return
71+
72+
if self.parent is not None:
73+
self.parent.show_menu(event)
74+
75+
6376
class ExtractionLineCanvas2D(SceneCanvas):
6477
""" """
6578

@@ -96,6 +109,8 @@ class ExtractionLineCanvas2D(SceneCanvas):
96109

97110
_px = None
98111
_py = None
112+
canvas_state = Any
113+
_context_menu = Any
99114

100115
def __init__(self, *args, **kw):
101116
super(ExtractionLineCanvas2D, self).__init__(*args, **kw)
@@ -105,6 +120,7 @@ def __init__(self, *args, **kw):
105120
)
106121
overlay = ExtractionLineInfoOverlay(tool=tool, component=self)
107122
self.tool = tool
123+
self.tools.append(ExtractionLineMenuTool(component=self, parent=self))
108124
self.tools.append(tool)
109125
self.overlays.append(overlay)
110126

@@ -130,6 +146,27 @@ def update_switch_state(self, name, nstate, refresh=True, mode=None):
130146
if refresh:
131147
self.invalidate_and_redraw()
132148

149+
def apply_canvas_state(self, state, refresh=True):
150+
self.canvas_state = state
151+
for name, valve_state in state.valves.items():
152+
self.set_valve_visual_state(name, valve_state, refresh=False)
153+
154+
if refresh:
155+
self.invalidate_and_redraw()
156+
157+
def set_valve_visual_state(self, name, visual_state, refresh=True):
158+
switch = self._get_switch_by_name(name)
159+
if switch is None or visual_state is None:
160+
return
161+
162+
if hasattr(switch, "apply_visual_state"):
163+
switch.apply_visual_state(visual_state)
164+
else:
165+
switch.state = visual_state.is_open
166+
167+
if refresh:
168+
self.invalidate_and_redraw()
169+
133170
def update_switch_owned_state(self, name, owned):
134171
switch = self._get_switch_by_name(name)
135172
if switch is not None:
@@ -184,10 +221,10 @@ def normal_mouse_move(self, event):
184221
self.event_state = "select"
185222
if item != self.active_item:
186223
self.active_item = item
224+
if self.manager:
225+
self.manager.set_active_canvas_item(item)
187226
if isinstance(item, (BaseValve, Switch)):
188227
event.window.set_pointer(self.select_pointer)
189-
if self.manager:
190-
self.manager.set_selected_explanation_item(item)
191228
else:
192229
event.window.control.setToolTip("")
193230
QToolTip.hideText()
@@ -196,7 +233,7 @@ def normal_mouse_move(self, event):
196233
self.event_state = "normal"
197234
event.window.set_pointer(self.normal_pointer)
198235
if self.manager:
199-
self.manager.set_selected_explanation_item(None)
236+
self.manager.set_active_canvas_item(None)
200237

201238
def select_mouse_move(self, event):
202239
""" """
@@ -211,6 +248,18 @@ def select_mouse_move(self, event):
211248
def select_right_down(self, event):
212249
item = self.active_item
213250
if item is not None:
251+
if self.manager and isinstance(item, (BaseValve, Switch)):
252+
self.manager.set_selected_explanation_item(item)
253+
self._show_menu(event, item)
254+
event.handled = True
255+
256+
def show_menu(self, event):
257+
item = self._over_item(event)
258+
if item is not None:
259+
self.active_item = item
260+
if self.manager and isinstance(item, (BaseValve, Switch)):
261+
self.manager.set_active_canvas_item(item)
262+
self.manager.set_selected_explanation_item(item)
214263
self._show_menu(event, item)
215264
event.handled = True
216265

@@ -243,6 +292,9 @@ def set_state(state):
243292
if item is None:
244293
return
245294

295+
if self.manager and isinstance(item, (BaseValve, Switch)):
296+
self.manager.set_selected_explanation_item(item)
297+
246298
if self.edit_mode:
247299
self.event_state = "drag"
248300
event.window.set_pointer(self.drag_pointer)
@@ -348,9 +400,8 @@ def drag_mouse_leave(self, event):
348400
def on_lock(self):
349401
item = self._active_item
350402
if item:
351-
item.soft_lock = lock = not item.soft_lock
403+
lock = not item.soft_lock
352404
self.manager.set_software_lock(item.name, lock)
353-
self.request_redraw()
354405

355406
def on_force_close(self):
356407
self._force_actuate(self.manager.close_valve, False)
@@ -414,31 +465,45 @@ def _action_factory(self, name, func, klass=None, **kw):
414465

415466
def _show_menu(self, event, obj):
416467
actions = []
468+
allow_locking = self.manager.mode != "client" or globalv.client_only_locking
469+
allow_force = self.manager.mode != "client" and self.force_actuate_enabled
417470

418-
if self.manager.mode != "client" or not globalv.client_only_locking:
419-
# print self.active_item, isinstance(self.active_item, Switch)
420-
# if isinstance(self.active_item, Switch):
421-
if isinstance(self.active_item, BaseValve):
422-
t = "Lock"
423-
if obj.soft_lock:
424-
t = "Unlock"
471+
if allow_locking and isinstance(obj, BaseValve):
472+
t = "Lock"
473+
if obj.soft_lock:
474+
t = "Unlock"
425475

426-
action = self._action_factory(t, "on_lock")
427-
actions.append(action)
476+
action = self._action_factory(t, "on_lock")
477+
actions.append(action)
428478

429-
if self.force_actuate_enabled:
430-
action = self._action_factory("Force Close", "on_force_close")
431-
actions.append(action)
479+
if allow_force:
480+
action = self._action_factory("Force Close", "on_force_close")
481+
actions.append(action)
432482

433-
action = self._action_factory("Force Open", "on_force_open")
434-
actions.append(action)
483+
action = self._action_factory("Force Open", "on_force_open")
484+
actions.append(action)
435485

436486
if actions:
437487
menu_manager = MenuManager(*actions)
438488

439489
self._active_item = self.active_item
440-
menu = menu_manager.create_menu(event.window.control, None)
441-
menu.show()
490+
control = event.window.control
491+
menu = menu_manager.create_menu(control, None)
492+
self._context_menu = menu
493+
494+
global_pos = getattr(event, "global_pos", None)
495+
if global_pos is None and control is not None:
496+
size = control.size()
497+
global_pos = control.mapToGlobal(
498+
QPoint(int(event.x), int(size.height() - event.y))
499+
)
500+
501+
if global_pos is not None and hasattr(menu, "exec_"):
502+
menu.exec_(global_pos)
503+
elif hasattr(menu, "exec_"):
504+
menu.exec_()
505+
else:
506+
menu.show()
442507

443508

444509
# ============= EOF ====================================

pychron/canvas/canvas2D/scene/primitives/base.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,14 @@ def map_dimension(self, d, keep_square=False):
216216

217217
def get_bounds(self):
218218
"""Get bounding box as (x, y, x2, y2)."""
219-
key = (self.x, self.y, self.width, self.height)
219+
canvas_bounds = None
220+
if self.canvas is not None:
221+
canvas_bounds = tuple(self.canvas.bounds) if self.canvas.bounds is not None else None
222+
if self.bounds != canvas_bounds:
223+
self._layout_needed = True
224+
self.bounds = canvas_bounds
225+
226+
key = (self.x, self.y, self.width, self.height, canvas_bounds)
220227
if self._cached_bounds is None or self._cached_bounds_key != key:
221228
x, y = self.get_xy(clear_layout_needed=False)
222229
w, h = self.get_wh()

pychron/canvas/canvas2D/scene/primitives/rounded.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@
2929
)
3030

3131

32+
def calc_border_gap_half_width(border_width, connection):
33+
"""Trim border cutouts to the connector interior instead of the full span."""
34+
return max((connection.width - border_width) / 2.0, 1)
35+
36+
3237
def rounded_rect(gc, x, y, width, height, corner_radius):
3338
with gc:
3439
gc.translate_ctm(x, y) # draw a rounded rectangle
@@ -115,7 +120,7 @@ def _render_border(self, gc, x, y, width, height, use_border_gaps=True):
115120
if self.use_border_gaps and use_border_gaps:
116121
# with gc:
117122
for t, c in self.connections:
118-
cw4 = c.width / 2
123+
cw4 = calc_border_gap_half_width(self.border_width, c)
119124
with gc:
120125
gc.set_line_width(self.border_width + 1)
121126
if isinstance(c, (BorderLine, Tee, Elbow, Cross)):
@@ -201,7 +206,7 @@ def angle(x, y):
201206
gc.set_stroke_color(self._convert_color(self.default_color))
202207
for t, c in self.connections:
203208
if isinstance(c, BorderLine):
204-
dw = math.atan((c.width - c.border_width / 2) / r)
209+
dw = math.atan(calc_border_gap_half_width(self.border_width, c) / r)
205210

206211
p1, p2 = c.start_point, c.end_point
207212
p2x, p2y = p2.get_xy()

0 commit comments

Comments
 (0)