From eb1e80ac323c5282473f6206d83ec8496195439e Mon Sep 17 00:00:00 2001 From: Chris Shucksmith Date: Mon, 9 Nov 2026 09:56:27 +0000 Subject: [PATCH 1/3] tui: drop the rule under the title of the Move To dialog The list now starts directly below the title like the other dialogs, and the dialog is one row shorter. Co-Authored-By: Claude Sonnet 5.5 --- rollingmill/tui.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/rollingmill/tui.py b/rollingmill/tui.py index 797e496..2b1cd35 100644 --- a/rollingmill/tui.py +++ b/rollingmill/tui.py @@ -1040,7 +1040,7 @@ def _draw_move_dialog(self, stdscr: curses.window, rows: int, cols: int) -> None if self._move_sel >= len(targets): self._move_sel = len(targets) - 1 - dh = min(6 + len(targets), rows - 2) + dh = min(5 + len(targets), rows - 2) dw = min(48, cols - 2) dy = (rows - dh) // 2 dx = (cols - dw) // 2 @@ -1056,10 +1056,9 @@ def _draw_move_dialog(self, stdscr: curses.window, rows: int, cols: int) -> None wcs_lbl = 'MCS' if self._m.active_wcs == 0 else f'WCS{self._m.active_wcs}' title = f' Move To (using {wcs_lbl})' win.addstr(0, 2, title[:dw - 4], CP(_CP_HEADER) | BOLD) - win.addstr(1, 1, '─' * (dw - 2), CP(_CP_LABEL)) for i, (label, _action) in enumerate(targets): - row = 2 + i + row = 1 + i if row >= dh - 3: break is_sel = (i == self._move_sel) From dc664c992c66ac6304f23e9be8b99901e611bb9e Mon Sep 17 00:00:00 2001 From: Chris Shucksmith Date: Mon, 9 Nov 2026 10:03:15 +0000 Subject: [PATCH 2/3] tui: name the active coordinate system in the Move To dialog titles The Move To and Move to position dialogs built their label as "WCS{slot}", a leftover from when slots were just WCS1-WCS10. 34477c2 introduced the slot names (User (RML-1), EXOFS, WCS1 (G54)...) but only switched the header over, so slot 1 showed as "WCS1" rather than "User (RML-1)" and G54 (slot 3) as "WCS3". Both titles now use wcs_label() like the header. Co-Authored-By: Claude Sonnet 5.5 --- rollingmill/tui.py | 4 +- tests/test_wcs_titles.py | 81 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 tests/test_wcs_titles.py diff --git a/rollingmill/tui.py b/rollingmill/tui.py index 2b1cd35..a4776ee 100644 --- a/rollingmill/tui.py +++ b/rollingmill/tui.py @@ -1053,7 +1053,7 @@ def _draw_move_dialog(self, stdscr: curses.window, rows: int, cols: int) -> None win.erase() win.box() - wcs_lbl = 'MCS' if self._m.active_wcs == 0 else f'WCS{self._m.active_wcs}' + wcs_lbl = _machine.wcs_label(self._m.active_wcs) title = f' Move To (using {wcs_lbl})' win.addstr(0, 2, title[:dw - 4], CP(_CP_HEADER) | BOLD) @@ -1094,7 +1094,7 @@ def _coord_entry_dialog(self, stdscr: curses.window) -> None: CP = curses.color_pair BOLD = curses.A_BOLD - wcs_lbl = 'MCS' if self._m.active_wcs == 0 else f'WCS{self._m.active_wcs}' + wcs_lbl = _machine.wcs_label(self._m.active_wcs) win.erase() win.box() diff --git a/tests/test_wcs_titles.py b/tests/test_wcs_titles.py new file mode 100644 index 0000000..503748c --- /dev/null +++ b/tests/test_wcs_titles.py @@ -0,0 +1,81 @@ +"""Dialog titles name the active coordinate system the same way as the header (wcs_label).""" +import curses + +import pytest + +from rollingmill import machine as M +from rollingmill.machine import MDX40A +from rollingmill.tui import TUI, _LogBuffer +from rollingmill.usb import MdxMockUSB + + +class _FakeWin: + """Just enough of a curses window to record what a dialog draws.""" + + def __init__(self, h=24, w=80): + self.h, self.w = h, w + self.drawn = [] + + def getmaxyx(self): + return self.h, self.w + + def addstr(self, y, x, text, attr=0): + self.drawn.append(text) + + def erase(self): + pass + + def box(self): + pass + + def refresh(self): + pass + + def keypad(self, flag): + pass + + def nodelay(self, flag): + pass + + def move(self, y, x): + pass + + def getch(self): + return 27 # Esc: leave modal dialogs at once + + +@pytest.fixture +def fake_curses(monkeypatch): + win = _FakeWin() + monkeypatch.setattr(curses, "newwin", lambda *a, **k: win) + monkeypatch.setattr(curses, "color_pair", lambda n: 0) + monkeypatch.setattr(curses, "curs_set", lambda n: 0) + monkeypatch.setattr(curses, "noecho", lambda: None) + return win + + +@pytest.mark.parametrize("slot", range(0, 9)) +def test_move_to_title_uses_the_slot_names(slot, fake_curses): + m = MDX40A(MdxMockUSB()) + m.set_active_wcs(slot) + TUI(m, _LogBuffer())._draw_move_dialog(_FakeWin(), 30, 100) + title = next(t for t in fake_curses.drawn if "Move To" in t) + assert title.strip() == f"Move To (using {M.WCS_SLOT_NAMES[slot]})" + + +def test_move_to_title_for_user_and_g54_reads_naturally(fake_curses): + m = MDX40A(MdxMockUSB()) + for slot, name in ((1, "User (RML-1)"), (3, "WCS1 (G54)")): + fake_curses.drawn.clear() + m.set_active_wcs(slot) + TUI(m, _LogBuffer())._draw_move_dialog(_FakeWin(), 30, 100) + assert any(name in t for t in fake_curses.drawn) + + +@pytest.mark.parametrize("slot", range(0, 9)) +def test_move_to_position_title_uses_the_slot_names(slot, fake_curses): + m = MDX40A(MdxMockUSB()) + m.set_active_wcs(slot) + TUI(m, _LogBuffer())._coord_entry_dialog(_FakeWin()) + title = next(t for t in fake_curses.drawn if "Move to position" in t) + assert title.strip() == f"Move to position ({M.WCS_SLOT_NAMES[slot]})" From adba485e33d60910971233987ae0865906e9e5ee Mon Sep 17 00:00:00 2001 From: Chris Shucksmith Date: Mon, 9 Nov 2026 10:23:24 +0000 Subject: [PATCH 3/3] tui: M on MCS in the coordinates dialog moves to machine 0,0,0,0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handler returned early for MCS ("MCS origin is always (0,0,0,0) — no-op / already there"), since the first version of the dialog. The MCS origin is the machine zero, but the tool is not necessarily there, so the move is real. MCS already has (0,0,0,0) loaded in index 0, so removing the special case sends the normal absolute move. The overwrite keys still refuse MCS. Co-Authored-By: Claude Sonnet 5.5 --- rollingmill/tui.py | 2 -- tests/test_wcs_dialog_move.py | 64 +++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 tests/test_wcs_dialog_move.py diff --git a/rollingmill/tui.py b/rollingmill/tui.py index a4776ee..1eeb021 100644 --- a/rollingmill/tui.py +++ b/rollingmill/tui.py @@ -774,8 +774,6 @@ def _wcs_handle_key(self, key: int) -> None: elif key in (10, 13): # Enter — activate self._m.set_active_wcs(self._wcs_sel) elif key in (ord('m'), ord('M')): # Move to stored origin - if self._wcs_sel == 0: - return # MCS origin is always (0,0,0,0) — no-op / already there if self._wcs_data and self._wcs_data[self._wcs_sel]: ox, oy, oz, oa = self._wcs_data[self._wcs_sel] self._m.move_to_machine_pos(ox, oy, oz, oa) diff --git a/tests/test_wcs_dialog_move.py b/tests/test_wcs_dialog_move.py new file mode 100644 index 0000000..6d9a4ba --- /dev/null +++ b/tests/test_wcs_dialog_move.py @@ -0,0 +1,64 @@ +"""'M' in the coordinates dialog moves to the selected coordinate system's origin, MCS included.""" +import struct + +import pytest + +from rollingmill.machine import MDX40A +from rollingmill.tui import TUI, _LogBuffer +from rollingmill.usb import MdxMockUSB + +DOWN = 258 # curses.KEY_DOWN + + +class _Recording(MdxMockUSB): + def __init__(self, responses=None): + super().__init__(responses=responses) + self.sent = [] + + def vend_set(self, wValue, data=b""): + self.sent.append((wValue, data)) + super().vend_set(wValue, data) + + def moves(self): + return [d for w, d in self.sent if w == 0x04f7] + + +def _dialog(responses=None): + link = _Recording(responses) + tui = TUI(MDX40A(link), _LogBuffer()) + tui._wcs_open_dialog() + link.sent.clear() + return tui, link + + +def _move_payload(x, y, z, a, speed=1800): + return struct.pack(">HH4i", speed, 0xFFFF, round(x * 1000), round(y * 1000), round(z * 1000), round(a * 1000)) + + +def test_m_on_mcs_sends_a_move_to_machine_zero(): + tui, link = _dialog() + assert tui._wcs_sel == 0 # MCS is the first row + tui._handle_key(ord("m")) + assert link.moves() == [_move_payload(0, 0, 0, 0)] + + +def test_m_on_mcs_is_bracketed_like_every_other_move(): + tui, link = _dialog() + tui._handle_key(ord("M")) + assert [w for w, _ in link.sent] == [0x1109, 0x04f7, 0x03f2, 0x1109] + assert link.sent[0][1] == b"\x00" and link.sent[-1][1] == b"\xff" + + +def test_m_on_a_stored_origin_moves_to_it(): + tui, link = _dialog({0x3203: struct.pack(">4i", 1000, 2000, -3000, 4000)}) # G54 (slot 3) + for _ in range(3): + tui._handle_key(DOWN) + tui._handle_key(ord("m")) + assert link.moves() == [_move_payload(1, 2, -3, 4)] + + +@pytest.mark.parametrize("key", [ord("x"), ord("y"), ord("z"), ord("a"), ord("0")]) +def test_the_overwrite_keys_still_refuse_mcs(key): + tui, link = _dialog() + tui._handle_key(key) + assert link.sent == [] # MCS origin cannot be overwritten