From 3a1bc940a3e5e6a387c2dda753dbc2147f49d2ec Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:38:50 +0900 Subject: [PATCH 01/29] test(terminal): pin how a real network device renders Every terminal fixture so far came from a Linux host, so what network gear sends was guesswork - and the second-opinion review twice asked whether the full-screen notice could fire on a pager or a terminal reset. Captured 7058 bytes from the lab Catalyst 8000v over the same vt100 shell the app opens: show version, show ip route, three screens of show interfaces through the pager, and a typo rubbed out with backspace. It contains no escape sequences at all. Not one. The pager erases --More-- with backspace-space-backspace and line editing does the same, so the whole device-facing path rests on \r \n \b. That answers the false-positive question by measurement, and it says what the rewrite must not break: this is the path a network engineer is on all day. Two artefacts of real capture are worth keeping. The pager ate the first character of the next command, so the device answered with % Invalid input and a ^ under the offending column - a column-alignment invariant that comes free. And exit arrived as xit for the same reason. Addresses, MAC addresses and the hostname are replaced with documentation values; the control bytes are untouched, verified by comparing their positions before and after. Co-Authored-By: Claude Opus 5 --- tests/fixtures/cisco_ios_vt100.bin | 147 +++++++++++++++++++++++++ tests/test_terminal_cisco.py | 170 +++++++++++++++++++++++++++++ 2 files changed, 317 insertions(+) create mode 100644 tests/fixtures/cisco_ios_vt100.bin create mode 100644 tests/test_terminal_cisco.py diff --git a/tests/fixtures/cisco_ios_vt100.bin b/tests/fixtures/cisco_ios_vt100.bin new file mode 100644 index 0000000..c5dd841 --- /dev/null +++ b/tests/fixtures/cisco_ios_vt100.bin @@ -0,0 +1,147 @@ + + + +lab-rtr>show version +Cisco IOS XE Software, Version 17.18.02 +Cisco IOS Software [IOSXE], Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 17.18.2, RELEASE SOFTWARE (fc3) +Technical Support: http://www.cisco.com/techsupport +Copyright (c) 1986-2025 by Cisco Systems, Inc. +Compiled Fri 19-Dec-25 03:49 by mcpre + + +Cisco IOS-XE software, Copyright (c) 2005-2025 by cisco Systems, Inc. +All rights reserved. Certain components of Cisco IOS-XE software are +licensed under the GNU General Public License ("GPL") Version 2.0. The +software code licensed under GPL Version 2.0 is free software that comes +with ABSOLUTELY NO WARRANTY. You can redistribute and/or modify such +GPL code under the terms of GPL Version 2.0. For more details, see the +documentation or "License Notice" file accompanying the IOS-XE software, +or the applicable URL provided on the flyer accompanying the IOS-XE +software. + + +ROM: IOS-XE ROMMON + +lab-rtr uptime is 2 days, 11 hours, 43 minutes +Uptime for this control processor is 2 days, 11 hours, 46 minutes + --More--   +lab-rtr>how ip interface brief + ^ +% Invalid input detected at '^' marker. + +lab-rtr>show ip route +Codes: L - local, C - connected, S - static, R - RIP, M - mobile, B - BGP + D - EIGRP, EX - EIGRP external, O - OSPF, IA - OSPF inter area + N1 - OSPF NSSA external type 1, N2 - OSPF NSSA external type 2 + E1 - OSPF external type 1, E2 - OSPF external type 2, m - OMP + n - NAT, Ni - NAT inside, No - NAT outside, Nd - NAT DIA + i - IS-IS, su - IS-IS summary, L1 - IS-IS level-1, L2 - IS-IS level-2 + ia - IS-IS inter area, * - candidate default, U - per-user static route + H - NHRP, G - NHRP registered, g - NHRP registration summary + o - ODR, P - periodic downloaded static route, l - LISP + a - application route + + - replicated route, % - next hop override, p - overrides from PfR + & - replicated local route overrides by connected + +Gateway of last resort is 192.0.2.1 to network 0.0.0.0 + +S* 0.0.0.0/0 [1/0] via 192.0.2.1 + 192.0.2.0/8 is variably subnetted, 3 subnets, 2 masks +C 192.0.2.0/24 is directly connected, GigabitEthernet1 +L 192.0.2.187/32 is directly connected, GigabitEthernet1 +L 192.0.2.234/32 is directly connected, GigabitEthernet1 +lab-rtr>show interfaces +GigabitEthernet1 is up, line protocol is up + Hardware is vNIC, address is 0000.5e00.5302 (bia 0000.5e00.5302) + Internet address is 192.0.2.234/24 + MTU 1500 bytes, BW 1000000 Kbit/sec, DLY 10 usec, + reliability 255/255, txload 1/255, rxload 1/255 + Encapsulation ARPA, loopback not set + Keepalive set (10 sec) + Full Duplex, 1000Mbps, link type is auto, media type is Virtual + output flow-control is unsupported, input flow-control is unsupported + ARP type: ARPA, ARP Timeout 04:00:00 + Last input 00:00:00, output 00:00:00, output hang never + Last clearing of "show interface" counters never + Input queue: 0/375/0/0 (size/max/drops/flushes); Total output drops: 0 + Queueing strategy: fifo + Output queue: 0/40 (size/max) + 5 minute input rate 1000 bits/sec, 2 packets/sec + 5 minute output rate 3000 bits/sec, 3 packets/sec + 80122 packets input, 7124598 bytes, 0 no buffer + Received 0 broadcasts (0 IP multicasts) + 0 runts, 0 giants, 0 throttles + 0 input errors, 0 CRC, 0 frame, 0 overrun, 0 ignored + 0 watchdog, 0 multicast, 0 pause input + --More--   74728 packets output, 33646647 bytes, 0 underruns + Output 0 broadcasts (0 IP multicasts) + 0 output errors, 0 collisions, 0 interface resets + 0 unknown protocol drops + 0 babbles, 0 late collision, 0 deferred + 0 lost carrier, 0 no carrier, 0 pause output + 0 output buffer failures, 0 output buffers swapped out +GigabitEthernet2 is administratively down, line protocol is down + Hardware is vNIC, address is 0000.5e00.5303 (bia 0000.5e00.5303) + MTU 1500 bytes, BW 1000000 Kbit/sec, DLY 10 usec, + reliability 255/255, txload 1/255, rxload 1/255 + Encapsulation ARPA, loopback not set + Keepalive set (10 sec) + Full Duplex, 1000Mbps, link type is auto, media type is Virtual + output flow-control is unsupported, input flow-control is unsupported + ARP type: ARPA, ARP Timeout 04:00:00 + Last input never, output 2d11h, output hang never + Last clearing of "show interface" counters never + Input queue: 0/375/0/0 (size/max/drops/flushes); Total output drops: 0 + Queueing strategy: fifo + Output queue: 0/40 (size/max) + 5 minute input rate 0 bits/sec, 0 packets/sec + 5 minute output rate 0 bits/sec, 0 packets/sec + --More--   0 packets input, 0 bytes, 0 no buffer + Received 0 broadcasts (0 IP multicasts) + 0 runts, 0 giants, 0 throttles + 0 input errors, 0 CRC, 0 frame, 0 overrun, 0 ignored + 0 watchdog, 0 multicast, 0 pause input + 1 packets output, 364 bytes, 0 underruns + Output 0 broadcasts (0 IP multicasts) + 0 output errors, 0 collisions, 0 interface resets + 0 unknown protocol drops + 0 babbles, 0 late collision, 0 deferred + 1 lost carrier, 0 no carrier, 0 pause output + 0 output buffer failures, 0 output buffers swapped out +GigabitEthernet3 is administratively down, line protocol is down + Hardware is vNIC, address is 0000.5e00.5301 (bia 0000.5e00.5301) + MTU 1500 bytes, BW 1000000 Kbit/sec, DLY 10 usec, + reliability 255/255, txload 1/255, rxload 1/255 + Encapsulation ARPA, loopback not set + Keepalive set (10 sec) + Full Duplex, 1000Mbps, link type is auto, media type is Virtual + output flow-control is unsupported, input flow-control is unsupported + ARP type: ARPA, ARP Timeout 04:00:00 + Last input never, output 2d11h, output hang never + Last clearing of "show interface" counters never + --More--   +lab-rtr>show verzz  sion +Cisco IOS XE Software, Version 17.18.02 +Cisco IOS Software [IOSXE], Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 17.18.2, RELEASE SOFTWARE (fc3) +Technical Support: http://www.cisco.com/techsupport +Copyright (c) 1986-2025 by Cisco Systems, Inc. +Compiled Fri 19-Dec-25 03:49 by mcpre + + +Cisco IOS-XE software, Copyright (c) 2005-2025 by cisco Systems, Inc. +All rights reserved. Certain components of Cisco IOS-XE software are +licensed under the GNU General Public License ("GPL") Version 2.0. The +software code licensed under GPL Version 2.0 is free software that comes +with ABSOLUTELY NO WARRANTY. You can redistribute and/or modify such +GPL code under the terms of GPL Version 2.0. For more details, see the +documentation or "License Notice" file accompanying the IOS-XE software, +or the applicable URL provided on the flyer accompanying the IOS-XE +software. + + +ROM: IOS-XE ROMMON + +lab-rtr uptime is 2 days, 11 hours, 44 minutes +Uptime for this control processor is 2 days, 11 hours, 46 minutes + --More--   +lab-rtr>xit diff --git a/tests/test_terminal_cisco.py b/tests/test_terminal_cisco.py new file mode 100644 index 0000000..3d95cad --- /dev/null +++ b/tests/test_terminal_cisco.py @@ -0,0 +1,170 @@ +"""ネットワーク機器の出力が正しく描かれることを、実機の受信バイトで検証する。 + +`tests/fixtures/cisco_ios_vt100.bin` は Catalyst 8000v (IOS-XE) へ SSH で +つなぎ、NetBelt と同じ `invoke_shell(term='vt100', width=80, height=24)` で +採った受信バイトそのもの。show version / show ip interface brief / +show ip route / show interfaces(ページャを 3 画面ぶん歩く)/ 打ち間違いを +backspace で消して打ち直す、を含む。実環境を指す値だけ文書用のものへ +置き換えてあり、制御文字は 1 バイトも変えていない。 + +**この 7058 バイトに、エスケープシーケンスは 1 つも含まれていない。** +届くのは `\\r` `\\n` `\\b` だけ。ネットワーク機器の画面はバックスペースの +上書きだけで組み立てられており、それが NetBelt の主用途である以上、 +この経路を壊すことは許容できない。 + +全画面アプリの検知が機器の通常操作で誤爆しないか、という問いにも +ここで答えている(目印は 1 度も現れない)。 +""" +import io +import os +import re +import sys +import unittest + +sys.path.insert(0, "src") + +FIXTURES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures") +CAPTURE = "cisco_ios_vt100.bin" + + +def capture(): + raw = io.open(os.path.join(FIXTURES, CAPTURE), "rb").read() + return raw.decode("utf-8", "replace") + + +class CiscoRenderingTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PyQt6.QtWidgets import QApplication + cls.app = QApplication.instance() or QApplication([]) + + def _screen(self, *payloads): + from ui.terminal_widget import TerminalWidget + w = TerminalWidget() + w.create_terminal_tab("dev") + for payload in payloads: + w.append_output("dev", payload) + return w._terminals["dev"].toPlainText() + + def test_the_pager_prompt_is_erased(self): + """--More-- が画面に残らないこと。 + + 機器は `\\b` と空白で消しにいく。消し方を取り違えると、 + show 出力のあちこちに --More-- が残る。 + """ + text = self._screen(capture()) + + leftover = [l for l in text.split("\n") if "More" in l] + self.assertEqual(leftover, [], + "ページャの表示が残っている: %r" % leftover[:3]) + + def test_a_typo_corrected_with_backspace_renders_clean(self): + """打ち間違いを消して打ち直した行が、正しく見えること。 + + 採取時に `show verzz` と打ち、backspace 2 回で消して + `sion` と続けた。画面には `show version` だけが見えるべき。 + """ + text = self._screen(capture()) + + self.assertIn("show version", text) + self.assertNotIn("verzz", text, "消したはずの文字が残っている") + + def test_the_output_keeps_its_columns(self): + """桁で読む出力が崩れないこと。 + + 機器の出力は桁で意味を持つ。採取時、直前のページャが次の + コマンドの先頭文字を食べて `how ip interface brief` になり、 + 機器が `^` で誤りの位置を指してきた。この `^` がずれると + どこが悪いのか読めない。あわせて show ip route の凡例の + 字下げも見る。 + """ + lines = self._screen(capture()).split("\n") + + marker = [i for i, l in enumerate(lines) if l.strip() == "^"] + self.assertEqual(len(marker), 1, + "^ の行が %d 行ある" % len(marker)) + at = marker[0] + self.assertIn("Invalid input", lines[at + 1], + "^ の次に理由が続いていない: %r" % lines[at + 1]) + self.assertEqual(lines[at].index("^"), 10, + "^ の桁がずれている: %r" % lines[at]) + + indented = [l for l in lines + if l.startswith(" ") and l.strip()] + self.assertGreaterEqual(len(indented), 10, + "凡例の字下げが失われている") + + def test_no_full_screen_notice_for_ordinary_device_work(self): + """機器を普通に操作している間、全画面アプリの案内を出さないこと。 + + レビューで 2 度「ページャや端末リセットで誤爆しないか」と + 指摘された。実機で測ったところ、目印になるシーケンスは + 1 度も届かない。 + """ + from ui.terminal_widget import TerminalWidget + text = self._screen(capture()) + + self.assertNotIn(TerminalWidget.ALT_SCREEN_NOTICE, text, + "機器の通常操作で誤爆している") + + def test_every_split_point_renders_the_same(self): + """受信がどこで切れても、同じ画面になること。""" + raw = capture() + whole = self._screen(raw) + for cut in range(1, len(raw), 7): # 7 バイトおきに全体を走査 + split = self._screen(raw[:cut], raw[cut:]) + if split != whole: + self.fail( + "%d バイト目で分けると画面が変わる\n" + " 切れ目: ...%r | %r...\n" + " 差分: %+d 文字" + % (cut, raw[max(0, cut - 8):cut], raw[cut:cut + 8], + len(split) - len(whole))) + + +class CiscoCaptureIntegrityTest(unittest.TestCase): + """採取データが、検証の材料として妥当であること。""" + + def test_the_device_sends_no_escape_sequences_at_all(self): + """ネットワーク機器はエスケープを送ってこないこと。 + + ここが崩れると、この検証の前提が変わる。逆に言えば、 + 機器向けに必要なのは `\\r` `\\n` `\\b` の扱いだけということ。 + """ + text = capture() + + found = re.findall(r"\x1b\[[0-9:;<=>?]*[ -/]*[@-~]", text) + self.assertEqual(found, [], + "エスケープが含まれている: %r" % found[:5]) + self.assertNotIn("\x1b", text) + + def test_the_capture_exercises_backspace_overwriting(self): + """バックスペースでの上書きが実際に含まれていること。""" + text = capture() + + self.assertGreater(text.count("\b"), 50, + "バックスペースが少なすぎる(材料として不足)") + + def test_the_capture_names_no_real_environment(self): + """公開できる状態であること。""" + text = capture() + + for pattern, label in ( + (r"\b10\.\d{1,3}\.\d{1,3}\.\d{1,3}\b", "私用 IPv4"), + (r"\b192\.168\.\d{1,3}\.\d{1,3}\b", "私用 IPv4"), + (r"\b5254\.[0-9a-f]{4}\.[0-9a-f]{4}\b", "実 MAC"), + (r"\b[0-9a-f]{2}(?::[0-9a-f]{2}){5}\b", "MAC アドレス")): + found = re.search(pattern, text, re.I) + self.assertIsNone( + found, "%s が残っている: %s" + % (label, found.group(0) if found else "")) + + for ip in re.findall(r"\b\d{1,3}(?:\.\d{1,3}){3}\b", text): + self.assertRegex( + ip, r"^(?:192\.0\.2\.|198\.51\.100\.|203\.0\.113\.|127\.|0\.0\.0\.0)", + "文書用に割り当てられていないアドレス: %s" % ip) + + +if __name__ == "__main__": + unittest.main() From 0a1e8798acebecf0e30781ad7a3a03f1e5029bd3 Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:09:34 +0900 Subject: [PATCH 02/29] feat(terminal): add the stream parser skeleton First piece of the v1.2.0 terminal rewrite. The old widget interprets control codes with regexes over a text buffer and patches receive boundaries by hand (_pending_escape); that seam produced most of the terminal bugs this release cycle. Replace the approach at the root: implement Paul Williams' DEC-compatible parser state machine (vt100.net/emu/dec_ansi_parser), which carries its state across feeds, so a sequence split anywhere on the wire parses identically. This commit covers ground, escape, escape intermediate, OSC and SOS/PM/APC; the CSI and DCS states follow in the next commits, so for now ESC [ falls through to esc_dispatch and ESC P is consumed like SOS. Nothing imports the module yet. It knows nothing about Qt. Deliberate deviations from the diagram are listed in the module docstring (BEL-terminated OSC as xterm does, decoded-str input instead of bytes, aborted strings are discarded). The tests pin the property that motivated the rewrite: every split point of a stream yields the same command list - including the 7058 byte Catalyst 8000v capture, which must come out as Print/Ctrl only and rebuild byte-for-byte. Co-Authored-By: Claude Opus 5 --- src/core/terminal/__init__.py | 1 + src/core/terminal/parser.py | 134 ++++++++++++++++++++++++++++ tests/test_terminal_parser.py | 161 ++++++++++++++++++++++++++++++++++ 3 files changed, 296 insertions(+) create mode 100644 src/core/terminal/__init__.py create mode 100644 src/core/terminal/parser.py create mode 100644 tests/test_terminal_parser.py diff --git a/src/core/terminal/__init__.py b/src/core/terminal/__init__.py new file mode 100644 index 0000000..8613955 --- /dev/null +++ b/src/core/terminal/__init__.py @@ -0,0 +1 @@ +# 端末エミュレーション。パーサと画面は UI を知らない。 diff --git a/src/core/terminal/parser.py b/src/core/terminal/parser.py new file mode 100644 index 0000000..0aec0d5 --- /dev/null +++ b/src/core/terminal/parser.py @@ -0,0 +1,134 @@ +"""受信文字列を端末命令の列へ直す状態機械。 + +Paul Williams の DEC 互換パーサ状態遷移図 +(https://vt100.net/emu/dec_ansi_parser) を実装する。UI を知らない。 +受信の切れ目は状態として持ち越されるので、呼び出し側での +継ぎ足し処理は要らない。 + +図からの意図的な違いは 3 つ: +- OSC を BEL (0x07) でも終端する。図の終端は ST だけだが、実際の + シェルは ``ESC ] 0 ; title BEL`` を送ってくる (xterm の挙動)。 +- 入力はデコード済みの str。0x80-0x9F の C1 制御は UTF-8 経由では + 現れないので、C1 の遷移は実装しない。図に無い 0x7E 超えの文字が + 列の途中に来たら、列を捨ててその文字を印字する。 +- CAN/SUB で中断した OSC/DCS は、途中までの中身を届けず捨てる。 +""" +import collections + +Print = collections.namedtuple("Print", "text") +Ctrl = collections.namedtuple("Ctrl", "char") +Esc = collections.namedtuple("Esc", "intermediate final") +Osc = collections.namedtuple("Osc", "text") + +(GROUND, ESCAPE, ESCAPE_INTERMEDIATE, + OSC_STRING, SOS_PM_APC) = range(5) + +MAX_PARAMS = 256 # 暴走した列でメモリを食わないための上限 +MAX_STRING = 4096 + + +class Parser(object): + def __init__(self): + self.state = GROUND + self._clear() + self._string = [] + + def _clear(self): + self._private = "" + self._params = "" + self._intermediate = "" + + def _end_string(self, out): + """ESC / ST で文字列列 (OSC) を閉じ、確定した命令を出す。""" + if self.state == OSC_STRING: + out.append(Osc("".join(self._string))) + self._string = [] + + def feed(self, text): + """デコード済み文字列を食わせ、確定した命令のリストを返す。""" + out = [] + printable = [] + + def flush(): + if printable: + out.append(Print("".join(printable))) + del printable[:] + + def abort_and_print(ch): + # 図に無い文字 (0x7E 超え) が列の途中に来た + self.state = GROUND + printable.append(ch) + + for ch in text: + code = ord(ch) + + # anywhere: どの状態でも同じ扱い + if code == 0x1B: + flush() + self._end_string(out) + self._clear() + self.state = ESCAPE + continue + if code in (0x18, 0x1A): # CAN / SUB は列を捨てる + flush() + self._string = [] + out.append(Ctrl(ch)) + self.state = GROUND + continue + + state = self.state + if state == GROUND: + if code >= 0x20: # 0x7F(DEL) の無視は画面側で + printable.append(ch) + else: + flush() + out.append(Ctrl(ch)) + continue + flush() + + if state == ESCAPE: + if code < 0x20: + out.append(Ctrl(ch)) + elif code <= 0x2F: + self._intermediate += ch + self.state = ESCAPE_INTERMEDIATE + elif ch == "]": + self._string = [] + self.state = OSC_STRING + elif ch in "PX^_": # DCS はまだ中身を読まず捨てる + self.state = SOS_PM_APC + elif code <= 0x7E: + out.append(Esc(self._intermediate, ch)) + self.state = GROUND + elif code == 0x7F: + pass + else: + abort_and_print(ch) + + elif state == ESCAPE_INTERMEDIATE: + if code < 0x20: + out.append(Ctrl(ch)) + elif code <= 0x2F: + self._intermediate += ch + elif code <= 0x7E: + out.append(Esc(self._intermediate, ch)) + self.state = GROUND + elif code == 0x7F: + pass + else: + abort_and_print(ch) + + elif state == OSC_STRING: + if code == 0x07: # xterm 拡張: BEL 終端 + self._end_string(out) + self.state = GROUND + elif code < 0x20: + pass + elif len(self._string) < MAX_STRING: + self._string.append(ch) + + elif state == SOS_PM_APC: + pass # ST/ESC/CAN/SUB まで無視 + + flush() + return out diff --git a/tests/test_terminal_parser.py b/tests/test_terminal_parser.py new file mode 100644 index 0000000..d09e12b --- /dev/null +++ b/tests/test_terminal_parser.py @@ -0,0 +1,161 @@ +"""パーサ状態機械の検証。 + +Paul Williams の遷移図 (vt100.net/emu/dec_ansi_parser) の状態を +そのままテストにする。Qt を一切使わない。 + +いちばん大事なのは「受信がどこで切れても同じ命令列になる」こと。 +旧実装はこれを正規表現の継ぎ足し (_pending_escape) でやっていて、 +そこが不具合の温床だった。状態機械は状態を持ち越すだけでこれを +満たすはずで、実機の採取データ全部で確かめる。 +""" +import io +import os +import sys +import unittest + +sys.path.insert(0, "src") + +from core.terminal.parser import ( # noqa: E402 + Parser, Print, Ctrl, Esc, Osc, MAX_STRING) + +FIXTURES = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "fixtures") + + +def parse(text): + return Parser().feed(text) + + +def merged(events): + """隣り合う Print を繋げる。切れ目の位置だけが違う出力を比べるため。""" + result = [] + for event in events: + if (result and isinstance(event, Print) + and isinstance(result[-1], Print)): + result[-1] = Print(result[-1].text + event.text) + else: + result.append(event) + return result + + +def fixture(name): + raw = io.open(os.path.join(FIXTURES, name), "rb").read() + return raw.decode("utf-8", "replace") + + +class GroundStateTest(unittest.TestCase): + def test_plain_text_comes_out_as_one_print(self): + self.assertEqual(parse("show version"), [Print("show version")]) + + def test_c0_controls_split_the_text(self): + self.assertEqual(parse("ab\r\ncd"), + [Print("ab"), Ctrl("\r"), Ctrl("\n"), Print("cd")]) + + def test_backspace_is_a_control(self): + self.assertEqual(parse("a\bb"), + [Print("a"), Ctrl("\b"), Print("b")]) + + def test_multibyte_text_prints(self): + self.assertEqual(parse("日本語"), [Print("日本語")]) + + def test_del_prints_here_and_is_dropped_by_the_screen(self): + # 図では ground の 20-7F は print。捨てる判断は画面側の仕事 + self.assertEqual(parse("\x7f"), [Print("\x7f")]) + + +class EscapeStateTest(unittest.TestCase): + def test_a_final_byte_dispatches(self): + self.assertEqual(parse("\x1b7"), [Esc("", "7")]) + + def test_an_intermediate_is_collected(self): + self.assertEqual(parse("\x1b(B"), [Esc("(", "B")]) + + def test_keypad_mode_is_a_plain_escape(self): + self.assertEqual(parse("\x1b="), [Esc("", "=")]) + + def test_esc_inside_esc_starts_over(self): + self.assertEqual(parse("\x1b\x1b7"), [Esc("", "7")]) + + def test_a_control_executes_without_leaving_the_sequence(self): + self.assertEqual(parse("\x1b(\rB"), + [Ctrl("\r"), Esc("(", "B")]) + + def test_a_character_outside_the_diagram_aborts_and_prints(self): + self.assertEqual(parse("\x1b日X"), [Print("日X")]) + + +class OscStringTest(unittest.TestCase): + def test_bel_terminates_like_xterm(self): + self.assertEqual(parse("\x1b]0;title\x07"), [Osc("0;title")]) + + def test_st_terminates_like_the_diagram(self): + self.assertEqual(parse("\x1b]0;hi\x1b\\"), + [Osc("0;hi"), Esc("", "\\")]) + + def test_cancel_discards_the_partial_string(self): + self.assertEqual(parse("\x1b]0;abc\x18Z"), + [Ctrl("\x18"), Print("Z")]) + + def test_an_endless_string_is_capped(self): + events = parse("\x1b]" + "a" * (MAX_STRING + 100) + "\x07") + self.assertEqual(len(events), 1) + self.assertEqual(len(events[0].text), MAX_STRING) + + +class SosPmApcTest(unittest.TestCase): + def test_the_string_is_consumed_silently(self): + self.assertEqual(parse("\x1bXsecret\x1b\\A"), + [Esc("", "\\"), Print("A")]) + + +class StreamContinuityTest(unittest.TestCase): + """受信の切れ目に関する性質。ここが旧実装の弱点だった。""" + + def test_state_survives_between_feeds(self): + p = Parser() + self.assertEqual(p.feed("\x1b("), []) + self.assertEqual(p.feed("B"), [Esc("(", "B")]) + + def test_every_split_point_gives_the_same_commands(self): + stream = "ab\x1b(B\r\n\x1b]0;t\x07日\x1b7xy" + whole = merged(parse(stream)) + for cut in range(1, len(stream)): + p = Parser() + split = merged(p.feed(stream[:cut]) + p.feed(stream[cut:])) + self.assertEqual(split, whole, + "%d 文字目で切ると命令列が変わる" % cut) + + +class RealDeviceCaptureTest(unittest.TestCase): + """Catalyst 8000v の実データ (7058 バイト)。""" + + def test_a_network_device_yields_only_print_and_ctrl(self): + events = parse(fixture("cisco_ios_vt100.bin")) + kinds = {type(e).__name__ for e in events} + self.assertLessEqual(kinds, {"Print", "Ctrl"}, + "機器の出力から想定外の命令が出た: %s" % kinds) + + def test_the_device_controls_are_just_cr_lf_bs(self): + events = parse(fixture("cisco_ios_vt100.bin")) + chars = {e.char for e in events if isinstance(e, Ctrl)} + self.assertLessEqual(chars, {"\r", "\n", "\b"}) + + def test_nothing_is_lost_or_invented(self): + raw = fixture("cisco_ios_vt100.bin") + events = parse(raw) + rebuilt = "".join( + e.text if isinstance(e, Print) else e.char for e in events) + self.assertEqual(rebuilt, raw) + + def test_every_split_point_gives_the_same_commands(self): + raw = fixture("cisco_ios_vt100.bin") + whole = merged(parse(raw)) + for cut in range(1, len(raw), 7): + p = Parser() + split = merged(p.feed(raw[:cut]) + p.feed(raw[cut:])) + self.assertEqual(split, whole, + "%d バイト目で切ると命令列が変わる" % cut) + + +if __name__ == "__main__": + unittest.main() From 83df635af4b1561e2e5941dee1b05a3024ea0b50 Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:11:25 +0900 Subject: [PATCH 03/29] feat(terminal): parse CSI sequences Adds the four CSI states from the Williams diagram (entry, param, intermediate, ignore). The parser stays deliberately dumb: params come out as numbers with omitted positions kept as None, private markers (< = > ?) ride in their own field, and unknown finals still come through - deciding what to honour is the screen's job, not the parser's. Two poison paths follow the diagram rather than modern practice: a colon (ECMA-48 sub-parameters, e.g. SGR 38:5:1) sends the whole sequence to csi_ignore, and a param string over MAX_PARAMS does the same instead of growing without bound. Neither form appears in any capture; revisit the colon if 256-colour SGR ever shows up. The Linux capture tests assert measured values, not guesses: nano under TERM=vt100 builds its screen with DECSTBM (ESC[1;24r) and CUP, bash toggles bracketed paste (?2004), clear is ESC[H ESC[J, and no capture contains a single OSC. Every split point of all three streams parses identically. Co-Authored-By: Claude Opus 5 --- src/core/terminal/parser.py | 81 ++++++++++++++++++++++++++++++++- tests/test_terminal_parser.py | 86 ++++++++++++++++++++++++++++++++++- 2 files changed, 165 insertions(+), 2 deletions(-) diff --git a/src/core/terminal/parser.py b/src/core/terminal/parser.py index 0aec0d5..2e23f4f 100644 --- a/src/core/terminal/parser.py +++ b/src/core/terminal/parser.py @@ -18,10 +18,12 @@ Print = collections.namedtuple("Print", "text") Ctrl = collections.namedtuple("Ctrl", "char") Esc = collections.namedtuple("Esc", "intermediate final") +Csi = collections.namedtuple("Csi", "private params intermediate final") Osc = collections.namedtuple("Osc", "text") (GROUND, ESCAPE, ESCAPE_INTERMEDIATE, - OSC_STRING, SOS_PM_APC) = range(5) + CSI_ENTRY, CSI_PARAM, CSI_INTERMEDIATE, CSI_IGNORE, + OSC_STRING, SOS_PM_APC) = range(9) MAX_PARAMS = 256 # 暴走した列でメモリを食わないための上限 MAX_STRING = 4096 @@ -38,6 +40,11 @@ def _clear(self): self._params = "" self._intermediate = "" + def _split_params(self): + if not self._params: + return () + return tuple(int(p) if p else None for p in self._params.split(";")) + def _end_string(self, out): """ESC / ST で文字列列 (OSC) を閉じ、確定した命令を出す。""" if self.state == OSC_STRING: @@ -92,6 +99,9 @@ def abort_and_print(ch): elif code <= 0x2F: self._intermediate += ch self.state = ESCAPE_INTERMEDIATE + elif ch == "[": + self._clear() + self.state = CSI_ENTRY elif ch == "]": self._string = [] self.state = OSC_STRING @@ -118,6 +128,75 @@ def abort_and_print(ch): else: abort_and_print(ch) + elif state == CSI_ENTRY: + if code < 0x20: + out.append(Ctrl(ch)) + elif code <= 0x2F: + self._intermediate += ch + self.state = CSI_INTERMEDIATE + elif ch == ":": + self.state = CSI_IGNORE + elif code <= 0x39 or ch == ";": + self._params += ch + self.state = CSI_PARAM + elif code <= 0x3F: # < = > ? + self._private += ch + self.state = CSI_PARAM + elif code <= 0x7E: + out.append(Csi(self._private, self._split_params(), + self._intermediate, ch)) + self.state = GROUND + elif code == 0x7F: + pass + else: + abort_and_print(ch) + + elif state == CSI_PARAM: + if code < 0x20: + out.append(Ctrl(ch)) + elif code <= 0x2F: + self._intermediate += ch + self.state = CSI_INTERMEDIATE + elif (0x30 <= code <= 0x39 or ch == ";") and \ + len(self._params) < MAX_PARAMS: + self._params += ch + elif code <= 0x3F: # : < = > ? と長すぎる列 + self.state = CSI_IGNORE + elif code <= 0x7E: + out.append(Csi(self._private, self._split_params(), + self._intermediate, ch)) + self.state = GROUND + elif code == 0x7F: + pass + else: + abort_and_print(ch) + + elif state == CSI_INTERMEDIATE: + if code < 0x20: + out.append(Ctrl(ch)) + elif code <= 0x2F: + self._intermediate += ch + elif code <= 0x3F: + self.state = CSI_IGNORE + elif code <= 0x7E: + out.append(Csi(self._private, self._split_params(), + self._intermediate, ch)) + self.state = GROUND + elif code == 0x7F: + pass + else: + abort_and_print(ch) + + elif state == CSI_IGNORE: + if code < 0x20: + out.append(Ctrl(ch)) + elif 0x40 <= code <= 0x7E: + self.state = GROUND + elif code <= 0x7F: + pass + else: + abort_and_print(ch) + elif state == OSC_STRING: if code == 0x07: # xterm 拡張: BEL 終端 self._end_string(out) diff --git a/tests/test_terminal_parser.py b/tests/test_terminal_parser.py index d09e12b..b53c050 100644 --- a/tests/test_terminal_parser.py +++ b/tests/test_terminal_parser.py @@ -16,7 +16,7 @@ sys.path.insert(0, "src") from core.terminal.parser import ( # noqa: E402 - Parser, Print, Ctrl, Esc, Osc, MAX_STRING) + Parser, Print, Ctrl, Esc, Csi, Osc, MAX_STRING) FIXTURES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures") @@ -84,6 +84,42 @@ def test_a_character_outside_the_diagram_aborts_and_prints(self): self.assertEqual(parse("\x1b日X"), [Print("日X")]) +class CsiSequenceTest(unittest.TestCase): + def test_no_params_at_all(self): + self.assertEqual(parse("\x1b[m"), [Csi("", (), "", "m")]) + + def test_params_are_numbers(self): + self.assertEqual(parse("\x1b[1;24r"), [Csi("", (1, 24), "", "r")]) + + def test_an_omitted_param_stays_visible_as_none(self): + self.assertEqual(parse("\x1b[;5H"), [Csi("", (None, 5), "", "H")]) + + def test_private_markers_are_kept_apart(self): + self.assertEqual(parse("\x1b[?1049h"), [Csi("?", (1049,), "", "h")]) + + def test_an_intermediate_is_collected(self): + self.assertEqual(parse("\x1b[4 q"), [Csi("", (4,), " ", "q")]) + + def test_an_unknown_final_still_comes_through(self): + # 何を捨てるかを決めるのはパーサでなく画面 + self.assertEqual(parse("\x1b[999X"), [Csi("", (999,), "", "X")]) + + def test_a_control_executes_without_leaving_the_sequence(self): + self.assertEqual(parse("\x1b[1;\r24r"), + [Ctrl("\r"), Csi("", (1, 24), "", "r")]) + + def test_a_colon_poisons_the_whole_sequence(self): + # 図の通り。サブパラメータ (38:5:1 形式) は列ごと無視する + self.assertEqual(parse("\x1b[38:5:1mX"), [Print("X")]) + + def test_runaway_params_poison_the_whole_sequence(self): + self.assertEqual(parse("\x1b[" + "1" * 300 + "mX"), [Print("X")]) + + def test_cancel_aborts_midway(self): + self.assertEqual(parse("\x1b[12\x18mX"), + [Ctrl("\x18"), Print("mX")]) + + class OscStringTest(unittest.TestCase): def test_bel_terminates_like_xterm(self): self.assertEqual(parse("\x1b]0;title\x07"), [Osc("0;title")]) @@ -157,5 +193,53 @@ def test_every_split_point_gives_the_same_commands(self): "%d バイト目で切ると命令列が変わる" % cut) +class RealLinuxCaptureTest(unittest.TestCase): + """Ubuntu の実データ (nano / 普通のシェル / clear)。 + + 値はどれも実測。nano は TERM=vt100 では代替画面 (?1049) を使わず、 + DECSTBM (ESC[1;24r) と CUP で全画面を組む。clear は ESC[H ESC[J。 + どのシェル操作にも OSC は 1 度も現れない。 + """ + LINUX = ("nano_vt100.bin", "shell_vt100.bin", "clear_vt100.bin") + + def test_nano_reaches_the_full_screen_markers(self): + events = parse(fixture("nano_vt100.bin")) + csi = [(e.private, e.params, e.final) + for e in events if isinstance(e, Csi)] + self.assertIn(("", (1, 24), "r"), csi) # DECSTBM + self.assertIn(("", (22, 16), "H"), csi) # CUP 行桁指定 + + def test_bash_toggles_bracketed_paste(self): + events = parse(fixture("shell_vt100.bin")) + csi = [(e.private, e.params, e.final) + for e in events if isinstance(e, Csi)] + self.assertIn(("?", (2004,), "h"), csi) + self.assertIn(("?", (2004,), "l"), csi) + + def test_clear_erases_instead_of_scrolling(self): + events = parse(fixture("clear_vt100.bin")) + csi = [(e.private, e.params, e.final) + for e in events if isinstance(e, Csi)] + self.assertIn(("", (), "H"), csi) + self.assertIn(("", (), "J"), csi) + + def test_no_escape_ever_leaks_into_printed_text(self): + for name in self.LINUX: + for event in parse(fixture(name)): + if isinstance(event, Print): + self.assertNotIn("\x1b", event.text, name) + + def test_every_split_point_gives_the_same_commands(self): + for name in self.LINUX: + raw = fixture(name) + whole = merged(parse(raw)) + for cut in range(1, len(raw)): + p = Parser() + split = merged(p.feed(raw[:cut]) + p.feed(raw[cut:])) + self.assertEqual( + split, whole, + "%s: %d バイト目で切ると命令列が変わる" % (name, cut)) + + if __name__ == "__main__": unittest.main() From 55208e566fee6546d45a6c0a1c8fa26f1413111e Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:12:36 +0900 Subject: [PATCH 04/29] feat(terminal): parse DCS strings, completing the state machine Adds the five DCS states (entry, param, intermediate, passthrough, ignore) from the Williams diagram. The hook head - private markers, params, intermediates, final - is captured when passthrough starts, and the whole thing is delivered as one Dcs command on ST. A CAN/SUB discards head and body together, matching how the parser already treats aborted OSC strings. No capture contains a DCS and the screen will ignore them, but consuming them by the diagram instead of a shortcut means a device that does send one (DECRQSS replies, XTGETTCAP) can never desync the stream or leak its payload into the visible text. With this the parser implements every state in the diagram. The module is still imported by nothing but its tests; the screen model comes next. Co-Authored-By: Claude Opus 5 --- src/core/terminal/parser.py | 90 +++++++++++++++++++++++++++++++++-- tests/test_terminal_parser.py | 19 +++++++- 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/src/core/terminal/parser.py b/src/core/terminal/parser.py index 2e23f4f..a532c54 100644 --- a/src/core/terminal/parser.py +++ b/src/core/terminal/parser.py @@ -20,10 +20,13 @@ Esc = collections.namedtuple("Esc", "intermediate final") Csi = collections.namedtuple("Csi", "private params intermediate final") Osc = collections.namedtuple("Osc", "text") +Dcs = collections.namedtuple("Dcs", "private params intermediate final text") (GROUND, ESCAPE, ESCAPE_INTERMEDIATE, CSI_ENTRY, CSI_PARAM, CSI_INTERMEDIATE, CSI_IGNORE, - OSC_STRING, SOS_PM_APC) = range(9) + OSC_STRING, SOS_PM_APC, + DCS_ENTRY, DCS_PARAM, DCS_INTERMEDIATE, DCS_PASSTHROUGH, + DCS_IGNORE) = range(14) MAX_PARAMS = 256 # 暴走した列でメモリを食わないための上限 MAX_STRING = 4096 @@ -34,6 +37,7 @@ def __init__(self): self.state = GROUND self._clear() self._string = [] + self._hook = None def _clear(self): self._private = "" @@ -46,10 +50,15 @@ def _split_params(self): return tuple(int(p) if p else None for p in self._params.split(";")) def _end_string(self, out): - """ESC / ST で文字列列 (OSC) を閉じ、確定した命令を出す。""" + """ESC / ST で文字列列 (OSC/DCS) を閉じ、確定した命令を出す。""" if self.state == OSC_STRING: out.append(Osc("".join(self._string))) + elif self.state == DCS_PASSTHROUGH: + private, params, intermediate, final = self._hook + out.append(Dcs(private, params, intermediate, final, + "".join(self._string))) self._string = [] + self._hook = None def feed(self, text): """デコード済み文字列を食わせ、確定した命令のリストを返す。""" @@ -79,6 +88,7 @@ def abort_and_print(ch): if code in (0x18, 0x1A): # CAN / SUB は列を捨てる flush() self._string = [] + self._hook = None out.append(Ctrl(ch)) self.state = GROUND continue @@ -105,7 +115,10 @@ def abort_and_print(ch): elif ch == "]": self._string = [] self.state = OSC_STRING - elif ch in "PX^_": # DCS はまだ中身を読まず捨てる + elif ch == "P": + self._clear() + self.state = DCS_ENTRY + elif ch in "X^_": self.state = SOS_PM_APC elif code <= 0x7E: out.append(Esc(self._intermediate, ch)) @@ -209,5 +222,76 @@ def abort_and_print(ch): elif state == SOS_PM_APC: pass # ST/ESC/CAN/SUB まで無視 + elif state == DCS_ENTRY: + if code < 0x20: + pass + elif code <= 0x2F: + self._intermediate += ch + self.state = DCS_INTERMEDIATE + elif ch == ":": + self.state = DCS_IGNORE + elif code <= 0x39 or ch == ";": + self._params += ch + self.state = DCS_PARAM + elif code <= 0x3F: + self._private += ch + self.state = DCS_PARAM + elif code <= 0x7E: + self._hook = (self._private, self._split_params(), + self._intermediate, ch) + self._string = [] + self.state = DCS_PASSTHROUGH + elif code == 0x7F: + pass + else: + abort_and_print(ch) + + elif state == DCS_PARAM: + if code < 0x20: + pass + elif code <= 0x2F: + self._intermediate += ch + self.state = DCS_INTERMEDIATE + elif (0x30 <= code <= 0x39 or ch == ";") and \ + len(self._params) < MAX_PARAMS: + self._params += ch + elif code <= 0x3F: + self.state = DCS_IGNORE + elif code <= 0x7E: + self._hook = (self._private, self._split_params(), + self._intermediate, ch) + self._string = [] + self.state = DCS_PASSTHROUGH + elif code == 0x7F: + pass + else: + abort_and_print(ch) + + elif state == DCS_INTERMEDIATE: + if code < 0x20: + pass + elif code <= 0x2F: + self._intermediate += ch + elif code <= 0x3F: + self.state = DCS_IGNORE + elif code <= 0x7E: + self._hook = (self._private, self._split_params(), + self._intermediate, ch) + self._string = [] + self.state = DCS_PASSTHROUGH + elif code == 0x7F: + pass + else: + abort_and_print(ch) + + elif state == DCS_PASSTHROUGH: + if code == 0x7F: + pass + elif len(self._string) < MAX_STRING: + self._string.append(ch) + + elif state == DCS_IGNORE: + pass # ST/ESC/CAN/SUB まで無視 + flush() return out diff --git a/tests/test_terminal_parser.py b/tests/test_terminal_parser.py index b53c050..febd02e 100644 --- a/tests/test_terminal_parser.py +++ b/tests/test_terminal_parser.py @@ -16,7 +16,7 @@ sys.path.insert(0, "src") from core.terminal.parser import ( # noqa: E402 - Parser, Print, Ctrl, Esc, Csi, Osc, MAX_STRING) + Parser, Print, Ctrl, Esc, Csi, Osc, Dcs, MAX_STRING) FIXTURES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures") @@ -138,6 +138,23 @@ def test_an_endless_string_is_capped(self): self.assertEqual(len(events[0].text), MAX_STRING) +class DcsStringTest(unittest.TestCase): + def test_the_whole_sequence_arrives_on_st(self): + # DECRQSS の形。頭 (パラメータ・最終文字) と中身が揃って届く + self.assertEqual(parse("\x1bP1$qm\x1b\\"), + [Dcs("", (1,), "$", "q", "m"), Esc("", "\\")]) + + def test_the_head_and_body_survive_a_feed_boundary(self): + p = Parser() + self.assertEqual(p.feed("\x1bP+q54"), []) + self.assertEqual(p.feed("4e\x1b\\"), + [Dcs("", (), "+", "q", "544e"), Esc("", "\\")]) + + def test_cancel_discards_head_and_body(self): + self.assertEqual(parse("\x1bP0;1|17/ab\x18X"), + [Ctrl("\x18"), Print("X")]) + + class SosPmApcTest(unittest.TestCase): def test_the_string_is_consumed_silently(self): self.assertEqual(parse("\x1bXsecret\x1b\\A"), From 2256562ad29cd6c85d1b7012d5e72c990b4b5ae0 Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:14:23 +0900 Subject: [PATCH 05/29] feat(terminal): model character attributes (SGR) Value object for what ECMA-48 calls select graphic rendition: colour, bold, underline, reverse. Cells will hold these as-is. Colours stay numbers (0-7 basic, 8-15 bright, 16-255 palette, an (r,g,b) tuple for direct colour) - mapping a number to an actual pixel colour is the renderer's decision, because it depends on the light/dark theme, and core code knows nothing about themes. SGR is the most frequent sequence in the captures (72 times), all of it the basic codes, but the extended 38/48 forms cost little and vim or ls --color will send them the moment someone opens a Linux shell. Unknown codes are skipped silently; a malformed 38/48 drops the rest of the parameter list rather than misreading colour components as attribute codes. Co-Authored-By: Claude Opus 5 --- src/core/terminal/attrs.py | 71 ++++++++++++++++++++++++++++++++ tests/test_terminal_attrs.py | 79 ++++++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 src/core/terminal/attrs.py create mode 100644 tests/test_terminal_attrs.py diff --git a/src/core/terminal/attrs.py b/src/core/terminal/attrs.py new file mode 100644 index 0000000..19d5726 --- /dev/null +++ b/src/core/terminal/attrs.py @@ -0,0 +1,71 @@ +"""文字属性 (ECMA-48 の SGR)。画面のセルがそのまま持つ値オブジェクト。 + +色は番号のまま持つ。0-7 が基本色、8-15 が明色、16-255 が 256 色 +パレット、(r, g, b) のタプルが直接指定。番号をどの実色に写すかは +描画側 (テーマ) の仕事で、ここでは決めない。 +""" +import collections + +Attr = collections.namedtuple("Attr", "fg bg bold underline reverse") +DEFAULT = Attr(None, None, False, False, False) + + +def apply_sgr(attr, params): + """SGR パラメータ列を attr へ適用した結果を返す。 + + 知らない番号は黙って飛ばす (ECMA-48 にはこちらが対応しない + 属性も多い)。省略されたパラメータは 0 (全解除) として扱う。 + """ + if not params: + return DEFAULT + params = list(params) + i = 0 + while i < len(params): + p = params[i] or 0 + if p == 0: + attr = DEFAULT + elif p == 1: + attr = attr._replace(bold=True) + elif p == 4: + attr = attr._replace(underline=True) + elif p == 7: + attr = attr._replace(reverse=True) + elif p == 22: + attr = attr._replace(bold=False) + elif p == 24: + attr = attr._replace(underline=False) + elif p == 27: + attr = attr._replace(reverse=False) + elif 30 <= p <= 37: + attr = attr._replace(fg=p - 30) + elif p == 39: + attr = attr._replace(fg=None) + elif 40 <= p <= 47: + attr = attr._replace(bg=p - 40) + elif p == 49: + attr = attr._replace(bg=None) + elif 90 <= p <= 97: + attr = attr._replace(fg=p - 90 + 8) + elif 100 <= p <= 107: + attr = attr._replace(bg=p - 100 + 8) + elif p in (38, 48): + colour, used = _extended_colour(params[i + 1:]) + if colour is None: + break # 形が崩れていたら残りごと捨てる + if p == 38: + attr = attr._replace(fg=colour) + else: + attr = attr._replace(bg=colour) + i += used + i += 1 + return attr + + +def _extended_colour(rest): + """38/48 に続く色指定を読む。(色, 消費した個数) を返す。""" + if len(rest) >= 2 and rest[0] == 5 and rest[1] is not None: + return min(max(rest[1], 0), 255), 2 + if len(rest) >= 4 and rest[0] == 2: + r, g, b = (min(max(v or 0, 0), 255) for v in rest[1:4]) + return (r, g, b), 4 + return None, 0 diff --git a/tests/test_terminal_attrs.py b/tests/test_terminal_attrs.py new file mode 100644 index 0000000..088bf10 --- /dev/null +++ b/tests/test_terminal_attrs.py @@ -0,0 +1,79 @@ +"""SGR 表示属性の検証。Qt を使わない。""" +import sys +import unittest + +sys.path.insert(0, "src") + +from core.terminal.attrs import Attr, DEFAULT, apply_sgr # noqa: E402 + + +class SgrTest(unittest.TestCase): + def test_no_params_means_full_reset(self): + dressed = Attr(fg=1, bg=2, bold=True, underline=True, reverse=True) + self.assertEqual(apply_sgr(dressed, ()), DEFAULT) + + def test_zero_resets_everything(self): + dressed = Attr(fg=1, bg=2, bold=True, underline=True, reverse=True) + self.assertEqual(apply_sgr(dressed, (0,)), DEFAULT) + + def test_an_omitted_param_counts_as_reset(self): + self.assertEqual(apply_sgr(Attr(1, 2, True, False, False), (None,)), + DEFAULT) + + def test_bold_and_reverse_pile_up(self): + attr = apply_sgr(DEFAULT, (1,)) + attr = apply_sgr(attr, (7,)) + self.assertEqual(attr, DEFAULT._replace(bold=True, reverse=True)) + + def test_each_attribute_has_its_own_off_switch(self): + dressed = Attr(None, None, True, True, True) + self.assertEqual(apply_sgr(dressed, (22,)).bold, False) + self.assertEqual(apply_sgr(dressed, (24,)).underline, False) + self.assertEqual(apply_sgr(dressed, (27,)).reverse, False) + + def test_basic_and_bright_colours(self): + self.assertEqual(apply_sgr(DEFAULT, (31,)).fg, 1) + self.assertEqual(apply_sgr(DEFAULT, (44,)).bg, 4) + self.assertEqual(apply_sgr(DEFAULT, (91,)).fg, 9) + self.assertEqual(apply_sgr(DEFAULT, (104,)).bg, 12) + + def test_default_colour_is_none_not_a_number(self): + attr = apply_sgr(DEFAULT, (31, 44)) + attr = apply_sgr(attr, (39, 49)) + self.assertIsNone(attr.fg) + self.assertIsNone(attr.bg) + + def test_many_params_apply_in_order(self): + attr = apply_sgr(Attr(1, 2, True, False, False), (0, 1, 33, 40)) + self.assertEqual(attr, Attr(3, 0, True, False, False)) + + def test_256_palette_via_semicolons(self): + self.assertEqual(apply_sgr(DEFAULT, (38, 5, 129)).fg, 129) + self.assertEqual(apply_sgr(DEFAULT, (48, 5, 236)).bg, 236) + + def test_truecolor_via_semicolons(self): + self.assertEqual(apply_sgr(DEFAULT, (38, 2, 10, 20, 30)).fg, + (10, 20, 30)) + + def test_extended_colour_consumes_its_own_params_only(self): + attr = apply_sgr(DEFAULT, (38, 5, 129, 1)) + self.assertEqual(attr.fg, 129) + self.assertTrue(attr.bold) + + def test_a_broken_extended_colour_drops_the_rest(self): + # 38 の後が読めない形なら、それ以降まとめて捨てる (適用しない) + attr = apply_sgr(DEFAULT, (38, 9, 1)) + self.assertEqual(attr, DEFAULT) + + def test_out_of_range_palette_numbers_are_clamped(self): + self.assertEqual(apply_sgr(DEFAULT, (38, 5, 999)).fg, 255) + self.assertEqual(apply_sgr(DEFAULT, (38, 2, 300, 300, 300)).fg, + (255, 255, 255)) + + def test_unknown_codes_are_skipped_silently(self): + attr = apply_sgr(DEFAULT, (3, 1)) # italic は対応しない + self.assertEqual(attr, DEFAULT._replace(bold=True)) + + +if __name__ == "__main__": + unittest.main() From c3bc740bb6010ecebf4ce9651b2433c0d0af7479 Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:18:34 +0900 Subject: [PATCH 06/29] feat(terminal): add the screen model core A grid of rows x cols cells, each holding (char, Attr). This is the concept the old widget never had: nano says "row 23 column 1" and now there is somewhere for that to land. This commit covers printing with deferred wrap (the VT100 right-edge quirk - a CR after an exactly 80-column line must not mint a phantom blank line), C0 controls, scrolling with history capture, cursor movement and SGR. Erasing, escape dispatch, margins, the alternate screen and modes follow in the next commits. Lines scrolled off the top of a full-screen region go to history, which is how the session record survives the grid being only 24 rows. The Catalyst capture already renders correctly on the model alone, no Qt: --More-- fully erased, the backspace-corrected typo clean, the ^ error marker in column 10, 12 indented legend lines - all equal to what the old widget shows today. Walking every 7th split point of the 7058 bytes takes ~2 s here versus ~4 min through the widget, which is the testability the rewrite was after. Co-Authored-By: Claude Opus 5 --- src/core/terminal/screen.py | 174 ++++++++++++++++++++++++++++++++++ tests/test_terminal_screen.py | 166 ++++++++++++++++++++++++++++++++ 2 files changed, 340 insertions(+) create mode 100644 src/core/terminal/screen.py create mode 100644 tests/test_terminal_screen.py diff --git a/src/core/terminal/screen.py b/src/core/terminal/screen.py new file mode 100644 index 0000000..160a77c --- /dev/null +++ b/src/core/terminal/screen.py @@ -0,0 +1,174 @@ +"""行×桁の画面。パーサの命令を適用して格子を更新する。UI を知らない。 + +セルは (文字, Attr) のタプル。履歴 (上から押し出された行) は +メイン画面でだけ増える。動きの根拠は ECMA-48 と、標準に無い部分は +XTerm Control Sequences。 + +制限: 全角文字も 1 セルとして扱う (桁計算は ASCII 前提。機器 CLI の +出力は ASCII で、ここが崩れる実害は Linux 側の全角編集のみ)。 +""" +import collections + +from core.terminal import parser +from core.terminal.attrs import DEFAULT, apply_sgr + +BLANK = (" ", DEFAULT) + + +def _param(params, i, default): + if i < len(params) and params[i] is not None: + return params[i] + return default + + +class Screen(object): + def __init__(self, rows=24, cols=80, max_history=5000): + self.rows = rows + self.cols = cols + self.history = collections.deque(maxlen=max_history) + self._new_history = [] + self.title = "" + self.reset() + + def reset(self): + """RIS 相当。履歴とタイトルだけは残す (セッションの記録なので)。""" + self.lines = self._blank_lines() + self.cursor_row = 0 + self.cursor_col = 0 + self.attr = DEFAULT + self.scroll_top = 0 + self.scroll_bottom = self.rows - 1 + self._pending_wrap = False + self.dirty = set(range(self.rows)) + + def _blank_lines(self): + return [self._blank_line() for _ in range(self.rows)] + + def _blank_line(self): + return [BLANK] * self.cols + + # ---- 外から使う口 ---------------------------------------------- + + def apply(self, events): + """パーサの命令列を画面へ適用する。""" + for event in events: + if isinstance(event, parser.Print): + self._print(event.text) + elif isinstance(event, parser.Ctrl): + self._ctrl(event.char) + elif isinstance(event, parser.Csi): + self._csi(event) + # Esc / Osc / Dcs は後段のコミットで + + def text(self): + """画面の見た目を行のリストで返す (検証と描画の共通口)。""" + return ["".join(cell[0] for cell in line).rstrip() + for line in self.lines] + + def take_new_history(self): + """前回から増えた履歴行を返して忘れる。""" + new = self._new_history + self._new_history = [] + return new + + def take_dirty(self): + """描き直しが要る行番号を返して忘れる。""" + dirty = self.dirty + self.dirty = set() + return dirty + + # ---- 印字と C0 ------------------------------------------------- + + def _print(self, text): + for ch in text: + if ch == "\x7f": # DEL は表示しない + continue + if self._pending_wrap: # 右端の 1 文字あとの折り返し + self.cursor_col = 0 + self._linefeed() + line = self.lines[self.cursor_row] + line[self.cursor_col] = (ch, self.attr) + self.dirty.add(self.cursor_row) + if self.cursor_col + 1 < self.cols: + self.cursor_col += 1 + else: + self._pending_wrap = True + + def _ctrl(self, ch): + if ch == "\r": + self.cursor_col = 0 + self._pending_wrap = False + elif ch in "\n\x0b\x0c": + self._linefeed() + elif ch == "\b": + if self.cursor_col: + self.cursor_col -= 1 + self._pending_wrap = False + elif ch == "\t": + self.cursor_col = min(self.cols - 1, + (self.cursor_col // 8 + 1) * 8) + self._pending_wrap = False + # BEL・NUL・SI/SO などは (今は) 何もしない + + def _linefeed(self): + self._pending_wrap = False + if self.cursor_row == self.scroll_bottom: + self._scroll_up(1) + elif self.cursor_row + 1 < self.rows: + self.cursor_row += 1 + + def _scroll_up(self, n): + for _ in range(n): + removed = self.lines.pop(self.scroll_top) + self.lines.insert(self.scroll_bottom, self._blank_line()) + if self.scroll_top == 0 and self.scroll_bottom == self.rows - 1: + self.history.append(removed) + self._new_history.append(removed) + self.dirty.update(range(self.scroll_top, self.scroll_bottom + 1)) + + def _scroll_down(self, n): + for _ in range(n): + self.lines.pop(self.scroll_bottom) + self.lines.insert(self.scroll_top, self._blank_line()) + self.dirty.update(range(self.scroll_top, self.scroll_bottom + 1)) + + # ---- CSI ------------------------------------------------------- + + def _move(self, row, col): + self.cursor_row = max(0, min(self.rows - 1, row)) + self.cursor_col = max(0, min(self.cols - 1, col)) + self._pending_wrap = False + self.dirty.add(self.cursor_row) + + def _csi(self, seq): + if seq.private or seq.intermediate: + return # 私用モードは後段のコミットで + p = seq.params + f = seq.final + n = max(1, _param(p, 0, 1)) + if f == "A": + limit = (self.scroll_top + if self.cursor_row >= self.scroll_top else 0) + self._move(max(limit, self.cursor_row - n), self.cursor_col) + elif f in "Be": + limit = (self.scroll_bottom + if self.cursor_row <= self.scroll_bottom + else self.rows - 1) + self._move(min(limit, self.cursor_row + n), self.cursor_col) + elif f in "Ca": + self._move(self.cursor_row, self.cursor_col + n) + elif f == "D": + self._move(self.cursor_row, self.cursor_col - n) + elif f == "E": + self._move(self.cursor_row + n, 0) + elif f == "F": + self._move(self.cursor_row - n, 0) + elif f in "G`": + self._move(self.cursor_row, _param(p, 0, 1) - 1) + elif f == "d": + self._move(_param(p, 0, 1) - 1, self.cursor_col) + elif f in "Hf": + self._move(_param(p, 0, 1) - 1, _param(p, 1, 1) - 1) + elif f == "m": + self.attr = apply_sgr(self.attr, p) + # 知らない最終文字は黙って捨てる (画面を壊さないことが仕事) diff --git a/tests/test_terminal_screen.py b/tests/test_terminal_screen.py new file mode 100644 index 0000000..bfec529 --- /dev/null +++ b/tests/test_terminal_screen.py @@ -0,0 +1,166 @@ +"""画面モデルの検証。Qt を使わない。 + +前半は遷移図ならぬ ECMA-48 の意味の検証 (印字・折り返し・消去・移動)。 +後半は実機採取データで、旧実装が検証済みの見え方をこのモデルでも +再現できることを固定する。数値はすべて実測。 +""" +import io +import os +import sys +import unittest + +sys.path.insert(0, "src") + +from core.terminal.parser import Parser # noqa: E402 +from core.terminal.screen import Screen # noqa: E402 +from core.terminal.attrs import DEFAULT # noqa: E402 + +FIXTURES = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "fixtures") + + +def fixture(name): + raw = io.open(os.path.join(FIXTURES, name), "rb").read() + return raw.decode("utf-8", "replace") + + +def feed(screen, text): + screen.apply(Parser().feed(text)) + return screen + + +def everything(screen): + """履歴 + 画面。セッションとして人の目に入る全行。""" + scrolled = ["".join(cell[0] for cell in line).rstrip() + for line in screen.history] + return scrolled + screen.text() + + +class PrintingTest(unittest.TestCase): + def test_text_lands_where_the_cursor_is(self): + s = feed(Screen(), "show version") + self.assertEqual(s.text()[0], "show version") + self.assertEqual((s.cursor_row, s.cursor_col), (0, 12)) + + def test_backspace_overwrites_like_a_device_pager(self): + s = feed(Screen(), "abc\b\bX") + self.assertEqual(s.text()[0], "aXc") + + def test_cr_returns_lf_descends(self): + s = feed(Screen(), "one\r\ntwo") + self.assertEqual(s.text()[:2], ["one", "two"]) + + def test_bare_lf_keeps_the_column(self): + s = feed(Screen(), "abc\ndef") + self.assertEqual(s.text()[:2], ["abc", " def"]) + + def test_tab_jumps_to_the_next_stop(self): + s = feed(Screen(), "a\tb") + self.assertEqual(s.text()[0], "a b") + + def test_the_81st_character_wraps(self): + s = feed(Screen(), "x" * 81) + self.assertEqual(s.text()[0], "x" * 80) + self.assertEqual(s.text()[1], "x") + + def test_a_cr_at_the_right_edge_does_not_make_a_phantom_line(self): + # 80 桁ちょうどの行は、右端で折り返しを保留する (VT100 の癖)。 + # 続く CR LF で空行が湧いてはいけない + s = feed(Screen(), "x" * 80 + "\r\nnext") + self.assertEqual(s.text()[:3], ["x" * 80, "next", ""]) + + def test_del_is_invisible(self): + s = feed(Screen(), "a\x7fb") + self.assertEqual(s.text()[0], "ab") + + +class ScrollAndHistoryTest(unittest.TestCase): + def test_lines_pushed_off_the_top_become_history(self): + s = feed(Screen(), "\r\n".join("line%d" % i for i in range(30))) + self.assertEqual(len(s.history), 6) # 30 行 - 画面 24 行 + self.assertEqual("".join(c[0] for c in s.history[0]).rstrip(), + "line0") + self.assertEqual(s.text()[0], "line6") + + def test_new_history_is_handed_over_once(self): + s = feed(Screen(), "\r\n".join("l%d" % i for i in range(26))) + self.assertEqual(len(s.take_new_history()), 2) + self.assertEqual(s.take_new_history(), []) + +class CursorMovementTest(unittest.TestCase): + def test_cup_is_one_based(self): + s = feed(Screen(), "\x1b[3;5HX") + self.assertEqual(s.text()[2], " X") + + def test_cup_without_params_goes_home(self): + s = feed(Screen(), "hello\x1b[HX") + self.assertEqual(s.text()[0], "Xello") + + def test_cup_clamps_to_the_screen(self): + s = feed(Screen(), "\x1b[99;999HX") + self.assertEqual((s.cursor_row, s.cursor_col), (23, 79)) + + def test_relative_moves(self): + s = feed(Screen(), "\x1b[5;5H\x1b[2A\x1b[3C\x1b[1B\x1b[6DX") + # (4,4) -> 上2 (2,4) -> 右3 (2,7) -> 下1 (3,7) -> 左6 (3,1) + self.assertEqual(s.text()[3], " X") + + def test_column_address(self): + s = feed(Screen(), "abcdef\x1b[3G_") + self.assertEqual(s.text()[0], "ab_def") + + +class AttributeTest(unittest.TestCase): + def test_sgr_travels_with_the_characters(self): + s = feed(Screen(), "a\x1b[7mb\x1b[mc") + row = s.lines[0] + self.assertEqual(row[0][1], DEFAULT) + self.assertTrue(row[1][1].reverse) + self.assertEqual(row[2][1], DEFAULT) + + +class UnknownSequenceTest(unittest.TestCase): + def test_an_unknown_final_changes_nothing(self): + s = feed(Screen(), "abc\x1b[999Xdef") + self.assertEqual(s.text()[0], "abcdef") + + +class CiscoOnTheScreenModelTest(unittest.TestCase): + """実機採取 7058 バイト。旧実装で検証済みの見え方を固定する。""" + + @classmethod + def setUpClass(cls): + cls.screen = feed(Screen(), fixture("cisco_ios_vt100.bin")) + cls.full = everything(cls.screen) + + def test_the_pager_prompt_is_erased(self): + leftover = [l for l in self.full if "More" in l] + self.assertEqual(leftover, []) + + def test_a_typo_corrected_with_backspace_renders_clean(self): + self.assertTrue(any("show version" in l for l in self.full)) + self.assertFalse(any("verzz" in l for l in self.full)) + + def test_the_output_keeps_its_columns(self): + marker = [i for i, l in enumerate(self.full) if l.strip() == "^"] + self.assertEqual(len(marker), 1) + at = marker[0] + self.assertEqual(self.full[at].index("^"), 10) + self.assertIn("Invalid input", self.full[at + 1]) + indented = [l for l in self.full + if l.startswith(" ") and l.strip()] + self.assertEqual(len(indented), 12) + + def test_every_split_point_shows_the_same_session(self): + raw = fixture("cisco_ios_vt100.bin") + for cut in range(1, len(raw), 7): + p = Parser() + s = Screen() + s.apply(p.feed(raw[:cut])) + s.apply(p.feed(raw[cut:])) + if everything(s) != self.full: + self.fail("%d バイト目で切ると画面が変わる" % cut) + + +if __name__ == "__main__": + unittest.main() From 62cdcc2d19f9aabc166cbb01d64f8d73b35c5972 Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:19:56 +0900 Subject: [PATCH 07/29] feat(terminal): erase, edit and confine - the destructive half of CSI ED/EL erasing, ECH, line and character insertion/deletion (IL DL ICH DCH), explicit scrolls (SU SD) and DECSTBM margins. Erased cells drop their attributes, edited lines keep their exact width, and margins outside the screen are refused like a real VT does. Two behaviours carry policy, not just spec: erasing the display never touches history (clear keeps the session record - the v1.1.1 decision), and a partial scroll region never feeds history, because what scrolls inside an application's pinned region (nano's editing area between its title and shortcut bars) is repainting, not session output. DECSTBM also homes the cursor, which matters because nano's ESC[1;24r is the very first thing it sends and the marker the full-screen notice keys on today. Co-Authored-By: Claude Opus 5 --- src/core/terminal/screen.py | 89 +++++++++++++++++++++++++++++++++++ tests/test_terminal_screen.py | 79 +++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+) diff --git a/src/core/terminal/screen.py b/src/core/terminal/screen.py index 160a77c..4c76b62 100644 --- a/src/core/terminal/screen.py +++ b/src/core/terminal/screen.py @@ -169,6 +169,95 @@ def _csi(self, seq): self._move(_param(p, 0, 1) - 1, self.cursor_col) elif f in "Hf": self._move(_param(p, 0, 1) - 1, _param(p, 1, 1) - 1) + elif f == "J": + self._erase_display(_param(p, 0, 0)) + elif f == "K": + self._erase_line(_param(p, 0, 0)) + elif f == "L": + self._shift_lines(n, insert=True) + elif f == "M": + self._shift_lines(n, insert=False) + elif f == "@": + self._shift_chars(n, insert=True) + elif f == "P": + self._shift_chars(n, insert=False) + elif f == "X": + self._erase_chars(n) + elif f == "S": + self._scroll_up(n) + elif f == "T": + self._scroll_down(n) + elif f == "r": + self._set_margins(p) elif f == "m": self.attr = apply_sgr(self.attr, p) # 知らない最終文字は黙って捨てる (画面を壊さないことが仕事) + + def _set_margins(self, p): + top = _param(p, 0, 1) - 1 + bottom = _param(p, 1, self.rows) - 1 + if 0 <= top < bottom <= self.rows - 1: + self.scroll_top = top + self.scroll_bottom = bottom + self._move(0, 0) # DECSTBM はカーソルも戻す + + def _erase_display(self, mode): + if mode == 0: + self._erase_line(0) + rng = range(self.cursor_row + 1, self.rows) + elif mode == 1: + self._erase_line(1) + rng = range(0, self.cursor_row) + else: # 2 と 3。履歴は消さない + rng = range(0, self.rows) + for r in rng: + self.lines[r] = self._blank_line() + self.dirty.update(rng) + self._pending_wrap = False + + def _erase_line(self, mode): + line = self.lines[self.cursor_row] + if mode == 0: + rng = range(self.cursor_col, self.cols) + elif mode == 1: + rng = range(0, self.cursor_col + 1) + else: + rng = range(0, self.cols) + for c in rng: + line[c] = BLANK + self.dirty.add(self.cursor_row) + self._pending_wrap = False + + def _shift_lines(self, n, insert): + """IL / DL。スクロール範囲の中でだけ効く。""" + if not self.scroll_top <= self.cursor_row <= self.scroll_bottom: + return + for _ in range(n): + if insert: + self.lines.pop(self.scroll_bottom) + self.lines.insert(self.cursor_row, self._blank_line()) + else: + self.lines.pop(self.cursor_row) + self.lines.insert(self.scroll_bottom, self._blank_line()) + self.dirty.update(range(self.cursor_row, self.scroll_bottom + 1)) + self._pending_wrap = False + + def _shift_chars(self, n, insert): + """ICH / DCH。行の右端は詰まる・押し出される。""" + line = self.lines[self.cursor_row] + for _ in range(n): + if insert: + line.pop() + line.insert(self.cursor_col, BLANK) + else: + line.pop(self.cursor_col) + line.append(BLANK) + self.dirty.add(self.cursor_row) + self._pending_wrap = False + + def _erase_chars(self, n): + line = self.lines[self.cursor_row] + for c in range(self.cursor_col, min(self.cols, self.cursor_col + n)): + line[c] = BLANK + self.dirty.add(self.cursor_row) + self._pending_wrap = False diff --git a/tests/test_terminal_screen.py b/tests/test_terminal_screen.py index bfec529..6935c46 100644 --- a/tests/test_terminal_screen.py +++ b/tests/test_terminal_screen.py @@ -110,6 +110,85 @@ def test_column_address(self): self.assertEqual(s.text()[0], "ab_def") +class ErasingTest(unittest.TestCase): + def test_erase_to_end_of_line(self): + s = feed(Screen(), "abcdef\x1b[4G\x1b[K") + self.assertEqual(s.text()[0], "abc") + + def test_erase_to_start_of_line(self): + s = feed(Screen(), "abcdef\x1b[3G\x1b[1K") + self.assertEqual(s.text()[0], " def") + + def test_erase_whole_line_leaves_the_cursor(self): + s = feed(Screen(), "abcdef\x1b[2KX") + self.assertEqual(s.text()[0], " X") + + def test_erase_below(self): + s = feed(Screen(), "aaa\r\nbbb\r\nccc\x1b[2;2H\x1b[J") + self.assertEqual(s.text()[:3], ["aaa", "b", ""]) + + def test_erase_a_run_of_characters(self): + s = feed(Screen(), "abcdef\x1b[2G\x1b[3X") + self.assertEqual(s.text()[0], "a ef") + + def test_erasing_the_display_keeps_the_history(self): + s = feed(Screen(), "\r\n".join("l%d" % i for i in range(30))) + before = len(s.history) + feed(s, "\x1b[2J") + self.assertEqual(s.text(), [""] * 24) + self.assertEqual(len(s.history), before) + + def test_erased_cells_are_undressed(self): + s = feed(Screen(), "\x1b[7mabc\x1b[2K") + self.assertEqual(s.lines[0][1], (" ", DEFAULT)) + + +class EditingTest(unittest.TestCase): + def test_inserted_lines_push_the_rest_down(self): + s = feed(Screen(), "aaa\r\nbbb\r\nccc\x1b[2;1H\x1b[L") + self.assertEqual(s.text()[:4], ["aaa", "", "bbb", "ccc"]) + + def test_deleted_lines_pull_the_rest_up(self): + s = feed(Screen(), "aaa\r\nbbb\r\nccc\x1b[2;1H\x1b[M") + self.assertEqual(s.text()[:3], ["aaa", "ccc", ""]) + + def test_inserted_chars_push_the_line_right(self): + s = feed(Screen(), "abcdef\x1b[3G\x1b[2@") + self.assertEqual(s.text()[0], "ab cdef") + + def test_deleted_chars_pull_the_line_left(self): + s = feed(Screen(), "abcdef\x1b[3G\x1b[2P") + self.assertEqual(s.text()[0], "abef") + + def test_the_line_stays_exactly_as_wide(self): + s = feed(Screen(), "abc\x1b[1;1H\x1b[3@") + self.assertEqual(len(s.lines[0]), 80) + feed(s, "\x1b[3P") + self.assertEqual(len(s.lines[0]), 80) + + +class ScrollRegionTest(unittest.TestCase): + def test_decstbm_confines_the_scroll(self): + s = Screen() + feed(s, "top\x1b[2;3r") # 2..3 行目だけがスクロールする + feed(s, "\x1b[3;1Haaa\r\nbbb\r\nccc") + self.assertEqual(s.text()[:4], ["top", "bbb", "ccc", ""]) + + def test_a_partial_region_never_feeds_history(self): + s = Screen() + feed(s, "\x1b[2;3r\x1b[3;1H" + "\r\n".join("x%d" % i + for i in range(10))) + self.assertEqual(len(s.history), 0) + + def test_decstbm_homes_the_cursor(self): + s = feed(Screen(), "\x1b[5;5H\x1b[2;10rX") + self.assertEqual(s.text()[0], "X") + + def test_nonsense_margins_are_refused(self): + s = feed(Screen(), "\x1b[7;3r") + self.assertEqual((s.scroll_top, s.scroll_bottom), (0, 23)) + + class AttributeTest(unittest.TestCase): def test_sgr_travels_with_the_characters(self): s = feed(Screen(), "a\x1b[7mb\x1b[mc") From 2d3361f9cc64087649e6a88615b6b32ae5ee2eb6 Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:25:56 +0900 Subject: [PATCH 08/29] feat(terminal): alternate screen, modes, charsets and resize The rest of the screen model. ESC dispatch (save/restore cursor, index, reverse index, full reset), ISO 2022 charset designation with the DEC line-drawing set, the xterm private modes that actually occur in captures (?1049/1047/47 alternate screen, ?7 autowrap, ?25 cursor, ?1 cursor keys, ?2004 bracketed paste - the last two only tracked and exposed for the input side), OSC window titles, DSR/DA answered through a response queue the connection can drain, and set_size for window-change support. Two findings from measuring the real clear capture: it is not ESC[2J but ESC[H ESC[J - a mode 0 erase from home - followed by 216 NUL padding bytes (the vt100 terminfo $<50> delay), so the keep-the- session-record rule now triggers on any erase that covers the whole screen, not just mode 2. And one Python trap fixed on the way: the charset branch tested `intermediate in "()"`, which is a substring check that is true for the empty string, so every plain ESC dispatch was being eaten as a charset designation until the tests caught it. The nano capture now assembles the full 24x80 layout on the model: title bar reverse-video on row 0, shortcut bars on rows 22-23, cursor parked in the editing area. The old widget flattened all of this into one line; that difference is the whole point of v1.2.0. Co-Authored-By: Claude Opus 5 --- src/core/terminal/screen.py | 171 ++++++++++++++++++++++++++++++++-- tests/test_terminal_screen.py | 154 +++++++++++++++++++++++++++++- 2 files changed, 313 insertions(+), 12 deletions(-) diff --git a/src/core/terminal/screen.py b/src/core/terminal/screen.py index 4c76b62..58e91a6 100644 --- a/src/core/terminal/screen.py +++ b/src/core/terminal/screen.py @@ -14,6 +14,11 @@ BLANK = (" ", DEFAULT) +# DEC Special Graphics (ESC ( 0 で指示される罫線用文字集合) +DEC_GRAPHICS = dict(zip( + "`abcdefghijklmnopqrstuvwxyz{|}~", + "◆▒␉␌␍␊°±␤␋┘┐┌└┼⎺⎻─⎼⎽├┤┴┬│≤≥π≠£·")) + def _param(params, i, default): if i < len(params) and params[i] is not None: @@ -28,17 +33,28 @@ def __init__(self, rows=24, cols=80, max_history=5000): self.history = collections.deque(maxlen=max_history) self._new_history = [] self.title = "" + self.responses = [] # 機器へ送り返す応答 (DSR/DA) self.reset() def reset(self): """RIS 相当。履歴とタイトルだけは残す (セッションの記録なので)。""" self.lines = self._blank_lines() + self._other = self._blank_lines() # 裏画面 (代替画面用) + self.alt_active = False self.cursor_row = 0 self.cursor_col = 0 self.attr = DEFAULT self.scroll_top = 0 self.scroll_bottom = self.rows - 1 self._pending_wrap = False + self._saved = (0, 0, DEFAULT) # ESC 7 / ESC 8 + self._saved_main = None # ?1049 用 + self.autowrap = True + self.cursor_visible = True + self.application_cursor_keys = False # ?1 DECCKM (入力側が見る) + self.bracketed_paste = False # ?2004 (入力側が見る) + self._g = {"(": "B", ")": "B"} # G0/G1 の指示文字 + self._charset = "(" # SI/SO でどちらを使うか self.dirty = set(range(self.rows)) def _blank_lines(self): @@ -58,7 +74,11 @@ def apply(self, events): self._ctrl(event.char) elif isinstance(event, parser.Csi): self._csi(event) - # Esc / Osc / Dcs は後段のコミットで + elif isinstance(event, parser.Esc): + self._esc(event) + elif isinstance(event, parser.Osc): + self._osc(event.text) + # Dcs は画面に出るものではないので捨てる def text(self): """画面の見た目を行のリストで返す (検証と描画の共通口)。""" @@ -77,6 +97,45 @@ def take_dirty(self): self.dirty = set() return dirty + def take_responses(self): + """機器へ送り返すべき応答 (DSR/DA) を返して忘れる。""" + responses = self.responses + self.responses = [] + return responses + + def set_size(self, rows, cols): + """端末の大きさを変える。内容は再折返ししない (実端末と同じ)。 + + 行が縮むときは、まず下の空行を削り、足りなければ上の行を + 履歴へ送る (書かれた行を黙って消さないため)。 + """ + if (rows, cols) == (self.rows, self.cols) or rows < 1 or cols < 1: + return + for line in self.lines + self._other: + del line[cols:] + line.extend([BLANK] * (cols - len(line))) + while len(self.lines) > rows: + if (self.cursor_row < len(self.lines) - 1 + and all(c == BLANK for c in self.lines[-1])): + self.lines.pop() + else: + removed = self.lines.pop(0) + if not self.alt_active: + self.history.append(removed) + self._new_history.append(removed) + self.cursor_row = max(0, self.cursor_row - 1) + del self._other[rows:] + while len(self.lines) < rows: + self.lines.append([BLANK] * cols) + while len(self._other) < rows: + self._other.append([BLANK] * cols) + self.rows, self.cols = rows, cols + self.scroll_top, self.scroll_bottom = 0, rows - 1 + self.cursor_row = min(self.cursor_row, rows - 1) + self.cursor_col = min(self.cursor_col, cols - 1) + self._pending_wrap = False + self.dirty = set(range(rows)) + # ---- 印字と C0 ------------------------------------------------- def _print(self, text): @@ -86,13 +145,16 @@ def _print(self, text): if self._pending_wrap: # 右端の 1 文字あとの折り返し self.cursor_col = 0 self._linefeed() + if self._g[self._charset] == "0": + ch = DEC_GRAPHICS.get(ch, ch) line = self.lines[self.cursor_row] line[self.cursor_col] = (ch, self.attr) self.dirty.add(self.cursor_row) if self.cursor_col + 1 < self.cols: self.cursor_col += 1 - else: + elif self.autowrap: self._pending_wrap = True + # autowrap 無効なら右端で上書きを続ける def _ctrl(self, ch): if ch == "\r": @@ -108,7 +170,11 @@ def _ctrl(self, ch): self.cursor_col = min(self.cols - 1, (self.cursor_col // 8 + 1) * 8) self._pending_wrap = False - # BEL・NUL・SI/SO などは (今は) 何もしない + elif ch == "\x0e": # SO: G1 へ + self._charset = ")" + elif ch == "\x0f": # SI: G0 へ + self._charset = "(" + # BEL・NUL などは何もしない def _linefeed(self): self._pending_wrap = False @@ -121,7 +187,8 @@ def _scroll_up(self, n): for _ in range(n): removed = self.lines.pop(self.scroll_top) self.lines.insert(self.scroll_bottom, self._blank_line()) - if self.scroll_top == 0 and self.scroll_bottom == self.rows - 1: + if (not self.alt_active and self.scroll_top == 0 + and self.scroll_bottom == self.rows - 1): self.history.append(removed) self._new_history.append(removed) self.dirty.update(range(self.scroll_top, self.scroll_bottom + 1)) @@ -141,8 +208,10 @@ def _move(self, row, col): self.dirty.add(self.cursor_row) def _csi(self, seq): + if seq.private == "?" and seq.final in "hl": + return self._private_mode(seq) if seq.private or seq.intermediate: - return # 私用モードは後段のコミットで + return # DECSCUSR 等、表示に関わらない p = seq.params f = seq.final n = max(1, _param(p, 0, 1)) @@ -191,8 +260,78 @@ def _csi(self, seq): self._set_margins(p) elif f == "m": self.attr = apply_sgr(self.attr, p) + elif f == "n": + if _param(p, 0, 0) == 6: # DSR: カーソル位置を答える + self.responses.append("\x1b[%d;%dR" % ( + self.cursor_row + 1, self.cursor_col + 1)) + elif _param(p, 0, 0) == 5: + self.responses.append("\x1b[0n") + elif f == "c": # DA: VT100 with AVO と名乗る + self.responses.append("\x1b[?1;2c") # 知らない最終文字は黙って捨てる (画面を壊さないことが仕事) + def _esc(self, seq): + if seq.intermediate in ("(", ")"): # 文字集合の指示 + self._g[seq.intermediate] = seq.final + elif seq.final == "7": + self._saved = (self.cursor_row, self.cursor_col, self.attr) + elif seq.final == "8": + row, col, attr = self._saved + self.attr = attr + self._move(row, col) + elif seq.final == "D": # IND + self._linefeed() + elif seq.final == "M": # RI: 上端では下へスクロール + if self.cursor_row == self.scroll_top: + self._scroll_down(1) + elif self.cursor_row: + self.cursor_row -= 1 + self._pending_wrap = False + elif seq.final == "E": # NEL + self.cursor_col = 0 + self._linefeed() + elif seq.final == "c": # RIS。履歴は reset が残す + self.reset() + # = > \ H などは表示を変えない + + def _osc(self, text): + num, _, rest = text.partition(";") + if num in ("0", "2"): + self.title = rest + + def _private_mode(self, seq): + on = seq.final == "h" + for mode in seq.params: + if mode in (1049, 1047, 47): + self._switch_screen(on, with_cursor=(mode == 1049)) + elif mode == 7: + self.autowrap = on + elif mode == 25: + self.cursor_visible = on + elif mode == 1: + self.application_cursor_keys = on + elif mode == 2004: + self.bracketed_paste = on + # ほかの私用モードは表示に効かないので無視 + + def _switch_screen(self, to_alt, with_cursor): + if to_alt == self.alt_active: + return + if to_alt and with_cursor: + self._saved_main = (self.cursor_row, self.cursor_col, self.attr) + self.lines, self._other = self._other, self.lines + self.alt_active = to_alt + if to_alt: # 代替画面は白紙で始まる + for r in range(self.rows): + self.lines[r] = self._blank_line() + self._move(0, 0) + elif with_cursor and self._saved_main: + row, col, attr = self._saved_main + self.attr = attr + self._move(row, col) + self.dirty.update(range(self.rows)) + self._pending_wrap = False + def _set_margins(self, p): top = _param(p, 0, 1) - 1 bottom = _param(p, 1, self.rows) - 1 @@ -202,14 +341,28 @@ def _set_margins(self, p): self._move(0, 0) # DECSTBM はカーソルも戻す def _erase_display(self, mode): - if mode == 0: + # 画面全体が消えるとき (clear は ESC[H ESC[J、つまり home からの + # mode 0 で来る) は、消す前に見えていた中身を履歴へ送る。 + # clear でセッションの記録を失わない、という v1.1.1 の方針 + wipes_all = (mode >= 2 or + (mode == 0 and (self.cursor_row, self.cursor_col) + == (0, 0))) + if wipes_all and not self.alt_active: + last = -1 + for r in range(self.rows): + if any(c != BLANK for c in self.lines[r]): + last = r + for line in self.lines[:last + 1]: + self.history.append(line) + self._new_history.append(line) + if wipes_all: + rng = range(0, self.rows) + elif mode == 0: self._erase_line(0) rng = range(self.cursor_row + 1, self.rows) - elif mode == 1: + else: self._erase_line(1) rng = range(0, self.cursor_row) - else: # 2 と 3。履歴は消さない - rng = range(0, self.rows) for r in rng: self.lines[r] = self._blank_line() self.dirty.update(rng) diff --git a/tests/test_terminal_screen.py b/tests/test_terminal_screen.py index 6935c46..00d2361 100644 --- a/tests/test_terminal_screen.py +++ b/tests/test_terminal_screen.py @@ -131,12 +131,15 @@ def test_erase_a_run_of_characters(self): s = feed(Screen(), "abcdef\x1b[2G\x1b[3X") self.assertEqual(s.text()[0], "a ef") - def test_erasing_the_display_keeps_the_history(self): + def test_erasing_the_display_loses_no_session_record(self): + # clear で記録を失わない (v1.1.1 の方針)。消される直前に + # 見えていた行は履歴へ移る s = feed(Screen(), "\r\n".join("l%d" % i for i in range(30))) - before = len(s.history) feed(s, "\x1b[2J") self.assertEqual(s.text(), [""] * 24) - self.assertEqual(len(s.history), before) + record = everything(s) + for i in range(30): + self.assertIn("l%d" % i, record) def test_erased_cells_are_undressed(self): s = feed(Screen(), "\x1b[7mabc\x1b[2K") @@ -204,6 +207,151 @@ def test_an_unknown_final_changes_nothing(self): self.assertEqual(s.text()[0], "abcdef") +class EscDispatchTest(unittest.TestCase): + def test_save_and_restore_cursor_with_attributes(self): + s = feed(Screen(), "\x1b[3;3H\x1b[7m\x1b7\x1b[H\x1b[m\x1b8X") + self.assertEqual(s.text()[2], " X") + self.assertTrue(s.lines[2][2][1].reverse) + + def test_reverse_index_at_the_top_scrolls_down(self): + s = feed(Screen(), "top\x1b[H\x1bMnew") + self.assertEqual(s.text()[:2], ["new", "top"]) + + def test_full_reset_clears_the_screen_but_not_the_history(self): + s = feed(Screen(), "\r\n".join("l%d" % i for i in range(30))) + feed(s, "\x1bc") + self.assertEqual(s.text(), [""] * 24) + self.assertEqual(len(s.history), 6) + + def test_line_drawing_characters(self): + # ESC)0 で G1 に罫線集合を指示し、SO で使い、SI で戻る + s = feed(Screen(), "\x1b)0\x0elqk\x0fx") + self.assertEqual(s.text()[0], "┌─┐x") + + +class ModeTest(unittest.TestCase): + def test_autowrap_off_pins_the_cursor_to_the_edge(self): + s = feed(Screen(), "\x1b[?7l\x1b[76Gabcdef") + self.assertTrue(s.text()[0].endswith("abcdf")) + self.assertEqual(len(s.text()[0]), 80) + + def test_input_side_flags_are_exposed(self): + s = Screen() + self.assertFalse(s.application_cursor_keys) + self.assertFalse(s.bracketed_paste) + feed(s, "\x1b[?1h\x1b[?2004h") + self.assertTrue(s.application_cursor_keys) + self.assertTrue(s.bracketed_paste) + feed(s, "\x1b[?1l\x1b[?2004l") + self.assertFalse(s.application_cursor_keys) + self.assertFalse(s.bracketed_paste) + + def test_cursor_visibility_is_tracked(self): + s = feed(Screen(), "\x1b[?25l") + self.assertFalse(s.cursor_visible) + + +class AlternateScreenTest(unittest.TestCase): + def test_leaving_the_alt_screen_restores_the_shell(self): + s = feed(Screen(), "user@lab:~$ vi") + feed(s, "\x1b[?1049h~~vi screen~~\x1b[?1049l") + self.assertEqual(s.text()[0], "user@lab:~$ vi") + self.assertEqual((s.cursor_row, s.cursor_col), (0, 14)) + + def test_the_alt_screen_starts_blank(self): + s = feed(Screen(), "shell stuff\x1b[?1049h") + self.assertEqual(s.text(), [""] * 24) + + def test_alt_screen_scrolling_never_reaches_history(self): + s = feed(Screen(), "\x1b[?1049h") + feed(s, "\r\n".join("frame%d" % i for i in range(40))) + self.assertEqual(len(s.history), 0) + + +class ResponseTest(unittest.TestCase): + def test_cursor_position_report(self): + s = feed(Screen(), "abc\x1b[6n") + self.assertEqual(s.take_responses(), ["\x1b[1;4R"]) + self.assertEqual(s.take_responses(), []) + + def test_device_attributes(self): + s = feed(Screen(), "\x1b[c") + self.assertEqual(s.take_responses(), ["\x1b[?1;2c"]) + + +class TitleTest(unittest.TestCase): + def test_osc_sets_the_title(self): + s = feed(Screen(), "\x1b]0;user@lab: ~\x07") + self.assertEqual(s.title, "user@lab: ~") + + +class ResizeTest(unittest.TestCase): + def test_wider_lines_keep_their_content(self): + s = feed(Screen(), "show version") + s.set_size(24, 132) + self.assertEqual(s.text()[0], "show version") + self.assertEqual(len(s.lines[0]), 132) + + def test_narrower_lines_are_cut_not_rewrapped(self): + s = feed(Screen(), "0123456789") + s.set_size(24, 8) + self.assertEqual(s.text()[0], "01234567") + + def test_shrinking_rows_prefers_dropping_blank_bottom_lines(self): + s = feed(Screen(), "keep me") + s.set_size(10, 80) + self.assertEqual(s.text()[0], "keep me") + self.assertEqual(len(s.history), 0) + + def test_shrinking_rows_saves_written_lines_to_history(self): + s = feed(Screen(), "\r\n".join("l%d" % i for i in range(24))) + s.set_size(10, 80) + self.assertEqual(len(s.history), 14) + self.assertEqual(s.text()[0], "l14") + self.assertEqual((s.cursor_row, s.cursor_col), (9, 3)) + + def test_the_scroll_region_snaps_back_to_full(self): + s = feed(Screen(), "\x1b[5;10r") + s.set_size(30, 80) + self.assertEqual((s.scroll_top, s.scroll_bottom), (0, 29)) + + +class NanoOnTheScreenModelTest(unittest.TestCase): + """nano の実データ (527 バイト)。旧実装では 1 行に潰れていた画面が + 24×80 に正しく組み上がることを固定する。値は実測。""" + + @classmethod + def setUpClass(cls): + cls.screen = feed(Screen(), fixture("nano_vt100.bin")) + + def test_the_title_bar_sits_on_row_0(self): + self.assertIn("GNU nano", self.screen.text()[0]) + self.assertIn("New Buffer", self.screen.text()[0]) + + def test_the_shortcut_bars_sit_at_the_bottom(self): + self.assertTrue(self.screen.text()[22].startswith("^G Help")) + self.assertTrue(self.screen.text()[23].startswith("^X Exit")) + + def test_the_cursor_waits_in_the_editing_area(self): + self.assertEqual((self.screen.cursor_row, self.screen.cursor_col), + (1, 0)) + + def test_the_title_bar_is_reverse_video(self): + # "GNU nano 7.2" の非空白 10 文字 + "New Buffer" の 9 文字 + reversed_cells = [cell for cell in self.screen.lines[0] + if cell[1].reverse and cell[0] != " "] + self.assertEqual(len(reversed_cells), 19) + + +class ClearOnTheScreenModelTest(unittest.TestCase): + """clear の実データ (255 バイト)。ESC[H ESC[J と NUL パディング。""" + + def test_the_screen_empties_but_the_record_survives(self): + s = feed(Screen(), fixture("clear_vt100.bin")) + self.assertEqual([l for l in s.text() if l], ["user@lab:~$"]) + self.assertIn("clear", everything(s)) + + class CiscoOnTheScreenModelTest(unittest.TestCase): """実機採取 7058 バイト。旧実装で検証済みの見え方を固定する。""" From 9441c813071cfe615b8a2f0829fc145ac22d34c7 Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:38:50 +0900 Subject: [PATCH 09/29] feat(terminal): render through the screen model The widget now feeds received text to the parser, applies the commands to the Screen, and mirrors the result into the QTextEdit. The document becomes [settled record] + [live screen]: lines pushed off the top join the record and are never touched again, and the live region is re-rendered per batch - but only the changed span (common prefix/suffix trimmed), because wiping the whole region would destroy a selection every time the device emits a log line. The cursor row keeps its blanks up to the cursor column so a prompt's trailing space still shows and the caret sits where the device put it. DSR and DA queries are answered back through the key_pressed path. nano now draws its real 24x80 layout in the widget - title bar, shortcut bars, cursor in the editing area - where v1.1.1 could only print an apology notice. Reconnection swaps in a fresh parser and screen; the old screen's rendering stays in the record. tests/test_terminal_fullscreen.py pinned that notice and the regex fragment carry-over, both of which this change removes on purpose; its surviving properties (split invariance, no title leaks, records outliving clear and nano, reconnect isolation, selections surviving output) move to tests/test_terminal_widget_render.py. The old escape machinery in the widget is now dead code; the next commits remove it. The widget-level Cisco split walk drops from ~4 min to ~5 s. Co-Authored-By: Claude Opus 5 --- src/ui/terminal_widget.py | 143 ++++++++-- tests/test_terminal_fullscreen.py | 400 --------------------------- tests/test_terminal_widget_render.py | 144 ++++++++++ 3 files changed, 261 insertions(+), 426 deletions(-) delete mode 100644 tests/test_terminal_fullscreen.py create mode 100644 tests/test_terminal_widget_render.py diff --git a/src/ui/terminal_widget.py b/src/ui/terminal_widget.py index cbe9a9a..8ee2ca5 100644 --- a/src/ui/terminal_widget.py +++ b/src/ui/terminal_widget.py @@ -1,9 +1,13 @@ import re from PyQt6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QTabWidget, QMenu -from PyQt6.QtGui import QFont, QColor, QPalette, QKeyEvent, QAction +from PyQt6.QtGui import (QFont, QColor, QPalette, QKeyEvent, QAction, + QTextCursor) from PyQt6.QtCore import Qt, pyqtSignal from typing import Dict, Optional +from core.terminal import parser as vt +from core.terminal.screen import Screen + class InteractiveTerminal(QTextEdit): """キー入力を処理できるインタラクティブなターミナル""" @@ -477,10 +481,9 @@ def create_terminal_tab(self, device_name: str) -> InteractiveTerminal: for i in range(self.tab_widget.count()): if self.tab_widget.tabText(i) == device_name: self.tab_widget.setCurrentIndex(i) - # 案内は 1 接続につき一度。タブは機器名で使い回す - # ため、ここで戻さないと再接続後に出なくなる。 - self._terminals[device_name]._fullscreen_warned = False - self._terminals[device_name]._pending_escape = "" + # 再接続は新しいセッション。前の画面はそのまま記録と + # して文書に残し、端末状態 (パーサ・画面) は作り直す + self._attach_screen(self._terminals[device_name]) return self._terminals[device_name] # 接続機器がなく、ホームタブが残っている場合は、ホームタブを再利用 @@ -517,35 +520,123 @@ def create_terminal_tab(self, device_name: str) -> InteractiveTerminal: # タブとして追加 index = self.tab_widget.addTab(terminal, device_name) self.tab_widget.setCurrentIndex(index) - + + self._attach_screen(terminal) return terminal - + + # 端末の格子の大きさ。pty 要求 (vt100, 80x24) と合わせてある + SCREEN_ROWS = 24 + SCREEN_COLS = 80 + + def _attach_screen(self, terminal: QTextEdit) -> None: + """端末状態 (パーサと画面) をこのタブへ付け直す。 + + 文書のいまの末尾から後ろを「今の画面」の領域とする。それより + 前は確定した記録で、二度と書き換えない。 + """ + terminal._parser = vt.Parser() + terminal._screen = Screen(self.SCREEN_ROWS, self.SCREEN_COLS) + region = QTextCursor(terminal.document()) + region.movePosition(QTextCursor.MoveOperation.End) + terminal._region = region + + def _render_screen(self, terminal: QTextEdit) -> None: + """画面の中身を文書へ写す。 + + 文書 = [確定した記録 (履歴)] + [今の画面]。上から押し出された + 行を記録側へ差し込み、画面領域は毎回まるごと描き直す。 + """ + screen = terminal._screen + region = terminal._region + + new_history = screen.take_new_history() + if new_history: + region.insertText("".join( + "".join(cell[0] for cell in line).rstrip() + "\n" + for line in new_history)) + + rows = screen.text() + # カーソルの行は、カーソルの桁まで空白を残す。プロンプト末尾の + # 空白 ("Router# ") を落とすとキャレットが $ に張り付いて見える + if len(rows[screen.cursor_row]) < screen.cursor_col: + rows[screen.cursor_row] = ( + rows[screen.cursor_row].ljust(screen.cursor_col)) + last = screen.cursor_row + for r, line in enumerate(rows): + if line and r > last: + last = r + new_text = "\n".join(rows[:last + 1]) + start = region.position() + + # 変わった範囲だけ置き換える。全部消して入れ直すと、機器が + # ログを吐くたびに画面内の範囲選択が消えてしまう + probe = QTextCursor(terminal.document()) + probe.setPosition(start) + probe.movePosition(QTextCursor.MoveOperation.End, + QTextCursor.MoveMode.KeepAnchor) + old_text = probe.selectedText().replace("\u2029", "\n") + if old_text != new_text: + prefix = 0 + limit = min(len(old_text), len(new_text)) + while prefix < limit and old_text[prefix] == new_text[prefix]: + prefix += 1 + suffix = 0 + while (suffix < limit - prefix + and old_text[-1 - suffix] == new_text[-1 - suffix]): + suffix += 1 + probe.setPosition(start + prefix) + probe.setPosition(start + len(old_text) - suffix, + QTextCursor.MoveMode.KeepAnchor) + probe.insertText(new_text[prefix:len(new_text) - suffix]) + region.setPosition(start) + screen.take_dirty() # 差分は文字列比較で取るので不使用 + + # キャレット (点滅カーソル) を画面カーソルの位置へ。範囲選択の + # 最中に動かすと選択が消えるので、そのときは触らない + if not terminal.textCursor().hasSelection(): + pos = start + for r in range(screen.cursor_row): + pos += len(rows[r]) + 1 + pos += min(screen.cursor_col, len(rows[screen.cursor_row])) + caret = QTextCursor(terminal.document()) + caret.setPosition(pos) + terminal.setTextCursor(caret) + terminal.ensureCursorVisible() + def append_output(self, device_name: str, text: str) -> None: """ 指定した機器のターミナルにテキストを追加 - + Args: device_name: 機器名 text: 追加するテキスト """ - if device_name in self._terminals: - terminal = self._terminals[device_name] - from PyQt6.QtGui import QTextCursor - - # 制御コードを処理(カーソル位置も変更される可能性がある) - processed_text = self._process_control_codes(terminal, text) - - # テキストがある場合のみ挿入 - # 描画は _process_control_codes 内で完了している(戻り値はログ用) - if processed_text: - # ログ記録中の場合はファイルに書き込み - if device_name in self._log_files: - try: - self._log_files[device_name].write(processed_text) - self._log_files[device_name].flush() # 即座にディスクに書き込む - except Exception as e: - import sys - print(f"[ERROR] ログ書き込みエラー: {str(e)}", file=sys.stderr) + if device_name not in self._terminals: + return + terminal = self._terminals[device_name] + + events = terminal._parser.feed(text) + terminal._screen.apply(events) + self._render_screen(terminal) + + # 機器からの問い合わせ (カーソル位置・装置識別) に答える。 + # key_pressed はキー入力と同じ「機器へ送る文字」の経路 + for response in terminal._screen.take_responses(): + terminal.key_pressed.emit(response) + + if device_name in self._log_files: + logged = "".join( + e.text if isinstance(e, vt.Print) else "\n" + for e in events + if isinstance(e, vt.Print) + or (isinstance(e, vt.Ctrl) and e.char == "\n")) + if logged: + try: + self._log_files[device_name].write(logged) + self._log_files[device_name].flush() + except Exception as e: + import sys + print(f"[ERROR] ログ書き込みエラー: {str(e)}", file=sys.stderr) def enable_reconnect(self, device_name: str, reconnect_callback): """ diff --git a/tests/test_terminal_fullscreen.py b/tests/test_terminal_fullscreen.py deleted file mode 100644 index a70d9bd..0000000 --- a/tests/test_terminal_fullscreen.py +++ /dev/null @@ -1,400 +0,0 @@ -"""全画面アプリと画面消去の扱いを、実機から採った生データで検証する。 - -`tests/fixtures/*.bin` は Ubuntu 24.04 へ SSH でつなぎ、NetBelt と同じ -`invoke_shell(term='vt100', width=80, height=24)` で採った受信バイトそのもの。 -実環境を指す文字列だけ RFC 5737 のアドレスと架空のホスト名へ置き換えてある -(エスケープシーケンスは 1 バイトも変えていない)。 - -合成したシーケンスで検証したために一度取り違えた。端末種別を vt100 と -名乗っている以上、その terminfo に無いものは届かない: - - - nano は ESC[?1049h(代替画面)を **送らない**。届いていたのは - ESC[1;24r(スクロール範囲)と ESC[1;79H(行・桁指定)だった。 - - clear は ESC[2J を **送らない**。ESC[H ESC[J が届く。 - -nano の画面が 1 行に潰れるのは、行・桁を指定して描く仕組みが無いため。 -本対応は次の版。ここでは「なぜ崩れるのか」を伝えることだけを守る。 -""" -import io -import os -import sys -import unittest - -sys.path.insert(0, "src") - -ESC = "\x1b" -FIXTURES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures") - - -def capture(name): - """実機から採った受信バイトを、届いたときと同じ文字列で返す。""" - raw = io.open(os.path.join(FIXTURES, name), "rb").read() - return raw.decode("utf-8", "replace") - - -class TerminalFullScreenTest(unittest.TestCase): - @classmethod - def setUpClass(cls): - os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") - from PyQt6.QtWidgets import QApplication - cls.app = QApplication.instance() or QApplication([]) - - HISTORY = ("lab#show ip route\r\n" - " 192.0.2.0/24 is directly connected, GigabitEthernet0/0\r\n") - - def _widget(self): - from ui.terminal_widget import TerminalWidget - w = TerminalWidget() - w.create_terminal_tab("dev") - return w - - def _screen(self, *payloads, history=False): - w = self._widget() - if history: - w.append_output("dev", self.HISTORY) - for payload in payloads: - w.append_output("dev", payload) - return w._terminals["dev"].toPlainText() - - def _notice(self): - from ui.terminal_widget import TerminalWidget - return TerminalWidget.ALT_SCREEN_NOTICE - - # --- 実機の nano --------------------------------------------------- - - def test_the_real_nano_capture_gets_a_notice(self): - """実機の nano で案内が出ること。 - - 合成した ESC[?1049h では出ていたが、実機では 1 度も出なかった。 - vt100 を名乗っているため nano がそれを送らないのが理由。 - """ - text = self._screen(capture("nano_vt100.bin"), history=True) - - self.assertIn(self._notice(), text, - "実機の nano で案内が出ていない:\n" + text) - - def test_the_notice_comes_before_the_broken_paint(self): - """崩れた画面より前に出ること。後ろだと混ざって読めない。""" - text = self._screen(capture("nano_vt100.bin"), history=True) - lines = text.split("\n") - notice_at = next(i for i, l in enumerate(lines) - if self._notice() in l) - garbled_at = next(i for i, l in enumerate(lines) if "Exit" in l) - - self.assertLess(notice_at, garbled_at, - "案内が崩れた画面の後ろに出ている:\n" + text) - - def test_the_notice_stands_on_its_own_line(self): - """1 行として出ること。崩れた行に紛れ込ませない。""" - text = self._screen(capture("nano_vt100.bin"), history=True) - line = next(l for l in text.split("\n") if self._notice() in l) - - self.assertEqual(line.strip(), self._notice(), - "案内の行に他の出力が混ざっている: %r" % line) - - def test_nothing_of_the_app_is_drawn_before_the_notice(self): - """アプリが 1 文字でも描く前に出ること。 - - nano は目印を 2 つ送る。先に届くのはスクロール範囲の設定 - (ESC[1;24r)で、行・桁指定(ESC[1;79H)はタイトル行を - 描いたあと。後者だけを見ていると、案内が崩れた文字列の - 途中に割り込む。届く順を検証するのはここだけ。 - """ - text = self._screen(capture("nano_vt100.bin"), history=True) - before = text.split(self._notice())[0] - - for marker in ("New Buffer", "Write Out", "Read File", "Exit"): - self.assertNotIn( - marker, before, - "案内より前に nano が描き始めている(%r):\n%s" - % (marker, text)) - - def test_the_history_before_it_survives(self): - """nano を開いても、それまでの記録を消さないこと。""" - text = self._screen(capture("nano_vt100.bin"), history=True) - - self.assertIn("show ip route", text) - self.assertIn("directly connected", text) - - def test_the_notice_is_not_repeated(self): - """開いている間、繰り返し出さないこと。 - - 代替画面を使っていないので抜けた合図も来ない。 - 1 接続につき一度だけにしてある。 - """ - text = self._screen(capture("nano_vt100.bin"), - capture("nano_vt100.bin"), history=True) - - self.assertEqual(text.count(self._notice()), 1) - - def test_reconnecting_lets_it_be_shown_again(self): - """再接続したら、また出せる状態に戻すこと。""" - from ui.terminal_widget import TerminalWidget - w = self._widget() - w.append_output("dev", capture("nano_vt100.bin")) - w.create_terminal_tab("dev") # 同じ機器名で繋ぎ直す - w.append_output("dev", capture("nano_vt100.bin")) - - self.assertEqual( - w._terminals["dev"].toPlainText().count( - TerminalWidget.ALT_SCREEN_NOTICE), 2, - "再接続後に案内が出なくなっている") - - # --- 受信が途中で切れても同じ画面になること ------------------------- - - def test_every_split_point_renders_the_same(self): - """どこで切れても、切れなかったときと同じ画面になること。 - - 本番は recv が返した分だけを渡すので、1 つのエスケープが 2 回の - 呼び出しにまたがる。以前は ESC を捨てて先へ進んでいたため、 - 残りが文字として画面へ漏れていた。採取データを全ての位置で - 2 分割して測ると、527 バイト中 161 箇所で画面が変わっていた。 - """ - for name in ("nano_vt100.bin", "shell_vt100.bin", - "clear_vt100.bin"): - raw = capture(name) - whole = self._screen(raw) - for cut in range(1, len(raw)): - split = self._screen(raw[:cut], raw[cut:]) - if split != whole: - self.fail( - "%s を %d バイト目で分けると画面が変わる\n" - " 切れ目: ...%r | %r...\n" - " 差分: %+d 文字" - % (name, cut, - raw[max(0, cut - 8):cut], raw[cut:cut + 8], - len(split) - len(whole))) - - def test_a_window_title_never_leaks_at_a_split(self): - """ウィンドウタイトルが、どこで切れても画面へ漏れないこと。 - - 終端は BEL の場合と ESC + backslash の場合がある。後者が - ちょうど ESC と backslash の間で割れると、持ち越しの対象から - 外れてタイトル本文が画面に出ていた。採取データに OSC が - 含まれていなかったため、実データのテストでは踏めなかった。 - """ - bel = ESC + "]0;user@lab: ~" + "\x07" - st = ESC + "]0;user@lab: ~" + ESC + "\\" - for label, payload in (("BEL 終端", bel), ("ST 終端", st)): - whole = self._screen("A" + payload + "B") - for cut in range(1, len(payload) + 2): - with self.subTest(terminator=label, cut=cut): - text = "A" + payload + "B" - split = self._screen(text[:cut], text[cut:]) - self.assertEqual( - split, whole, - "%s を %d バイト目で分けるとタイトルが漏れる: %r" - % (label, cut, split)) - - def test_the_notice_survives_a_split_marker(self): - """目印がちょうど切れても、案内が出ること。""" - raw = capture("nano_vt100.bin") - marker = raw.index(ESC + "[1;24r") - for cut in range(marker, marker + 7): - with self.subTest(cut=cut): - text = self._screen(raw[:cut], raw[cut:]) - self.assertIn(self._notice(), text, - "%d バイト目で切れると案内が出ない" % cut) - - def test_a_dangling_escape_is_not_held_for_ever(self): - """完成しない断片を、いつまでも抱え込まないこと。 - - 機器が ESC のあと何も送らなくなることはある。上限を超えたら - 今までどおり読み飛ばして、後続の表示を止めない。 - """ - from ui.terminal_widget import TerminalWidget - w = self._widget() - long_fragment = ESC + "[" + "1;" * TerminalWidget.MAX_PENDING_ESCAPE - w.append_output("dev", long_fragment) - w.append_output("dev", "prompt$ ") - - self.assertIn("prompt$ ", w._terminals["dev"].toPlainText(), - "断片を抱えたまま後続を出していない") - - def test_a_carried_fragment_is_dropped_on_reconnect(self): - """再接続したら、前のセッションの断片を持ち越さないこと。""" - w = self._widget() - w.append_output("dev", "text" + ESC + "[1;2") - w.create_terminal_tab("dev") - self.assertEqual( - getattr(w._terminals["dev"], "_pending_escape", ""), "", - "切断前の断片が残っている") - - # --- 誤爆しないこと ------------------------------------------------- - - def test_ordinary_shell_work_says_nothing(self): - """普通のシェル操作で案内を出さないこと。 - - 実機で ls --color / ip / uname / rev を流したときの受信バイト。 - 行・桁指定もスクロール範囲も 1 度も現れない。 - """ - text = self._screen(capture("shell_vt100.bin"), history=True) - - self.assertNotIn(self._notice(), text, "普通の操作で誤爆している") - - def test_clear_says_nothing(self): - """clear でも案内を出さないこと。""" - text = self._screen(capture("clear_vt100.bin"), history=True) - - self.assertNotIn(self._notice(), text) - - def test_private_modes_say_nothing(self): - """ブラケットペーストやカーソル表示で誤爆しないこと。 - - 機器との普通のやりとりで頻繁に来る。 - """ - for seq in ("[?2004h", "[?25l", "[?25h", "[?1h", "[?7h"): - with self.subTest(seq=seq): - text = self._screen(ESC + seq + "X") - self.assertNotIn(self._notice(), text) - self.assertIn("X", text) - - def test_an_unparameterised_home_says_nothing(self): - """引数なしの ESC[H / ESC[r は画面の初期化。全画面の合図ではない。""" - for seq in ("[H", "[r"): - with self.subTest(seq=seq): - self.assertNotIn(self._notice(), self._screen(ESC + seq + "X")) - - def test_the_alternate_screen_still_counts(self): - """代替画面を使う端末設定なら、そちらでも拾えること。""" - for mode in ("1049", "1047", "47"): - with self.subTest(mode=mode): - self.assertIn(self._notice(), - self._screen(ESC + "[?" + mode + "h")) - - # --- 画面消去で記録を失わない --------------------------------------- - - def test_the_real_clear_keeps_the_session(self): - """実機の clear で、それまでの出力を失わないこと。""" - text = self._screen(capture("clear_vt100.bin"), history=True) - - self.assertIn("show ip route", text) - self.assertIn("directly connected", text) - - def test_erase_whole_screen_does_not_delete_the_session(self): - """ESC[2J が来ても記録ごと消さないこと(備え)。 - - vt100 を名乗っている限り clear からは届かないが、そう送ってくる - 機器があったときに 30 分ぶんの出力を失うのは受け入れられない。 - """ - text = self._screen(ESC + "[2J", "prompt$ ", history=True) - - self.assertIn("show ip route", text, "画面消去で記録が消えた") - - def test_erase_scrollback_does_not_delete_it_either(self): - text = self._screen(ESC + "[3J", "after", history=True) - - self.assertIn("show ip route", text) - - def test_erase_whole_screen_pushes_the_old_output_out_of_sight(self): - """消さない代わりに、上へ送り出すこと。""" - text = self._screen(ESC + "[2J", "prompt$ ", history=True) - tail = text.split("directly connected, GigabitEthernet0/0")[1] - - self.assertGreaterEqual(tail.count("\n"), 10, - "画面から送り出せていない: %r" % text[-80:]) - - # --- 既存の消去は壊さない ------------------------------------------- - - def test_erase_to_end_of_screen_still_erases(self): - """ESC[J(カーソルから末尾まで)は今までどおり消すこと。 - - NX-OS が行編集で使う。 - """ - w = self._widget() - w.append_output("dev", "abcdef") - w.append_output("dev", "\b\b\b" + ESC + "[J") - self.assertEqual(w._terminals["dev"].toPlainText(), "abc") - - def test_erase_to_start_of_line_still_erases(self): - w = self._widget() - w.append_output("dev", "abcdef") - w.append_output("dev", "\b\b\b" + ESC + "[1J") - self.assertEqual(w._terminals["dev"].toPlainText(), "def") - - -class FixtureIntegrityTest(unittest.TestCase): - """採取データが、検証の材料として妥当であることを確かめる。""" - - def test_nano_never_asks_for_the_alternate_screen(self): - """実機の nano が代替画面を使っていないこと。 - - ここが崩れると、この検証全体の前提が変わる。 - """ - text = capture("nano_vt100.bin") - for seq in ("[?1049h", "[?1047h", "[?47h"): - self.assertNotIn(ESC + seq, text) - - def test_nano_does_set_a_scroll_region(self): - """検知の目印が実際に含まれていること。""" - import re - self.assertTrue(re.search(r"\x1b\[\d+;\d+r", capture("nano_vt100.bin"))) - - def test_ordinary_work_has_neither_marker(self): - import re - for name in ("shell_vt100.bin", "clear_vt100.bin"): - with self.subTest(capture=name): - text = capture(name) - self.assertIsNone(re.search(r"\x1b\[\d+;\d+r", text)) - self.assertIsNone(re.search(r"\x1b\[\d+;\d+H", text)) - - def test_the_captures_name_no_real_environment(self): - """公開できる状態であること。 - - 最初は IPv4 しか見ておらず、`ip -br addr` の出力に混じった - IPv6 リンクローカルを見落としていた。リンクローカルは EUI-64 で - 作られるため、機器の MAC アドレスがそのまま復元できてしまう。 - - 判定は「禁止する形を並べる」ではなく「文書用に割り当てられた - 範囲か」で行う。並べる方式は、次に別の形が来たときにまた抜ける - し、実環境の値を禁止リストとして書き残すことになる。 - """ - import re - - forbidden = ( - (re.compile(r"\b[0-9a-f]{2}(?::[0-9a-f]{2}){5}\b", - re.I), "MAC アドレス"), - ) - # RFC 2606 の予約名か、採取時に置き換えた架空名だけ許す - allowed_hosts = re.compile( - r"^[\w.-]+@(?:lab|localhost|[\w.-]+\.(?:example|test|invalid))$", - re.I) - # RFC 5737 / RFC 6890 の文書用・ループバックのみ許す - allowed_v4 = re.compile( - r"^(?:192\.0\.2\.|198\.51\.100\.|203\.0\.113\.|127\.)") - # RFC 3849 の文書用のみ許す - allowed_v6 = re.compile(r"^(?:2001:db8:|::1$)", re.I) - - for name in ("nano_vt100.bin", "shell_vt100.bin", - "clear_vt100.bin"): - with self.subTest(capture=name): - text = capture(name) - for pattern, label in forbidden: - found = pattern.search(text) - self.assertIsNone( - found, - "%s が残っている: %s" - % (label, found.group(0) if found else "")) - for prompt in re.findall(r"[\w.-]+@[\w.-]+", text): - self.assertRegex( - prompt, allowed_hosts, - "実環境のホスト名が残っている: %s" % prompt) - for ip in re.findall( - r"\b\d{1,3}(?:\.\d{1,3}){3}\b", - text): - self.assertRegex( - ip, allowed_v4, - "文書用に割り当てられていないアドレス: %s" % ip) - # コロン区切りの塊を拾ってから振り分ける。時刻表記 - # (20:28:23) は :: を含まずコロンも 2 個なので外れる。 - for token in re.findall(r"[0-9a-f]*(?::[0-9a-f]*)+", - text, re.I): - if "::" not in token and token.count(":") < 7: - continue - self.assertRegex( - token.rstrip(":"), allowed_v6, - "文書用に割り当てられていないアドレス: %s" % token) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_terminal_widget_render.py b/tests/test_terminal_widget_render.py new file mode 100644 index 0000000..6903838 --- /dev/null +++ b/tests/test_terminal_widget_render.py @@ -0,0 +1,144 @@ +"""新しい描画経路 (パーサ + 画面モデル + QTextEdit) のウィジェット検証。 + +v1.1.1 まではカーソル位置指定を解釈できず、nano や vi は 1 行に潰れ、 +「対応していません」の案内を出すのが精一杯だった。v1.2.0 は画面を +持ったので、案内ではなく実際に描く。ここでは旧実装の検証で守って +いた性質のうち、残るべきものを新実装の言葉で固定する。 + +文書の構造は [確定した記録] + [今の画面]。記録は二度と書き換えない。 +""" +import io +import os +import sys +import unittest + +sys.path.insert(0, "src") + +FIXTURES = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "fixtures") + + +def fixture(name): + raw = io.open(os.path.join(FIXTURES, name), "rb").read() + return raw.decode("utf-8", "replace") + + +class WidgetRenderTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PyQt6.QtWidgets import QApplication + cls.app = QApplication.instance() or QApplication([]) + + def widget(self): + from ui.terminal_widget import TerminalWidget + w = TerminalWidget() + w.create_terminal_tab("dev") + return w + + def screen_text(self, w): + return w._terminals["dev"].toPlainText() + + +class NanoRendersForRealTest(WidgetRenderTest): + """実機の nano (527 バイト)。旧実装では 1 行に潰れていた。""" + + def setUp(self): + self.w = self.widget() + self.w.append_output("dev", "user@lab:~$ ") + self.w.append_output("dev", fixture("nano_vt100.bin")) + self.lines = self.screen_text(self.w).split("\n") + + def test_the_editor_layout_is_actually_drawn(self): + self.assertTrue(any("GNU nano 7.2" in l for l in self.lines)) + self.assertTrue(self.lines[-2].startswith("^G Help")) + self.assertTrue(self.lines[-1].startswith("^X Exit")) + + def test_no_apology_is_shown_any_more(self): + self.assertNotIn("対応していません", self.screen_text(self.w)) + + def test_the_record_before_nano_survives(self): + self.assertTrue(any("user@lab:~$" in l for l in self.lines)) + + +class ClearKeepsTheSessionTest(WidgetRenderTest): + """実機の clear (255 バイト)。記録を失わないこと。""" + + def test_what_was_on_screen_moves_into_the_record(self): + w = self.widget() + w.append_output("dev", "user@lab:~$ ls\r\nfile1 file2\r\n") + w.append_output("dev", fixture("clear_vt100.bin")) + text = self.screen_text(w) + self.assertIn("file1 file2", text) + self.assertTrue(text.rstrip().endswith("user@lab:~$")) + + +class SplitInvarianceTest(WidgetRenderTest): + """受信がどこで切れても同じ画面になること。切れ目対応は + パーサが状態として持つので、ウィジェットに継ぎ足し処理は無い。""" + + def render_split(self, raw, cut): + w = self.widget() + w.append_output("dev", raw[:cut]) + w.append_output("dev", raw[cut:]) + return self.screen_text(w) + + def test_every_split_point_of_nano_renders_the_same(self): + raw = fixture("nano_vt100.bin") + w = self.widget() + w.append_output("dev", raw) + whole = self.screen_text(w) + for cut in range(1, len(raw)): + if self.render_split(raw, cut) != whole: + self.fail("%d バイト目で切ると画面が変わる" % cut) + + def test_a_window_title_never_leaks_at_any_split(self): + raw = "before\x1b]0;user@lab: /home/secret\x07after" + for cut in range(1, len(raw)): + text = self.render_split(raw, cut) + self.assertNotIn("secret", text, "%d バイト目" % cut) + self.assertIn("beforeafter", text) + + +class ReconnectTest(WidgetRenderTest): + def test_terminal_state_starts_over_but_the_record_stays(self): + w = self.widget() + w.append_output("dev", "old session\r\n\x1b[?1h") + self.assertTrue(w._terminals["dev"]._screen.application_cursor_keys) + w.create_terminal_tab("dev") # 再接続はタブを使い回す + self.assertFalse(w._terminals["dev"]._screen.application_cursor_keys) + w.append_output("dev", "new session") + text = self.screen_text(w) + self.assertIn("old session", text) + self.assertIn("new session", text) + + +class SelectionSurvivesOutputTest(WidgetRenderTest): + def test_incoming_output_does_not_break_a_selection(self): + # コピーしようと選択している最中に機器がログを吐いても、 + # 選択が消えたり置き換わったりしないこと (旧実装から守る性質) + from PyQt6.QtGui import QTextCursor + w = self.widget() + w.append_output("dev", "pick me\r\n") + terminal = w._terminals["dev"] + cursor = terminal.textCursor() + cursor.setPosition(0) + cursor.movePosition(QTextCursor.MoveOperation.Right, + QTextCursor.MoveMode.KeepAnchor, 7) + terminal.setTextCursor(cursor) + w.append_output("dev", "more output\r\n") + self.assertEqual(terminal.textCursor().selectedText(), "pick me") + self.assertIn("more output", self.screen_text(w)) + + +class DeviceQueryTest(WidgetRenderTest): + def test_a_cursor_position_query_is_answered(self): + w = self.widget() + sent = [] + w._terminals["dev"].key_pressed.connect(sent.append) + w.append_output("dev", "ab\x1b[6n") + self.assertEqual(sent, ["\x1b[1;3R"]) + + +if __name__ == "__main__": + unittest.main() From 736b612c751a67ca67cd71d8cac311ac8f5aa222 Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:39:26 +0900 Subject: [PATCH 10/29] refactor(terminal): drop the regex control-code interpreter Dead since the widget switched to the parser + screen model. This was the 195-line loop that read escape sequences with regexes over the QTextEdit document, carried split fragments in _pending_escape, and edited text around a persistent render cursor - the seam most of the v1.1.x terminal bugs lived in. Its helpers and constants go in the next commit. Co-Authored-By: Claude Opus 5 --- src/ui/terminal_widget.py | 195 -------------------------------------- 1 file changed, 195 deletions(-) diff --git a/src/ui/terminal_widget.py b/src/ui/terminal_widget.py index 8ee2ca5..2288928 100644 --- a/src/ui/terminal_widget.py +++ b/src/ui/terminal_widget.py @@ -757,201 +757,6 @@ def _render_cursor_for(self, terminal: QTextEdit): cursor.clearSelection() return cursor - def _process_control_codes(self, terminal: QTextEdit, text: str) -> str: - """機器から届いた制御コードを解釈して画面へ反映する。 - - 端末(Cisco IOS 等)は行編集を「上書き描画」で行う。 - - \b はカーソルを左へ動かすだけで、文字は消さない - - \b + 空白 + \b が来たときだけ、その位置の文字が消える - - 行の途中に文字を入れると、残り全体を送り直してから \b で戻ってくる - そのため通常文字は、行末なら追記、途中なら既存文字を置換する。 - - Args: - terminal: 描画先のターミナル - text: 受信テキスト - - Returns: - str: ログ記録用に、画面へ出したテキスト - """ - import re - from PyQt6.QtGui import QTextCursor - - text = text.replace('\x00', '') # NUL は捨てる(BEL は OSC 終端に使うため後段で処理) - - # 前回の受信が途中で切れていたら、その続きとして繋ぐ - pending = getattr(terminal, "_pending_escape", "") - if pending: - terminal._pending_escape = "" - text = pending + text - - logged = [] # ログへ残す内容(画面に出した文字と改行) - cursor = self._render_cursor_for(terminal) - - def put(s): - """カーソル位置へ書く。行末なら追記、途中なら上書き(端末と同じ挙動)。""" - for ch in s: - if not cursor.atBlockEnd(): - cursor.deleteChar() # 上書き(挿入すると行が伸びてしまう) - cursor.insertText(ch) - - i = 0 - n = len(text) - while i < n: - ch = text[i] - - # --- エスケープシーケンス --- - if ch == '\x1b': - # CSI: ESC [ <パラメータ 0x30-0x3F> <中間 0x20-0x2F> <終端 0x40-0x7E> - match = re.match(r'\x1b\[([0-9:;<=>?]*)([ -/]*)([@-~])', text[i:]) - if match: - params_str, _intermediate, command = match.groups() - # '?' 等で始まるプライベートパラメータ(DECSET/DECRST。bash の - # ブラケットペースト ESC[?2004h など)は解釈せず読み飛ばす - private = bool(params_str) and params_str[0] in '<=>?' - params = [] if private else (params_str.split(';') if params_str else []) - first = params[0] if params else '' - try: - # パラメータ省略時、移動系は 1、消去系は 0 が既定 - num = int(first) if first else 1 - mode = int(first) if first else 0 - except ValueError: - # SGR の ':' 区切り副パラメータなど。移動・消去系では起きない - num, mode = 1, 0 - if self._is_full_screen_draw(private, params_str, - command): - self._note_full_screen_app(terminal, cursor) - - if private: - pass # 私用パラメータは解釈しない - elif command == 'D': # カーソル左 - for _ in range(num): - cursor.movePosition(QTextCursor.MoveOperation.Left) - elif command == 'C': # カーソル右 - for _ in range(num): - cursor.movePosition(QTextCursor.MoveOperation.Right) - elif command == 'K': # 行内の消去 - if mode == 1: # 行頭からカーソルまで - cursor.movePosition(QTextCursor.MoveOperation.StartOfLine, - QTextCursor.MoveMode.KeepAnchor) - elif mode == 2: # 行全体 - cursor.movePosition(QTextCursor.MoveOperation.StartOfLine) - cursor.movePosition(QTextCursor.MoveOperation.EndOfLine, - QTextCursor.MoveMode.KeepAnchor) - else: # 0: カーソルから行末まで - cursor.movePosition(QTextCursor.MoveOperation.EndOfLine, - QTextCursor.MoveMode.KeepAnchor) - cursor.removeSelectedText() - elif command == 'J': # 画面の消去(NX-OS が行編集で使う) - if mode == 2: # 画面全体 - # 端末では「今見えている画面」を消す指示だが、 - # ここは追記していく文書で、画面という区切りが - # 無い。消すとセッションの記録ごと失われ、 - # Linux で clear を打っただけでそれまでの - # show 出力が全部消える。端末と同じように、 - # 上へ送り出して見えなくするだけにする。 - # なお端末種別を vt100 と名乗っているため、 - # Linux の clear は ESC[2J ではなく ESC[H ESC[J - # を送る。ここへは届かない。備えとして残す。 - cursor.movePosition( - QTextCursor.MoveOperation.End) - cursor.insertText( - '\n' * self._visible_lines(terminal)) - elif mode == 3: # スクロールバックの消去 - pass # 記録は消さない - elif mode == 1: # 先頭からカーソルまで - cursor.movePosition(QTextCursor.MoveOperation.Start, - QTextCursor.MoveMode.KeepAnchor) - cursor.removeSelectedText() - else: # 0: カーソルから末尾まで - cursor.movePosition(QTextCursor.MoveOperation.End, - QTextCursor.MoveMode.KeepAnchor) - cursor.removeSelectedText() - i += len(match.group()) - continue - # OSC: ESC ] ... 終端は BEL または ST(ESC \)。ウィンドウタイトル等 - match = re.match(r'\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)', text[i:]) - if match: - i += len(match.group()) - continue - # 受信の切れ目にかかっただけかもしれない。次と繋げれば - # 完成する形なら、捨てずに持ち越す。捨てると残りが文字と - # して画面へ漏れる。 - # 2バイト系より前に見ること。あちらは終端に 0x30-0x7E を - # 許すので、ESC[ や ESC] で切れていると「2バイトの命令」 - # として食べてしまう(実測で 161 箇所中 45 箇所しか減らず)。 - if (n - i <= self.MAX_PENDING_ESCAPE - and self._INCOMPLETE_ESCAPE.match(text, i)): - terminal._pending_escape = text[i:] - i = n - continue - # 2バイト系: ESC ( B(文字集合指定), ESC = / ESC >(キーパッドモード)等 - match = re.match(r'\x1b[ -/]*[0-~]', text[i:]) - if match: - i += len(match.group()) - continue - # 本当に未知のシーケンス。ESC 1文字だけ捨てて必ず前進する - # (ここで前進しないと無限ループになり、アプリが固まる) - i += 1 - continue - - # --- ベル --- - if ch == '\x07': - i += 1 - continue - - # --- バックスペース --- - # 端末では \b は「カーソルを左へ動かす」だけ。文字を消すときは、機器が - # 空白で上書きしてから \b で戻る列を送ってくるので、特別扱いは不要。 - if ch in ('\b', '\x08'): - bs = 0 - j = i - while j < n and text[j] in ('\b', '\x08'): - bs += 1 - j += 1 - for _ in range(bs): - cursor.movePosition(QTextCursor.MoveOperation.Left) - i = j - continue - - # --- 改行 --- - if ch == '\n': - cursor.movePosition(QTextCursor.MoveOperation.End) - cursor.insertText('\n') - logged.append('\n') - i += 1 - continue - - # --- 復帰 --- - if ch == '\r': - j = i - while j < n and text[j] == '\r': - j += 1 - if j < n and text[j] == '\n': - cursor.movePosition(QTextCursor.MoveOperation.End) - cursor.insertText('\n') - logged.append('\n') - i = j + 1 - continue - cursor.movePosition(QTextCursor.MoveOperation.StartOfLine) - i = j - continue - - # --- 通常文字(連続分をまとめて書く)--- - j = i - while j < n and text[j] not in ('\x1b', '\x07', '\b', '\x08', '\r', '\n'): - j += 1 - chunk = text[i:j] - put(chunk) - logged.append(chunk) - i = j - - # 選択中に視覚カーソルを動かすと選択が外れる。コピーしようと - # している最中に機器がログを送ってくるだけで範囲が消えてしまう。 - if not terminal.textCursor().hasSelection(): - terminal.setTextCursor(cursor) - terminal.ensureCursorVisible() - return ''.join(logged) - def _close_tab(self, index: int) -> None: """ From 4afa010664f3a9812334ac82ba2463f3b022bcae Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:40:14 +0900 Subject: [PATCH 11/29] refactor(terminal): remove the full-screen notice and its lookouts The notice ("full-screen apps are not supported") existed because the widget could not honour cursor addressing; now it can, and nano actually renders. Gone with it: the DECSTBM/CUP heuristics that guessed when a full-screen app was starting, the alt-screen mode list, the incomplete-escape regex and its carry limit, the visible line estimate, and the per-tab render cursor - all machinery of the old interpreter. The device capture test keeps proving the heuristics are not missed: a network device sends no escape sequences at all. Co-Authored-By: Claude Opus 5 --- src/ui/terminal_widget.py | 102 ----------------------------------- tests/test_terminal_cisco.py | 19 +------ 2 files changed, 2 insertions(+), 119 deletions(-) diff --git a/src/ui/terminal_widget.py b/src/ui/terminal_widget.py index 2288928..ad8ba92 100644 --- a/src/ui/terminal_widget.py +++ b/src/ui/terminal_widget.py @@ -1,4 +1,3 @@ -import re from PyQt6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QTabWidget, QMenu from PyQt6.QtGui import (QFont, QColor, QPalette, QKeyEvent, QAction, QTextCursor) @@ -656,107 +655,6 @@ def enable_reconnect(self, device_name: str, reconnect_callback): pass terminal.reconnect_requested.connect(lambda: reconnect_callback(device_name)) - # 全画面アプリが代替画面へ切り替えるときのモード番号 - # (1049 が現行、1047 / 47 は古い実装)。ただし端末種別を vt100 と - # 名乗っているため、nano や vi はこれを送ってこない。実機で採った - # 限り、目印になるのは下の 2 つだった。 - ALT_SCREEN_MODES = ("1049", "1047", "47") - - ALT_SCREEN_NOTICE = ( - "[NetBelt] nano や vi のような全画面アプリの表示には対応していません。" - "画面が崩れますが、機器との接続は切れていません。" - ) - - # 端末が名乗っている画面の高さ。実際の表示から取れないときに使う - DEFAULT_SCREEN_LINES = 24 - - # 途中で切れたエスケープを次の受信まで持ち越す上限。これを超えたら - # 完成する見込みが無いとみなして、今までどおり読み飛ばす。 - # ウィンドウタイトル(OSC)は長い。パスまで含めると 64 では足りない - # (`ESC]0;user@host: /home/user/configs/2026` で 75 バイト)。 - MAX_PENDING_ESCAPE = 128 - - # 「まだ伸びうる」形。終端の文字がまだ来ていないもの。 - # ESC 単体 / ESC[ とパラメータ / ESC] と本文 / ESC と中間文字 - # OSC の本文のあとに ESC を 1 つ許すのは、終端が ESC + backslash の - # 形のときに、ちょうどその間で切れるとタイトルが画面へ漏れるため。 - _INCOMPLETE_ESCAPE = re.compile( - "\x1b(?:\\[[0-9:;<=>?]*[ -/]*|][^\x07\x1b]*\x1b?|[ -/]*)?$") - - def _visible_lines(self, terminal: QTextEdit) -> int: - """画面に見えているおおよその行数を返す。""" - from PyQt6.QtGui import QFontMetrics - try: - spacing = QFontMetrics(terminal.font()).lineSpacing() - height = terminal.viewport().height() - if spacing > 0 and height > 0: - return max(1, min(200, height // spacing)) - except Exception: - pass - return self.DEFAULT_SCREEN_LINES - - def _is_full_screen_draw(self, private: bool, params_str: str, - command: str) -> bool: - """全画面アプリが画面を組み立て始めた合図かを判定する。 - - 代替画面への切り替え(ESC[?1049h など)が本来の合図だが、 - こちらは端末種別を vt100 と名乗っており、その terminfo には - 代替画面が無い。そのため nano も vi も送ってこない。 - - 実機で採った限り、代わりに届くのは次の 2 つ。 - ESC[<上>;<下>r スクロール範囲の設定。描画より前に届く - ESC[<行>;<桁>H 行と桁を指定したカーソル移動 - 同じ条件で採った普通のシェル操作には、どちらも現れなかった。 - """ - if private: - codes = params_str[1:].split(';') if len(params_str) > 1 else [] - return (command == 'h' - and any(c in self.ALT_SCREEN_MODES for c in codes)) - if not params_str: - return False # 引数なしの ESC[H / ESC[r は画面の初期化 - if command == 'r': - return True # スクロール範囲を切るのは全画面アプリだけ - return command in ('H', 'f') and ';' in params_str - - def _note_full_screen_app(self, terminal: QTextEdit, cursor) -> None: - """崩れる理由を、一度だけ画面に出す。 - - このターミナルは行を追記していく作りで、行・桁を指定して - 描く仕組みを持たない。全画面アプリは画面のどこにでも書くので、 - すべてが同じ場所に重なって出る。黙って崩れた画面を見せるより、 - 崩れる理由を伝えるほうがよい。 - - 抜けたことを知る手立てが無い(代替画面を使っていないので - 戻りの合図も来ない)ため、1 接続につき一度だけにする。 - """ - from PyQt6.QtGui import QTextCursor - - if getattr(terminal, "_fullscreen_warned", False): - return - terminal._fullscreen_warned = True - cursor.movePosition(QTextCursor.MoveOperation.End) - cursor.insertText('\n' + self.ALT_SCREEN_NOTICE + '\n') - - def _render_cursor_for(self, terminal: QTextEdit): - """機器出力の書き込み位置を返す(ユーザーの選択とは独立) - - 端末は行編集をカーソル移動と上書きで行い、カーソルを戻す指示と - 上書きする文字が別々のパケットで届く。そのため書き込み位置は - append_output を跨いで保つ必要がある。一方で、この位置に - terminal.textCursor() を使うと、利用者が範囲選択している最中に - 出力が届いたとき insertText が選択範囲を置き換えてしまう。 - 書き込み位置は選択とは別に持つ。 - """ - from PyQt6.QtGui import QTextCursor - - cursor = getattr(terminal, "_render_cursor", None) - if cursor is None or cursor.document() is not terminal.document(): - cursor = QTextCursor(terminal.document()) - cursor.movePosition(QTextCursor.MoveOperation.End) - terminal._render_cursor = cursor - cursor.clearSelection() - return cursor - def _close_tab(self, index: int) -> None: """ diff --git a/tests/test_terminal_cisco.py b/tests/test_terminal_cisco.py index 3d95cad..b370e5a 100644 --- a/tests/test_terminal_cisco.py +++ b/tests/test_terminal_cisco.py @@ -10,10 +10,8 @@ **この 7058 バイトに、エスケープシーケンスは 1 つも含まれていない。** 届くのは `\\r` `\\n` `\\b` だけ。ネットワーク機器の画面はバックスペースの 上書きだけで組み立てられており、それが NetBelt の主用途である以上、 -この経路を壊すことは許容できない。 - -全画面アプリの検知が機器の通常操作で誤爆しないか、という問いにも -ここで答えている(目印は 1 度も現れない)。 +この経路を壊すことは許容できない。v1.2.0 の描画作り直しの成否も +まずここで測る。 """ import io import os @@ -95,19 +93,6 @@ def test_the_output_keeps_its_columns(self): self.assertGreaterEqual(len(indented), 10, "凡例の字下げが失われている") - def test_no_full_screen_notice_for_ordinary_device_work(self): - """機器を普通に操作している間、全画面アプリの案内を出さないこと。 - - レビューで 2 度「ページャや端末リセットで誤爆しないか」と - 指摘された。実機で測ったところ、目印になるシーケンスは - 1 度も届かない。 - """ - from ui.terminal_widget import TerminalWidget - text = self._screen(capture()) - - self.assertNotIn(TerminalWidget.ALT_SCREEN_NOTICE, text, - "機器の通常操作で誤爆している") - def test_every_split_point_renders_the_same(self): """受信がどこで切れても、同じ画面になること。""" raw = capture() From dd5a3316a882e609eab45b0830d64cec36bdc945 Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:44:04 +0900 Subject: [PATCH 12/29] feat(terminal): paint SGR attributes for real Colour, bold, underline and reverse video now reach the glyphs. Numbers map to pixels only here in the renderer (xterm's default 16, the 6x6x6 cube, the grayscale ramp, direct RGB); the core model keeps numbers, as designed. Reverse video swaps the terminal's configured palette pair, so --More-- highlights and nano's title bar band come out right on any colour scheme - and attribute-carrying blanks are no longer trimmed at line ends, because that band IS trailing spaces. Painting is a format-only pass over rows the screen marked dirty: setCharFormat moves no characters, so selections survive it, and default-attribute text gets an empty format on purpose - pinning an explicit colour would freeze old output when the user changes the palette in settings. Under TERM=vt100 devices send no colour today; what this buys now is reverse and bold, and the rest is ready the day the terminal type setting learns xterm-256color. Co-Authored-By: Claude Opus 5 --- src/ui/terminal_widget.py | 112 +++++++++++++++++++++++---- tests/test_terminal_widget_render.py | 67 ++++++++++++++++ 2 files changed, 166 insertions(+), 13 deletions(-) diff --git a/src/ui/terminal_widget.py b/src/ui/terminal_widget.py index ad8ba92..3262078 100644 --- a/src/ui/terminal_widget.py +++ b/src/ui/terminal_widget.py @@ -1,11 +1,19 @@ from PyQt6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QTabWidget, QMenu from PyQt6.QtGui import (QFont, QColor, QPalette, QKeyEvent, QAction, - QTextCursor) + QTextCursor, QTextCharFormat) from PyQt6.QtCore import Qt, pyqtSignal from typing import Dict, Optional from core.terminal import parser as vt -from core.terminal.screen import Screen +from core.terminal.attrs import DEFAULT +from core.terminal.screen import Screen, BLANK + +# SGR の基本 16 色 (xterm の既定値)。0-7 が基本、8-15 が明色 +ANSI_COLOURS = ( + "#000000", "#cd0000", "#00cd00", "#cdcd00", + "#0000ee", "#cd00cd", "#00cdcd", "#e5e5e5", + "#7f7f7f", "#ff0000", "#00ff00", "#ffff00", + "#5c5cff", "#ff00ff", "#00ffff", "#ffffff") class InteractiveTerminal(QTextEdit): @@ -539,27 +547,97 @@ def _attach_screen(self, terminal: QTextEdit) -> None: region.movePosition(QTextCursor.MoveOperation.End) terminal._region = region + @staticmethod + def _visible_cells(line): + """行のセル列から、既定属性の末尾空白だけを落とす。 + + 色や反転の付いた空白 (nano のタイトルバーの帯) は表示に + 意味があるので残す。 + """ + n = len(line) + while n and line[n - 1] == BLANK: + n -= 1 + return line[:n] + + @staticmethod + def _runs(cells): + """同じ属性が続く区間ごとに (文字列, 属性) を返す。""" + runs = [] + for ch, attr in cells: + if runs and runs[-1][1] == attr: + runs[-1][0].append(ch) + else: + runs.append(([ch], attr)) + return [("".join(chars), attr) for chars, attr in runs] + + def _colour(self, value, default): + """セルの色番号を実際の色にする。""" + if value is None: + return default + if isinstance(value, tuple): + return QColor(*value) + if value < 16: + return QColor(ANSI_COLOURS[value]) + if value < 232: # 6x6x6 の色立方体 + value -= 16 + parts = (value // 36, value // 6 % 6, value % 6) + return QColor(*(0 if p == 0 else 55 + p * 40 for p in parts)) + gray = 8 + (value - 232) * 10 # グレースケール + return QColor(gray, gray, gray) + + def _char_format(self, attr) -> QTextCharFormat: + fmt = QTextCharFormat() + if attr == DEFAULT: + return fmt # 空の書式 = パレットに従う + settings = self._terminal_settings + fg = self._colour(attr.fg, QColor(settings["text_color"])) + bg = self._colour(attr.bg, QColor(settings["background_color"])) + if attr.reverse: + fg, bg = bg, fg + fmt.setForeground(fg) + if attr.bg is not None or attr.reverse: + fmt.setBackground(bg) + if attr.bold: + fmt.setFontWeight(QFont.Weight.Bold) + if attr.underline: + fmt.setFontUnderline(True) + return fmt + + def _paint_row(self, terminal: QTextEdit, offset: int, cells) -> None: + """1 行ぶんのセル属性を、文書のその範囲へ塗る。 + + 書式だけを変えるので文字位置は動かず、範囲選択も壊れない。 + """ + painter = QTextCursor(terminal.document()) + for text, attr in self._runs(cells): + painter.setPosition(offset) + painter.setPosition(offset + len(text), + QTextCursor.MoveMode.KeepAnchor) + painter.setCharFormat(self._char_format(attr)) + offset += len(text) + def _render_screen(self, terminal: QTextEdit) -> None: """画面の中身を文書へ写す。 文書 = [確定した記録 (履歴)] + [今の画面]。上から押し出された - 行を記録側へ差し込み、画面領域は毎回まるごと描き直す。 + 行を記録側へ差し込み、画面領域は変わった範囲だけ描き直す。 """ screen = terminal._screen region = terminal._region - new_history = screen.take_new_history() - if new_history: - region.insertText("".join( - "".join(cell[0] for cell in line).rstrip() + "\n" - for line in new_history)) + for line in screen.take_new_history(): + for text, attr in self._runs(self._visible_cells(line)): + region.insertText(text, self._char_format(attr)) + region.insertText("\n", QTextCharFormat()) - rows = screen.text() + cell_rows = [self._visible_cells(line) for line in screen.lines] # カーソルの行は、カーソルの桁まで空白を残す。プロンプト末尾の # 空白 ("Router# ") を落とすとキャレットが $ に張り付いて見える - if len(rows[screen.cursor_row]) < screen.cursor_col: - rows[screen.cursor_row] = ( - rows[screen.cursor_row].ljust(screen.cursor_col)) + pad = screen.cursor_col - len(cell_rows[screen.cursor_row]) + if pad > 0: + cell_rows[screen.cursor_row] = ( + cell_rows[screen.cursor_row] + [BLANK] * pad) + rows = ["".join(cell[0] for cell in cells) for cells in cell_rows] last = screen.cursor_row for r, line in enumerate(rows): if line and r > last: @@ -588,7 +666,15 @@ def _render_screen(self, terminal: QTextEdit) -> None: QTextCursor.MoveMode.KeepAnchor) probe.insertText(new_text[prefix:len(new_text) - suffix]) region.setPosition(start) - screen.take_dirty() # 差分は文字列比較で取るので不使用 + + # 変わった行に色・太字・反転を塗り直す + dirty = screen.take_dirty() + dirty.add(screen.cursor_row) # カーソル桁の空白の伸縮ぶん + offset = start + for r in range(last + 1): + if r in dirty: + self._paint_row(terminal, offset, cell_rows[r]) + offset += len(rows[r]) + 1 # キャレット (点滅カーソル) を画面カーソルの位置へ。範囲選択の # 最中に動かすと選択が消えるので、そのときは触らない diff --git a/tests/test_terminal_widget_render.py b/tests/test_terminal_widget_render.py index 6903838..869d97e 100644 --- a/tests/test_terminal_widget_render.py +++ b/tests/test_terminal_widget_render.py @@ -131,6 +131,73 @@ def test_incoming_output_does_not_break_a_selection(self): self.assertIn("more output", self.screen_text(w)) +class ColourTest(WidgetRenderTest): + """SGR が実際の色になること。番号→実色の対応は xterm の既定値。""" + + def format_at(self, w, needle): + from PyQt6.QtGui import QTextCursor + terminal = w._terminals["dev"] + pos = terminal.toPlainText().index(needle) + cursor = QTextCursor(terminal.document()) + cursor.setPosition(pos + 1) # charFormat は直前の文字の書式 + return cursor.charFormat() + + def test_basic_colours_reach_the_glyphs(self): + w = self.widget() + w.append_output("dev", "\x1b[31mRED\x1b[m plain") + self.assertEqual(self.format_at(w, "RED").foreground().color().name(), + "#cd0000") + + def test_default_text_carries_no_explicit_colour(self): + # 書式を空に戻しておかないと、設定でパレットを変えたとき + # 過去の文字だけ古い色で残ってしまう + from PyQt6.QtGui import QTextFormat + w = self.widget() + w.append_output("dev", "\x1b[31mRED\x1b[m plain") + self.assertFalse(self.format_at(w, "plain").hasProperty( + QTextFormat.Property.ForegroundBrush)) + + def test_reverse_video_swaps_the_palette(self): + w = self.widget() + w.append_output("dev", "\x1b[7m--More--\x1b[m") + fmt = self.format_at(w, "--More--") + settings = w._terminal_settings + self.assertEqual(fmt.foreground().color().name(), + settings["background_color"].lower()) + self.assertEqual(fmt.background().color().name(), + settings["text_color"].lower()) + + def test_bold_is_bold(self): + from PyQt6.QtGui import QFont + w = self.widget() + w.append_output("dev", "\x1b[1mboot system\x1b[m") + self.assertEqual(self.format_at(w, "boot").fontWeight(), + QFont.Weight.Bold) + + def test_the_256_colour_cube(self): + w = self.widget() + w.append_output("dev", "\x1b[38;5;196mX") + self.assertEqual(self.format_at(w, "X").foreground().color().name(), + "#ff0000") + + def test_colour_survives_scrolling_into_the_record(self): + w = self.widget() + w.append_output("dev", "\x1b[32mUP\x1b[m\r\n" + "\r\n" * 30) + self.assertEqual(self.format_at(w, "UP").foreground().color().name(), + "#00cd00") + + def test_nano_title_bar_keeps_its_band(self): + # 反転属性の付いた空白は帯として意味があるので、行末でも + # 削らずに描く + w = self.widget() + w.append_output("dev", fixture("nano_vt100.bin")) + title = [l for l in w._terminals["dev"].toPlainText().split("\n") + if "GNU nano" in l][0] + self.assertEqual(len(title), 80) + fmt = self.format_at(w, "GNU nano") + self.assertEqual(fmt.background().color().name(), "#ffffff") + + class DeviceQueryTest(WidgetRenderTest): def test_a_cursor_position_query_is_answered(self): w = self.widget() From 1e4b1c9e32a300bd266a1fea1d478e23e4ce940f Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:47:24 +0900 Subject: [PATCH 13/29] feat(terminal): follow the window size and tell the device The grid was pinned at 80x24 since the first release, so show output wrapped at 80 columns no matter how wide the window was. Now the widget measures how many rows and columns fit (font metrics x viewport), resizes the screen model, and announces the change: SSHConnection.set_terminal_size sends an RFC 4254 6.7 window-change on a live channel and feeds the 6.2 pty-req when called before connecting - which MainWindow now does, so a session starts at the real window size instead of 80x24. Resize storms while dragging are coalesced through a 200 ms timer, an unchanged size is not re-announced, and a dead channel swallows the notify without taking the session down. Telnet has its own standard for this (RFC 1073 NAWS) but our client does not negotiate options, so non-SSH connections are silently left at their fixed size. Co-Authored-By: Claude Opus 5 --- src/core/ssh_connection.py | 25 ++++++++-- src/ui/main_window.py | 14 ++++++ src/ui/terminal_widget.py | 73 ++++++++++++++++++++++++++-- tests/test_pty_size.py | 45 +++++++++++++++++ tests/test_terminal_widget_render.py | 38 +++++++++++++++ 5 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 tests/test_pty_size.py diff --git a/src/core/ssh_connection.py b/src/core/ssh_connection.py index 31d1242..04c34bc 100644 --- a/src/core/ssh_connection.py +++ b/src/core/ssh_connection.py @@ -54,6 +54,9 @@ def __init__(self, host: str, port: int, username: str, password: str = "", self.client: Optional[paramiko.SSHClient] = None self.channel: Optional[paramiko.Channel] = None self.is_connected = False + # 端末の大きさ。接続前に set_terminal_size で上書きされる + self.term_cols = 80 + self.term_rows = 24 self._read_thread: Optional[threading.Thread] = None self._stop_reading = False @@ -166,8 +169,9 @@ def connect(self) -> bool: # SSH接続を実行 self.client.connect(**connect_kwargs) - # インタラクティブシェルを開始 - self.channel = self.client.invoke_shell(term='vt100', width=80, height=24) + # インタラクティブシェルを開始 (RFC 4254 6.2 pty-req) + self.channel = self.client.invoke_shell( + term='vt100', width=self.term_cols, height=self.term_rows) self.channel.settimeout(0.1) self.is_connected = True @@ -230,7 +234,22 @@ def send_command(self, command: str): self.channel.send(command.encode('utf-8')) except Exception as e: self.error_occurred.emit(f"送信エラー: {str(e)}") - + + def set_terminal_size(self, cols: int, rows: int): + """端末の大きさを機器へ伝える (RFC 4254 6.7 window-change)。 + + 接続前に呼ばれたら、接続時の pty 要求 (6.2) に使う。 + 通知に失敗しても接続はそのまま続ける。 + """ + self.term_cols = cols + self.term_rows = rows + if not self.is_connected or not self.channel: + return + try: + self.channel.resize_pty(width=cols, height=rows) + except Exception: + pass + def _read_output(self): """バックグラウンドで出力を読み取る""" while not self._stop_reading and self.is_connected: diff --git a/src/ui/main_window.py b/src/ui/main_window.py index 97d84c9..85f0bd4 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -317,6 +317,7 @@ def _create_main_widget(self): self.terminal_widget.macro_settings_requested.connect(self._on_macro_settings_from_context) self.terminal_widget.keepalive_start_requested.connect(self._on_keepalive_start_requested) self.terminal_widget.keepalive_stop_requested.connect(self._on_keepalive_stop_requested) + self.terminal_widget.terminal_resized.connect(self._on_terminal_resized) splitter.addWidget(self.terminal_widget) # デフォルトの分割比率を設定(30% : 70%) @@ -598,6 +599,9 @@ def _connect_ssh(self, device_data: dict): # SSH接続を作成 ssh = SSHConnection(host, port, username, password, ssh_key, self) + # pty 要求 (RFC 4254 6.2) に、いまの表示領域の行数・桁数を使う + cols, rows = self.terminal_widget.grid_size_for(device_name) + ssh.set_terminal_size(cols, rows) # シグナル接続 ssh.output_received.connect(lambda text: self.terminal_widget.append_output(device_name, text)) @@ -961,6 +965,16 @@ def _on_connect_button_clicked(self): # 接続処理を実行(ダブルクリックと同じ処理) self._on_connect_requested(device_data) + def _on_terminal_resized(self, device_name: str, cols: int, rows: int): + """端末の行数・桁数の変化を機器へ伝える (RFC 4254 6.7)。 + + SSH だけが対応している。Telnet (RFC 1073) と シリアルは未対応 + なので、伝えられない接続では黙って何もしない。 + """ + conn = self.connections.get(device_name) + if conn is not None and hasattr(conn, "set_terminal_size"): + conn.set_terminal_size(cols, rows) + def _on_tab_closed(self, device_name: str): """ タブが閉じられたときの処理 diff --git a/src/ui/terminal_widget.py b/src/ui/terminal_widget.py index 3262078..179d63f 100644 --- a/src/ui/terminal_widget.py +++ b/src/ui/terminal_widget.py @@ -27,7 +27,13 @@ class InteractiveTerminal(QTextEdit): keepalive_stop_requested = pyqtSignal() # キープアライブ停止要求シグナル # Ctrl+ホイールでのフォントサイズ変更要求(回した向き: +1 / -1) font_size_change_requested = pyqtSignal(int) - + # ウィジェットの大きさが変わった(行数・桁数の再計算が要る) + resized = pyqtSignal() + + def resizeEvent(self, event): + super().resizeEvent(event) + self.resized.emit() + def __init__(self, parent=None): super().__init__(parent) self.setReadOnly(False) @@ -326,7 +332,9 @@ class TerminalWidget(QWidget): keepalive_start_requested = pyqtSignal(str) # キープアライブ停止要求シグナル(機器名) keepalive_stop_requested = pyqtSignal(str) - + # 端末の行数・桁数が変わった(機器名, 桁, 行)。機器への通知に使う + terminal_resized = pyqtSignal(str, int, int) + def __init__(self): super().__init__() self._terminals: Dict[str, InteractiveTerminal] = {} # 機器名 -> ターミナル @@ -334,6 +342,13 @@ def __init__(self): self._log_dialogs: Dict[str, object] = {} # 機器名 -> ログ記録ダイアログ # ターミナルの外観設定。_create_terminal が参照するので _create_ui より先に持つ self._terminal_settings = dict(self.DEFAULT_TERMINAL_SETTINGS) + # ウィンドウをドラッグ中のリサイズ連打を 1 回にまとめる + from PyQt6.QtCore import QTimer + self._pending_resizes = set() + self._resize_timer = QTimer(self) + self._resize_timer.setSingleShot(True) + self._resize_timer.setInterval(200) + self._resize_timer.timeout.connect(self._apply_pending_resizes) self._create_ui() def _create_ui(self): @@ -457,6 +472,10 @@ def apply_terminal_settings(self, settings: dict): palette.setColor(QPalette.ColorRole.Text, text_color) terminal.setPalette(palette) + # 文字の大きさが変われば収まる行数・桁数も変わる + for device_name in self._terminals: + self._schedule_grid_update(device_name) + def current_terminal_settings(self) -> dict: """ いま実際に適用されている外観設定を返す @@ -523,6 +542,10 @@ def create_terminal_tab(self, device_name: str) -> InteractiveTerminal: terminal.keepalive_stop_requested.connect( lambda: self.keepalive_stop_requested.emit(device_name) ) + + # ウィンドウの大きさに画面の格子を追従させる + terminal.resized.connect( + lambda: self._schedule_grid_update(device_name)) # タブとして追加 index = self.tab_widget.addTab(terminal, device_name) @@ -541,12 +564,56 @@ def _attach_screen(self, terminal: QTextEdit) -> None: 文書のいまの末尾から後ろを「今の画面」の領域とする。それより 前は確定した記録で、二度と書き換えない。 """ + rows, cols = self._grid_size(terminal) terminal._parser = vt.Parser() - terminal._screen = Screen(self.SCREEN_ROWS, self.SCREEN_COLS) + terminal._screen = Screen(rows, cols) region = QTextCursor(terminal.document()) region.movePosition(QTextCursor.MoveOperation.End) terminal._region = region + def _grid_size(self, terminal: QTextEdit): + """いまの表示領域に収まる (行数, 桁数)。測れないときは 24x80。""" + from PyQt6.QtGui import QFontMetrics + metrics = QFontMetrics(terminal.font()) + char_w = metrics.horizontalAdvance("0") + line_h = metrics.lineSpacing() + width = terminal.viewport().width() + height = terminal.viewport().height() + if char_w <= 0 or line_h <= 0 or width < 2 * char_w or height < line_h: + return self.SCREEN_ROWS, self.SCREEN_COLS + return (max(5, min(200, height // line_h)), + max(20, min(500, width // char_w))) + + def grid_size_for(self, device_name: str): + """接続時の pty 要求 (RFC 4254 6.2) に使う (桁, 行)。""" + terminal = self._terminals.get(device_name) + if terminal is None: + return self.SCREEN_COLS, self.SCREEN_ROWS + rows, cols = self._grid_size(terminal) + return cols, rows + + def _schedule_grid_update(self, device_name: str) -> None: + self._pending_resizes.add(device_name) + self._resize_timer.start() + + def _apply_pending_resizes(self) -> None: + pending, self._pending_resizes = self._pending_resizes, set() + for device_name in sorted(pending): + self._apply_grid_size(device_name) + + def _apply_grid_size(self, device_name: str) -> None: + """画面の格子を表示領域に合わせ、変わったら機器へ知らせる。""" + terminal = self._terminals.get(device_name) + if terminal is None: + return + screen = terminal._screen + rows, cols = self._grid_size(terminal) + if (rows, cols) == (screen.rows, screen.cols): + return + screen.set_size(rows, cols) + self._render_screen(terminal) + self.terminal_resized.emit(device_name, cols, rows) + @staticmethod def _visible_cells(line): """行のセル列から、既定属性の末尾空白だけを落とす。 diff --git a/tests/test_pty_size.py b/tests/test_pty_size.py new file mode 100644 index 0000000..ff536fc --- /dev/null +++ b/tests/test_pty_size.py @@ -0,0 +1,45 @@ +"""端末サイズの機器への伝達 (RFC 4254)。 + +pty-req (6.2) は接続時に、window-change (6.7) は接続中に使う。 +実機は使わず、channel をモックにして呼び出しだけを検証する。 +""" +import sys +import unittest +from unittest import mock + +sys.path.insert(0, "src") + +from core.ssh_connection import SSHConnection # noqa: E402 + + +class PtySizeTest(unittest.TestCase): + def conn(self): + return SSHConnection("192.0.2.1", 22, "user", "pw") + + def test_the_size_set_before_connecting_feeds_the_pty_request(self): + conn = self.conn() + conn.set_terminal_size(120, 40) + self.assertEqual((conn.term_cols, conn.term_rows), (120, 40)) + + def test_a_live_connection_hears_window_change_at_once(self): + conn = self.conn() + conn.is_connected = True + conn.channel = mock.Mock() + conn.set_terminal_size(132, 43) + conn.channel.resize_pty.assert_called_once_with(width=132, height=43) + + def test_a_dead_channel_does_not_take_the_app_down(self): + conn = self.conn() + conn.is_connected = True + conn.channel = mock.Mock() + conn.channel.resize_pty.side_effect = OSError("Socket is closed") + conn.set_terminal_size(132, 43) # 例外が漏れないこと + self.assertEqual((conn.term_cols, conn.term_rows), (132, 43)) + + def test_the_default_matches_the_advertised_vt100(self): + conn = self.conn() + self.assertEqual((conn.term_cols, conn.term_rows), (80, 24)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_terminal_widget_render.py b/tests/test_terminal_widget_render.py index 869d97e..797c2c5 100644 --- a/tests/test_terminal_widget_render.py +++ b/tests/test_terminal_widget_render.py @@ -198,6 +198,44 @@ def test_nano_title_bar_keeps_its_band(self): self.assertEqual(fmt.background().color().name(), "#ffffff") +class GridResizeTest(WidgetRenderTest): + """ウィンドウの大きさに格子が追従し、機器へ知らせること。""" + + def test_a_new_size_reaches_screen_and_signal(self): + w = self.widget() + recorded = [] + w.terminal_resized.connect( + lambda d, c, r: recorded.append((d, c, r))) + w._grid_size = lambda terminal: (30, 100) + w._apply_grid_size("dev") + screen = w._terminals["dev"]._screen + self.assertEqual((screen.rows, screen.cols), (30, 100)) + self.assertEqual(recorded, [("dev", 100, 30)]) + + def test_the_same_size_is_not_re_announced(self): + w = self.widget() + recorded = [] + w.terminal_resized.connect( + lambda d, c, r: recorded.append((d, c, r))) + w._grid_size = lambda terminal: (30, 100) + w._apply_grid_size("dev") + w._apply_grid_size("dev") + self.assertEqual(len(recorded), 1) + + def test_what_was_written_survives_the_resize(self): + w = self.widget() + w.append_output("dev", "show run\r\nhostname lab-rtr\r\n") + w._grid_size = lambda terminal: (10, 40) + w._apply_grid_size("dev") + self.assertIn("hostname lab-rtr", self.screen_text(w)) + + def test_grid_size_stays_within_sane_bounds(self): + w = self.widget() + rows, cols = w._grid_size(w._terminals["dev"]) + self.assertTrue(5 <= rows <= 200, rows) + self.assertTrue(20 <= cols <= 500, cols) + + class DeviceQueryTest(WidgetRenderTest): def test_a_cursor_position_query_is_answered(self): w = self.widget() From 0d053e6b1b8158d99c4d5f1775e9ab702577e934 Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:48:23 +0900 Subject: [PATCH 14/29] feat(terminal): honour the input modes applications set Arrow keys now follow DECCKM: while an application holds ESC[?1h - nano does - they go out as SS3 (ESC O A) instead of CSI, which is the form such applications actually decode. And pasted or dropped text is wrapped in xterm bracketed-paste markers, but only while the far end has ESC[?2004h raised; bash then treats the paste as one edit instead of executing each line on sight. Network devices raise neither mode, so nothing changes on the primary path - both switches live in the screen model the device itself controls. Co-Authored-By: Claude Opus 5 --- src/ui/terminal_widget.py | 28 ++++++++++++++++++++++---- tests/test_terminal_widget_render.py | 30 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/ui/terminal_widget.py b/src/ui/terminal_widget.py index 179d63f..a46cabd 100644 --- a/src/ui/terminal_widget.py +++ b/src/ui/terminal_widget.py @@ -94,12 +94,21 @@ def send_text(self, text: str) -> bool: """ if not text or not self.can_send_input(): return False + # アプリが ESC[?2004h を送ってきていたら、貼り付けを目印で + # 包む (xterm のブラケットペースト)。bash はこれで貼り付けを + # 即実行せず 1 かたまりの編集として扱える + screen = getattr(self, "_screen", None) + bracketed = screen is not None and screen.bracketed_paste + if bracketed: + self.key_pressed.emit("\x1b[200~") # 1文字ずつ送る。改行は端末と同じく CR で送る for char in text: if char == '\n' or char == '\r': self.key_pressed.emit('\r') else: self.key_pressed.emit(char) + if bracketed: + self.key_pressed.emit("\x1b[201~") return True def custom_paste(self): @@ -260,6 +269,17 @@ def wheelEvent(self, event): return super().wheelEvent(event) + def _cursor_key(self, letter: str) -> str: + """カーソルキーの送り方。 + + アプリが ESC[?1h (DECCKM) を立てている間は SS3 形式 + (ESC O A) で送る。nano は矢印キーをこの形で待っている。 + """ + screen = getattr(self, "_screen", None) + if screen is not None and screen.application_cursor_keys: + return "\x1bO" + letter + return "\x1b[" + letter + def keyPressEvent(self, event: QKeyEvent): """キーイベントを処理""" if not self._input_enabled: @@ -287,13 +307,13 @@ def keyPressEvent(self, event: QKeyEvent): elif key == Qt.Key.Key_Escape: self.key_pressed.emit('\x1b') elif key == Qt.Key.Key_Up: - self.key_pressed.emit('\x1b[A') + self.key_pressed.emit(self._cursor_key('A')) elif key == Qt.Key.Key_Down: - self.key_pressed.emit('\x1b[B') + self.key_pressed.emit(self._cursor_key('B')) elif key == Qt.Key.Key_Right: - self.key_pressed.emit('\x1b[C') + self.key_pressed.emit(self._cursor_key('C')) elif key == Qt.Key.Key_Left: - self.key_pressed.emit('\x1b[D') + self.key_pressed.emit(self._cursor_key('D')) else: # 通常の文字入力 text = event.text() diff --git a/tests/test_terminal_widget_render.py b/tests/test_terminal_widget_render.py index 797c2c5..1e653db 100644 --- a/tests/test_terminal_widget_render.py +++ b/tests/test_terminal_widget_render.py @@ -236,6 +236,36 @@ def test_grid_size_stays_within_sane_bounds(self): self.assertTrue(20 <= cols <= 500, cols) +class InputModeTest(WidgetRenderTest): + """機器が立てた入力モードに、送る側が合わせること。""" + + def sendable(self, w): + terminal = w._terminals["dev"] + terminal.set_input_enabled(True) + return terminal + + def test_arrow_keys_follow_decckm(self): + w = self.widget() + terminal = self.sendable(w) + self.assertEqual(terminal._cursor_key("A"), "\x1b[A") + w.append_output("dev", "\x1b[?1h") # nano が立てる + self.assertEqual(terminal._cursor_key("A"), "\x1bOA") + w.append_output("dev", "\x1b[?1l") + self.assertEqual(terminal._cursor_key("A"), "\x1b[A") + + def test_a_paste_is_bracketed_only_when_asked(self): + w = self.widget() + terminal = self.sendable(w) + sent = [] + terminal.key_pressed.connect(sent.append) + terminal.send_text("conf t\n") + self.assertEqual("".join(sent), "conf t\r") # 機器相手は素のまま + del sent[:] + w.append_output("dev", "\x1b[?2004h") # bash が立てる + terminal.send_text("echo hi\n") + self.assertEqual("".join(sent), "\x1b[200~echo hi\r\x1b[201~") + + class DeviceQueryTest(WidgetRenderTest): def test_a_cursor_position_query_is_answered(self): w = self.widget() From 8b9861a07648304c09d753eaf7dadc40e6fed440 Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:53:15 +0900 Subject: [PATCH 15/29] test(terminal): pin the ECMA-48 meaning of EL 1 The old widget implemented erase-to-start-of-line by deleting the text and pulling the rest left, and this test pinned that. ECMA-48 says EL blanks the span - including the cursor position - and shifts nothing; shifting is exactly what breaks column-aligned output. The screen model does it by the book (test_terminal_screen already pins " def" for the same operation), so the widget-level expectation moves to the standard behaviour. Co-Authored-By: Claude Opus 5 --- tests/test_terminal_cursor.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_terminal_cursor.py b/tests/test_terminal_cursor.py index 8930dd1..a0632eb 100644 --- a/tests/test_terminal_cursor.py +++ b/tests/test_terminal_cursor.py @@ -114,11 +114,13 @@ def test_esc_j_clears_to_end(self): self.assertEqual(self._text(w), "abc") def test_esc_k_variants(self): - # ESC[1K は行頭からカーソルまで、ESC[2K は行全体 + # ESC[1K は行頭からカーソルまで (カーソル位置を含む) を空白に + # する。ECMA-48 の EL は消した分を詰めない。旧実装は削除して + # 左へ詰めており ("def")、桁がずれる方が誤りだった w = self._terminal() w.append_output("dev", "abcdef") w.append_output("dev", (ESC + "[D") * 3 + ESC + "[1K") - self.assertEqual(self._text(w), "def") + self.assertEqual(self._text(w), " ef") if __name__ == "__main__": unittest.main() From a858645b9949e3641273a820394da49344105196 Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:40:36 +0900 Subject: [PATCH 16/29] fix(terminal): stop the screen model losing text and hanging Five fixes from the review round and from testing the build on real gear. Shrinking the window destroyed text: set_size truncated every line at the new width, so narrowing the window while reading a long line - an authorized_keys entry, a wide show output - permanently ate its tail, and widening again did not bring it back. It now reflows: overflow wraps onto following lines, surplus rows go to history, and blank bottom rows are dropped before any written line is. The buffer parked behind an alternate screen is reflowed too, so opening vi and then resizing no longer silently drops the shell's bottom rows. Repeat counts are now clamped to the screen. CSI parameters may carry 256 digits, so ESC[999999999S spun _scroll_up for tens of minutes on the GUI thread while _new_history grew past a million rows, with no way out but killing the app. Line commands clamp to rows, character commands to columns; cursor moves are left alone because _move already clamps them. ED 3 erases saved lines, not the display - it was clearing the visible screen and, backwards, growing the history instead. NetBelt does not discard the session record, so it now does nothing. And clearing DECAWM now also drops a pending wrap, which otherwise let exactly one more character spill onto the next line. Co-Authored-By: Claude Opus 5 --- src/core/terminal/parser.py | 25 ++++--- src/core/terminal/screen.py | 119 ++++++++++++++++++++++++++-------- tests/test_terminal_screen.py | 73 ++++++++++++++++++++- 3 files changed, 181 insertions(+), 36 deletions(-) diff --git a/src/core/terminal/parser.py b/src/core/terminal/parser.py index a532c54..aa41ab3 100644 --- a/src/core/terminal/parser.py +++ b/src/core/terminal/parser.py @@ -30,6 +30,7 @@ MAX_PARAMS = 256 # 暴走した列でメモリを食わないための上限 MAX_STRING = 4096 +MAX_INTERMEDIATE = 32 class Parser(object): @@ -107,7 +108,8 @@ def abort_and_print(ch): if code < 0x20: out.append(Ctrl(ch)) elif code <= 0x2F: - self._intermediate += ch + if len(self._intermediate) < MAX_INTERMEDIATE: + self._intermediate += ch self.state = ESCAPE_INTERMEDIATE elif ch == "[": self._clear() @@ -132,7 +134,8 @@ def abort_and_print(ch): if code < 0x20: out.append(Ctrl(ch)) elif code <= 0x2F: - self._intermediate += ch + if len(self._intermediate) < MAX_INTERMEDIATE: + self._intermediate += ch elif code <= 0x7E: out.append(Esc(self._intermediate, ch)) self.state = GROUND @@ -145,7 +148,8 @@ def abort_and_print(ch): if code < 0x20: out.append(Ctrl(ch)) elif code <= 0x2F: - self._intermediate += ch + if len(self._intermediate) < MAX_INTERMEDIATE: + self._intermediate += ch self.state = CSI_INTERMEDIATE elif ch == ":": self.state = CSI_IGNORE @@ -168,7 +172,8 @@ def abort_and_print(ch): if code < 0x20: out.append(Ctrl(ch)) elif code <= 0x2F: - self._intermediate += ch + if len(self._intermediate) < MAX_INTERMEDIATE: + self._intermediate += ch self.state = CSI_INTERMEDIATE elif (0x30 <= code <= 0x39 or ch == ";") and \ len(self._params) < MAX_PARAMS: @@ -188,7 +193,8 @@ def abort_and_print(ch): if code < 0x20: out.append(Ctrl(ch)) elif code <= 0x2F: - self._intermediate += ch + if len(self._intermediate) < MAX_INTERMEDIATE: + self._intermediate += ch elif code <= 0x3F: self.state = CSI_IGNORE elif code <= 0x7E: @@ -226,7 +232,8 @@ def abort_and_print(ch): if code < 0x20: pass elif code <= 0x2F: - self._intermediate += ch + if len(self._intermediate) < MAX_INTERMEDIATE: + self._intermediate += ch self.state = DCS_INTERMEDIATE elif ch == ":": self.state = DCS_IGNORE @@ -250,7 +257,8 @@ def abort_and_print(ch): if code < 0x20: pass elif code <= 0x2F: - self._intermediate += ch + if len(self._intermediate) < MAX_INTERMEDIATE: + self._intermediate += ch self.state = DCS_INTERMEDIATE elif (0x30 <= code <= 0x39 or ch == ";") and \ len(self._params) < MAX_PARAMS: @@ -271,7 +279,8 @@ def abort_and_print(ch): if code < 0x20: pass elif code <= 0x2F: - self._intermediate += ch + if len(self._intermediate) < MAX_INTERMEDIATE: + self._intermediate += ch elif code <= 0x3F: self.state = DCS_IGNORE elif code <= 0x7E: diff --git a/src/core/terminal/screen.py b/src/core/terminal/screen.py index 58e91a6..2300f36 100644 --- a/src/core/terminal/screen.py +++ b/src/core/terminal/screen.py @@ -104,31 +104,57 @@ def take_responses(self): return responses def set_size(self, rows, cols): - """端末の大きさを変える。内容は再折返ししない (実端末と同じ)。 + """端末の大きさを変える。書かれた内容は一文字も捨てない。 - 行が縮むときは、まず下の空行を削り、足りなければ上の行を - 履歴へ送る (書かれた行を黙って消さないため)。 + 桁が狭くなったら、はみ出す分は切らずに次の行へ送る (組み直し)。 + 切り捨てると、窓を縮めただけで出力の末尾が永久に失われる。 + 行が足りなくなった分は上から履歴へ送る。 + + 代替画面 (vi 等) の中身だけは組み直さない。アプリはサイズ変更を + 受けて描き直すので、組み直しても上書きされるだけ。ただし裏へ + 退避しているメイン画面は、見えていなくても記録なので守る。 """ if (rows, cols) == (self.rows, self.cols) or rows < 1 or cols < 1: return - for line in self.lines + self._other: + main = self._other if self.alt_active else self.lines + alt = self.lines if self.alt_active else self._other + + if self.alt_active: + cursor = self._saved_main[:2] if self._saved_main else (0, 0) + else: + cursor = (self.cursor_row, self.cursor_col) + reflowed, cursor = self._reflow(main, cols, cursor) + + # 入りきらない分は、まず下の空行から捨てる。中身が無いので + # 捨てても記録は減らない。それでも余るときだけ上を履歴へ送る + while (len(reflowed) > rows and cursor[0] < len(reflowed) - 1 + and all(c == BLANK for c in reflowed[-1])): + reflowed.pop() + while len(reflowed) > rows: + removed = reflowed.pop(0) + self.history.append(removed) + self._new_history.append(removed) + cursor = (max(0, cursor[0] - 1), cursor[1]) + while len(reflowed) < rows: + reflowed.append([BLANK] * cols) + + del alt[rows:] + for line in alt: del line[cols:] line.extend([BLANK] * (cols - len(line))) - while len(self.lines) > rows: - if (self.cursor_row < len(self.lines) - 1 - and all(c == BLANK for c in self.lines[-1])): - self.lines.pop() - else: - removed = self.lines.pop(0) - if not self.alt_active: - self.history.append(removed) - self._new_history.append(removed) - self.cursor_row = max(0, self.cursor_row - 1) - del self._other[rows:] - while len(self.lines) < rows: - self.lines.append([BLANK] * cols) - while len(self._other) < rows: - self._other.append([BLANK] * cols) + while len(alt) < rows: + alt.append([BLANK] * cols) + + if self.alt_active: + self._other[:] = reflowed + if self._saved_main: + self._saved_main = (min(cursor[0], rows - 1), + min(cursor[1], cols - 1), + self._saved_main[2]) + else: + self.lines[:] = reflowed + self.cursor_row = min(cursor[0], rows - 1) + self.cursor_col = min(cursor[1], cols - 1) self.rows, self.cols = rows, cols self.scroll_top, self.scroll_bottom = 0, rows - 1 self.cursor_row = min(self.cursor_row, rows - 1) @@ -136,6 +162,31 @@ def set_size(self, rows, cols): self._pending_wrap = False self.dirty = set(range(rows)) + def _reflow(self, lines, cols, cursor): + """行の並びを cols 幅で組み直す。(新しい行, 新しいカーソル) を返す。""" + cursor_row, cursor_col = cursor + out = [] + new_cursor = (0, 0) + for r, line in enumerate(lines): + cells = list(line) + end = len(cells) + while end and cells[end - 1] == BLANK: + end -= 1 + if r == cursor_row: + end = max(end, cursor_col) # カーソル手前の空白は残す + cells = cells[:end] + first = len(out) + if not cells: + out.append([BLANK] * cols) + else: + for i in range(0, len(cells), cols): + chunk = cells[i:i + cols] + chunk.extend([BLANK] * (cols - len(chunk))) + out.append(chunk) + if r == cursor_row: + new_cursor = (first + cursor_col // cols, cursor_col % cols) + return out, new_cursor + # ---- 印字と C0 ------------------------------------------------- def _print(self, text): @@ -215,6 +266,12 @@ def _csi(self, seq): p = seq.params f = seq.final n = max(1, _param(p, 0, 1)) + # 繰り返す命令の回数は、画面より大きくても意味を持たない。 + # 頭打ちにしないと ESC[999999999S だけで画面処理が何十分も + # 回り、その間 GUI が固まる (パラメータは 256 桁まで書ける)。 + # カーソル移動は _move が行桁で丸めるので、ここでは触らない + rows_n = min(n, self.rows) + cols_n = min(n, self.cols) if f == "A": limit = (self.scroll_top if self.cursor_row >= self.scroll_top else 0) @@ -243,19 +300,19 @@ def _csi(self, seq): elif f == "K": self._erase_line(_param(p, 0, 0)) elif f == "L": - self._shift_lines(n, insert=True) + self._shift_lines(rows_n, insert=True) elif f == "M": - self._shift_lines(n, insert=False) + self._shift_lines(rows_n, insert=False) elif f == "@": - self._shift_chars(n, insert=True) + self._shift_chars(cols_n, insert=True) elif f == "P": - self._shift_chars(n, insert=False) + self._shift_chars(cols_n, insert=False) elif f == "X": - self._erase_chars(n) + self._erase_chars(cols_n) elif f == "S": - self._scroll_up(n) + self._scroll_up(rows_n) elif f == "T": - self._scroll_down(n) + self._scroll_down(rows_n) elif f == "r": self._set_margins(p) elif f == "m": @@ -306,6 +363,11 @@ def _private_mode(self, seq): self._switch_screen(on, with_cursor=(mode == 1049)) elif mode == 7: self.autowrap = on + if not on: + # 右端で保留していた折り返しも一緒に捨てる。 + # 残すと、折り返しを切った直後の 1 文字だけが + # 次の行へ流れる + self._pending_wrap = False elif mode == 25: self.cursor_visible = on elif mode == 1: @@ -341,6 +403,11 @@ def _set_margins(self, p): self._move(0, 0) # DECSTBM はカーソルも戻す def _erase_display(self, mode): + if mode == 3: + # ED 3 は履歴 (スクロールバック) を消す命令で、可視画面には + # 触らない。NetBelt はセッションの記録を消さない方針なので + # 何もしない。画面まで消すと clear -x で表示が飛ぶ + return # 画面全体が消えるとき (clear は ESC[H ESC[J、つまり home からの # mode 0 で来る) は、消す前に見えていた中身を履歴へ送る。 # clear でセッションの記録を失わない、という v1.1.1 の方針 diff --git a/tests/test_terminal_screen.py b/tests/test_terminal_screen.py index 00d2361..1915534 100644 --- a/tests/test_terminal_screen.py +++ b/tests/test_terminal_screen.py @@ -7,6 +7,7 @@ import io import os import sys +import time import unittest sys.path.insert(0, "src") @@ -192,6 +193,49 @@ def test_nonsense_margins_are_refused(self): self.assertEqual((s.scroll_top, s.scroll_bottom), (0, 23)) +class RunawayParameterTest(unittest.TestCase): + """暴走したパラメータで画面処理が止まらなくならないこと。 + + パラメータは 256 桁まで書けるので、頭打ちにしないと 10^250 回の + ループになる。apply は GUI スレッドから呼ばれるので、そうなると + 強制終了以外に戻る手立てが無い。 + """ + HUGE = "9" * 200 + + def test_every_repeating_command_finishes_at_once(self): + for final in "STLM@PX": + with self.subTest(command=final): + s = Screen() + start = time.time() + feed(s, "\x1b[" + self.HUGE + final) + self.assertLess(time.time() - start, 1.0, + "ESC[<巨大数>%s が終わらない" % final) + + def test_a_huge_scroll_does_not_flood_the_history(self): + s = Screen() + feed(s, "\x1b[" + self.HUGE + "S") + self.assertLessEqual(len(s.take_new_history()), s.rows) + + def test_the_result_still_matches_a_full_screen_operation(self): + # 頭打ちにしても、意味は「画面全部」のままであること + huge = feed(Screen(), "a\r\nb\r\nc\x1b[" + self.HUGE + "S") + full = feed(Screen(), "a\r\nb\r\nc\x1b[24S") + self.assertEqual(huge.text(), full.text()) + + +class ScrollbackEraseTest(unittest.TestCase): + def test_ed3_leaves_the_visible_screen_alone(self): + """ESC[3J は履歴を消す命令で、見えている画面には触らない。 + + NetBelt は記録を消さない方針なので何もしない。画面まで消すと + clear -x を打っただけで表示が飛ぶ。 + """ + s = feed(Screen(), "visible text") + feed(s, "\x1b[3J") + self.assertEqual(s.text()[0], "visible text") + self.assertEqual(len(s.history), 0) + + class AttributeTest(unittest.TestCase): def test_sgr_travels_with_the_characters(self): s = feed(Screen(), "a\x1b[7mb\x1b[mc") @@ -292,10 +336,25 @@ def test_wider_lines_keep_their_content(self): self.assertEqual(s.text()[0], "show version") self.assertEqual(len(s.lines[0]), 132) - def test_narrower_lines_are_cut_not_rewrapped(self): + def test_narrowing_wraps_instead_of_cutting(self): + """桁が狭くなっても一文字も捨てないこと。 + + 以前は del line[cols:] で切り捨てており、窓を縮めるだけで + 出力の末尾が永久に失われていた (実機試験で報告された)。 + 長い鍵や show 出力を読んでいる最中に窓を縮めると消える。 + """ s = feed(Screen(), "0123456789") s.set_size(24, 8) - self.assertEqual(s.text()[0], "01234567") + self.assertEqual(s.text()[:2], ["01234567", "89"]) + + def test_repeated_narrowing_still_keeps_everything(self): + key = "ssh-ed25519 " + "A" * 68 + " user@example.com" + s = Screen(24, 120) + feed(s, key) + for cols in (100, 80, 60, 40, 30): + s.set_size(24, cols) + self.assertEqual("".join(s.text()).replace(" ", ""), + key.replace(" ", "")) def test_shrinking_rows_prefers_dropping_blank_bottom_lines(self): s = feed(Screen(), "keep me") @@ -310,6 +369,16 @@ def test_shrinking_rows_saves_written_lines_to_history(self): self.assertEqual(s.text()[0], "l14") self.assertEqual((s.cursor_row, s.cursor_col), (9, 3)) + def test_the_screen_behind_an_alternate_screen_is_not_lost(self): + """vi を開いている間に窓を縮めても、裏のシェル画面を捨てないこと。""" + s = feed(Screen(), "\r\n".join("shell-%02d" % i for i in range(24))) + feed(s, "\x1b[?1049h") + s.set_size(10, 80) + feed(s, "\x1b[?1049l") + seen = everything(s) + for i in range(24): + self.assertIn("shell-%02d" % i, seen) + def test_the_scroll_region_snaps_back_to_full(self): s = feed(Screen(), "\x1b[5;10r") s.set_size(30, 80) From 787d01375cf7b1317b540e0385e6a4e110f56b74 Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:40:49 +0900 Subject: [PATCH 17/29] fix(terminal): make the screen fill the view, and keep notices straight Testing the build on real gear turned up three visible faults, all from the same root: the live screen was drawn only as tall as its last non-blank row. clear looked like it did nothing - the region collapsed, so scrollback rose to fill the viewport. nano drew half off-screen, because ensureCursorVisible scrolled to the caret up in the editing area rather than to the screen's bottom. The screen is now always drawn its full height and the view is pinned to the bottom, which is what a terminal does: the last N lines of the document are exactly what the device has on screen. Selections no longer die when the screen scrolls. A line scrolling off shifted every row, so the diff found no common prefix and replaced the whole region, taking any selection with it. The scrolled-off line is already in the document, so instead of writing it again the boundary between record and live screen just moves past it - nothing is edited, so nothing is lost. Re-inserted text also carries an explicit empty format now, and rows covered by the replacement are repainted, so colours no longer bleed from whatever sat at the insertion point. Notices the app writes itself (disconnect banner, errors, serial messages) used bare LF. A terminal's LF moves down without returning to column 0, so with a prompt on screen the disconnect banner came out in a staircase. They go through show_notice, which converts to CRLF; device output keeps its LF meaning untouched. Session logs also keep tabs again - dropping them ran table columns together. Co-Authored-By: Claude Opus 5 --- src/core/serial_connection.py | 7 +- src/ui/main_window.py | 14 +-- src/ui/terminal_widget.py | 65 ++++++++++--- tests/test_fixture_sanitisation.py | 94 ++++++++++++++++++ tests/test_terminal_escape.py | 10 +- tests/test_terminal_widget_render.py | 140 ++++++++++++++++++++++++++- 6 files changed, 305 insertions(+), 25 deletions(-) create mode 100644 tests/test_fixture_sanitisation.py diff --git a/src/core/serial_connection.py b/src/core/serial_connection.py index 610ab8e..158afc9 100644 --- a/src/core/serial_connection.py +++ b/src/core/serial_connection.py @@ -63,7 +63,8 @@ def connect(self) -> bool: self._should_stop = False # 接続成功メッセージ - self.output_received.emit(f"\n接続しました: {self.port} ({self.baudrate} baud)\n") + self.output_received.emit( + f"\r\n接続しました: {self.port} ({self.baudrate} baud)\r\n") self.connected.emit() # 読み取りスレッドを開始 @@ -73,12 +74,12 @@ def connect(self) -> bool: except serial.SerialException as e: error_msg = f"接続失敗: {str(e)}" - self.output_received.emit(f"\n{error_msg}\n") + self.output_received.emit(f"\r\n{error_msg}\r\n") self.error_occurred.emit(error_msg) return False except Exception as e: error_msg = f"予期しないエラー: {str(e)}" - self.output_received.emit(f"\n{error_msg}\n") + self.output_received.emit(f"\r\n{error_msg}\r\n") self.error_occurred.emit(error_msg) return False diff --git a/src/ui/main_window.py b/src/ui/main_window.py index 85f0bd4..49a7007 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -642,7 +642,7 @@ def connect_thread(): if not success: # GUIスレッド外からウィジェットを直接触らない。 # 既に append_output へ接続済みのシグナルへ流す。 - ssh.output_received.emit("\n接続失敗\n") + ssh.output_received.emit("\r\n接続失敗\r\n") except Exception as e: ssh.error_occurred.emit(f"接続スレッドエラー: {str(e)}") @@ -713,7 +713,7 @@ def connect_thread(): if not success: # GUIスレッド外からウィジェットを直接触らない。 # 既に append_output へ接続済みのシグナルへ流す。 - serial_conn.output_received.emit("\n接続失敗\n") + serial_conn.output_received.emit("\r\n接続失敗\r\n") except Exception as e: serial_conn.error_occurred.emit(f"接続スレッドエラー: {str(e)}") @@ -782,7 +782,7 @@ def connect_thread(): if not success: # GUIスレッド外からウィジェットを直接触らない。 # 既に append_output へ接続済みのシグナルへ流す。 - telnet.output_received.emit("\n接続失敗\n") + telnet.output_received.emit("\r\n接続失敗\r\n") except Exception as e: telnet.error_occurred.emit(f"接続スレッドエラー: {str(e)}") @@ -905,7 +905,7 @@ def _on_connection_closed(self, device_name: str): del self.connections[device_name] # 切断メッセージと再接続方法を表示 - self.terminal_widget.append_output( + self.terminal_widget.show_notice( device_name, "\n\n========================================\n" "セッションが切断されました\n" @@ -926,7 +926,7 @@ def _on_connection_error(self, device_name: str, error: str): self._on_connection_closed(device_name) else: # その他のエラー - self.terminal_widget.append_output(device_name, f"\nエラー: {error}\n") + self.terminal_widget.show_notice(device_name, f"\nエラー: {error}\n") if device_name in self.connections: del self.connections[device_name] @@ -939,11 +939,11 @@ def _reconnect_device(self, device_name: str): """ # 機器情報を取得 if device_name not in self.device_info: - self.terminal_widget.append_output(device_name, "\n再接続情報が見つかりません\n") + self.terminal_widget.show_notice(device_name, "\n再接続情報が見つかりません\n") return # 再接続メッセージ - self.terminal_widget.append_output(device_name, "再接続中...\n\n") + self.terminal_widget.show_notice(device_name, "再接続中...\n\n") # 接続処理を実行 device_data = self.device_info[device_name] diff --git a/src/ui/terminal_widget.py b/src/ui/terminal_widget.py index a46cabd..34868d5 100644 --- a/src/ui/terminal_widget.py +++ b/src/ui/terminal_widget.py @@ -713,8 +713,21 @@ def _render_screen(self, terminal: QTextEdit) -> None: region = terminal._region for line in screen.take_new_history(): - for text, attr in self._runs(self._visible_cells(line)): - region.insertText(text, self._char_format(attr)) + # 押し出された行は、画面領域の先頭として文書にもう書いて + # ある。同じ内容なら書き直さず、記録との境目を進めるだけに + # する。書き直すと画面領域が丸ごと入れ替わり、そこにある + # 範囲選択が消える (機器がログを 1 行吐くだけで起きる) + text = "".join(cell[0] for cell in self._visible_cells(line)) + probe = QTextCursor(terminal.document()) + probe.setPosition(region.position()) + probe.movePosition(QTextCursor.MoveOperation.EndOfBlock, + QTextCursor.MoveMode.KeepAnchor) + if (probe.selectedText() == text + and not probe.atEnd()): + region.setPosition(probe.position() + 1) + continue + for run, attr in self._runs(self._visible_cells(line)): + region.insertText(run, self._char_format(attr)) region.insertText("\n", QTextCharFormat()) cell_rows = [self._visible_cells(line) for line in screen.lines] @@ -725,11 +738,10 @@ def _render_screen(self, terminal: QTextEdit) -> None: cell_rows[screen.cursor_row] = ( cell_rows[screen.cursor_row] + [BLANK] * pad) rows = ["".join(cell[0] for cell in cells) for cells in cell_rows] - last = screen.cursor_row - for r, line in enumerate(rows): - if line and r > last: - last = r - new_text = "\n".join(rows[:last + 1]) + # 画面は必ず行数ぶんの高さで描く。末尾の空行を詰めると、clear の + # あとに履歴が下端へせり上がり「消えていない」ように見える。 + # 全画面アプリも、画面の一部しか窓に入らなくなる + new_text = "\n".join(rows) start = region.position() # 変わった範囲だけ置き換える。全部消して入れ直すと、機器が @@ -739,6 +751,7 @@ def _render_screen(self, terminal: QTextEdit) -> None: probe.movePosition(QTextCursor.MoveOperation.End, QTextCursor.MoveMode.KeepAnchor) old_text = probe.selectedText().replace("\u2029", "\n") + touched = (0, -1) if old_text != new_text: prefix = 0 limit = min(len(old_text), len(new_text)) @@ -751,14 +764,25 @@ def _render_screen(self, terminal: QTextEdit) -> None: probe.setPosition(start + prefix) probe.setPosition(start + len(old_text) - suffix, QTextCursor.MoveMode.KeepAnchor) - probe.insertText(new_text[prefix:len(new_text) - suffix]) + # 書式は空で入れる。insertText は挿入位置の書式を引き継ぐので、 + # 指定しないと直前の色や反転が新しい文字へ伝染する + probe.insertText(new_text[prefix:len(new_text) - suffix], + QTextCharFormat()) + touched = (prefix, len(new_text) - suffix) region.setPosition(start) # 変わった行に色・太字・反転を塗り直す dirty = screen.take_dirty() dirty.add(screen.cursor_row) # カーソル桁の空白の伸縮ぶん + # 書き換わった範囲の行も塗り直す。画面側が「変わっていない」と + # 思っていても、入れ直した文字は書式を失っている + at = 0 + for r, line in enumerate(rows): + if at <= touched[1] and at + len(line) >= touched[0]: + dirty.add(r) + at += len(line) + 1 offset = start - for r in range(last + 1): + for r in range(len(rows)): if r in dirty: self._paint_row(terminal, offset, cell_rows[r]) offset += len(rows[r]) + 1 @@ -773,7 +797,23 @@ def _render_screen(self, terminal: QTextEdit) -> None: caret = QTextCursor(terminal.document()) caret.setPosition(pos) terminal.setTextCursor(caret) - terminal.ensureCursorVisible() + # 端末と同じく、常に下端へ寄せる。画面は文書の末尾 rows 行 + # なので、ここを見せることが「いま端末に映っているもの」を + # 見せることになる。カーソルへ寄せると、全画面アプリでは + # 画面の上半分しか窓に入らない + bar = terminal.verticalScrollBar() + bar.setValue(bar.maximum()) + + def show_notice(self, device_name: str, text: str) -> None: + """アプリ自身の案内 (切断バナー・エラー文) を画面へ出す。 + + 端末では LF は「1 行下へ」であって行頭へは戻らない (戻すのは + CR)。アプリの文言は普通の改行で書かれているので、ここで + CRLF へ直す。直さないと案内が階段状にずれて出る。 + """ + self.append_output(device_name, + text.replace("\r\n", "\n") + .replace("\n", "\r\n")) def append_output(self, device_name: str, text: str) -> None: """ @@ -797,11 +837,12 @@ def append_output(self, device_name: str, text: str) -> None: terminal.key_pressed.emit(response) if device_name in self._log_files: + # タブは桁を作る文字なので落とすと表が潰れる logged = "".join( - e.text if isinstance(e, vt.Print) else "\n" + e.text if isinstance(e, vt.Print) else e.char for e in events if isinstance(e, vt.Print) - or (isinstance(e, vt.Ctrl) and e.char == "\n")) + or (isinstance(e, vt.Ctrl) and e.char in "\n\t")) if logged: try: self._log_files[device_name].write(logged) diff --git a/tests/test_fixture_sanitisation.py b/tests/test_fixture_sanitisation.py new file mode 100644 index 0000000..8be725d --- /dev/null +++ b/tests/test_fixture_sanitisation.py @@ -0,0 +1,94 @@ +"""採取データが公開できる状態であることを見張る。 + +tests/fixtures/*.bin は実機・実ラボから採った受信バイトそのもの。 +このリポジトリは公開されているので、実環境を指す値が 1 つでも +混ざったまま入ってはいけない。 + +判定は「禁止する形を並べる」ではなく「文書用に割り当てられた範囲か」で +行う。並べる方式は、次に別の形が来たときにまた抜けるうえ、実環境の値を +禁止リストとして書き残すことになる。 + +最初は IPv4 しか見ておらず、`ip -br addr` の出力に混じった IPv6 リンク +ローカルを見落としていた。リンクローカルは EUI-64 で作られるため、 +機器の MAC アドレスがそのまま復元できてしまう。 +""" +import io +import os +import re +import unittest + +FIXTURES = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "fixtures") + +# RFC 5737 / RFC 6890 の文書用・ループバック・ワイルドカードのみ許す +ALLOWED_V4 = re.compile( + r"^(?:192\.0\.2\.|198\.51\.100\.|203\.0\.113\.|127\.|0\.0\.0\.0)") +# RFC 3849 の文書用のみ許す +ALLOWED_V6 = re.compile(r"^(?:2001:db8:|::1$)", re.I) +# RFC 2606 の予約名か、採取時に置き換えた架空名だけ許す +ALLOWED_HOST = re.compile( + r"^[\w.-]+@(?:lab|lab-rtr|localhost|[\w.-]+\.(?:example|test|invalid))$", + re.I) + +ANY_V4 = re.compile(r"\b\d{1,3}(?:\.\d{1,3}){3}\b") +ANY_V6 = re.compile(r"\b(?:[0-9a-f]{1,4}:){3,}[0-9a-f]{0,4}\b", re.I) +MAC_COLON = re.compile(r"\b[0-9a-f]{2}(?::[0-9a-f]{2}){5}\b", re.I) +MAC_CISCO = re.compile(r"\b[0-9a-f]{4}(?:\.[0-9a-f]{4}){2}\b", re.I) +# Cisco の文書用 MAC は 0000.5e00.53xx (RFC 7042) +ALLOWED_MAC_CISCO = re.compile(r"^0000\.5e00\.53", re.I) +USER_AT_HOST = re.compile(r"\b[\w.-]+@[\w.-]+\b") + + +def captures(): + for name in sorted(os.listdir(FIXTURES)): + if name.endswith(".bin"): + raw = io.open(os.path.join(FIXTURES, name), "rb").read() + yield name, raw.decode("utf-8", "replace") + + +class FixtureSanitisationTest(unittest.TestCase): + def test_there_are_captures_to_check(self): + """見張る対象がある (採取データの置き場所が変わっていない)。""" + self.assertGreaterEqual(len(list(captures())), 4) + + def test_every_ipv4_is_a_documentation_address(self): + for name, text in captures(): + with self.subTest(capture=name): + for ip in ANY_V4.findall(text): + self.assertRegex( + ip, ALLOWED_V4, + "%s: 文書用に割り当てられていないアドレス" % name) + + def test_every_ipv6_is_a_documentation_address(self): + for name, text in captures(): + with self.subTest(capture=name): + for addr in ANY_V6.findall(text): + self.assertRegex( + addr, ALLOWED_V6, + "%s: 文書用に割り当てられていないアドレス" % name) + + def test_no_mac_address_survives(self): + for name, text in captures(): + with self.subTest(capture=name): + found = MAC_COLON.search(text) + self.assertIsNone( + found, "%s: MAC アドレスが残っている: %s" + % (name, found.group(0) if found else "")) + for mac in MAC_CISCO.findall(text): + self.assertRegex( + mac, ALLOWED_MAC_CISCO, + "%s: 文書用でない MAC が残っている" % name) + + def test_no_real_host_or_user_name_survives(self): + for name, text in captures(): + with self.subTest(capture=name): + for pair in USER_AT_HOST.findall(text): + if "@" not in pair: + continue + self.assertRegex( + pair, ALLOWED_HOST, + "%s: 実環境を指す名前が残っている" % name) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_terminal_escape.py b/tests/test_terminal_escape.py index bbe344a..58129bd 100644 --- a/tests/test_terminal_escape.py +++ b/tests/test_terminal_escape.py @@ -23,11 +23,19 @@ def setUpClass(cls): cls.app = QApplication.instance() or QApplication([]) def _screen(self, payload): + """画面に見えている文字を返す。 + + 画面は常に行数ぶんの高さで描かれる (clear で下端に履歴が + せり上がってこないようにするため) ので、判定に関係の無い + 末尾の空行は落とす。ここで見たいのは、エスケープが文字と + して出ていないかどうか。 + """ from ui.terminal_widget import TerminalWidget w = TerminalWidget() w.create_terminal_tab("dev") w.append_output("dev", payload) - return w._terminals["dev"].toPlainText() + text = w._terminals["dev"].toPlainText() + return text.rstrip("\n") # --- DECSET/DECRST (ESC[?h / ESC[?l) --- diff --git a/tests/test_terminal_widget_render.py b/tests/test_terminal_widget_render.py index 1e653db..979e485 100644 --- a/tests/test_terminal_widget_render.py +++ b/tests/test_terminal_widget_render.py @@ -30,12 +30,21 @@ def setUpClass(cls): from PyQt6.QtWidgets import QApplication cls.app = QApplication.instance() or QApplication([]) - def widget(self): + def widget(self, rows=None, cols=None): from ui.terminal_widget import TerminalWidget w = TerminalWidget() + if rows is not None: + w._grid_size = lambda t, r=rows, c=cols: (r, c) w.create_terminal_tab("dev") + if rows is not None: + w._apply_grid_size("dev") return w + def visible_screen(self, w): + """いま端末に映っている行 (文書の末尾 rows 行)。""" + terminal = w._terminals["dev"] + return terminal.toPlainText().split("\n")[-terminal._screen.rows:] + def screen_text(self, w): return w._terminals["dev"].toPlainText() @@ -44,7 +53,9 @@ class NanoRendersForRealTest(WidgetRenderTest): """実機の nano (527 バイト)。旧実装では 1 行に潰れていた。""" def setUp(self): - self.w = self.widget() + # 採取したときと同じ 24x80 で描く。nano は端末の大きさに合わせて + # 組むので、違う大きさで流すと下端の位置が合わない + self.w = self.widget(24, 80) self.w.append_output("dev", "user@lab:~$ ") self.w.append_output("dev", fixture("nano_vt100.bin")) self.lines = self.screen_text(self.w).split("\n") @@ -73,6 +84,104 @@ def test_what_was_on_screen_moves_into_the_record(self): self.assertTrue(text.rstrip().endswith("user@lab:~$")) +class ScreenOccupiesTheViewportTest(WidgetRenderTest): + """画面は必ず行数ぶんの高さを占めること。 + + 実機試験で「clear が効かない」「nano の表示がおかしい」と報告された。 + どちらも原因は同じで、末尾の空行を詰めていたために履歴が下端へ + せり上がり、画面が窓の一部しか占めていなかった。 + """ + + def test_clear_leaves_an_empty_screen(self): + w = self.widget(24, 80) + w.append_output("dev", "\r\n".join("out-%02d" % i for i in range(20)) + + "\r\n$ clear\r\n") + w.append_output("dev", fixture("clear_vt100.bin")) + screen = self.visible_screen(w) + self.assertEqual(len(screen), 24) + self.assertFalse([l for l in screen if "out-" in l], + "clear のあとも直前の出力が画面に残っている") + self.assertIn("user@lab:~$", "".join(screen)) + self.assertIn("out-19", self.screen_text(w)) # 記録には残る + + def test_a_full_screen_app_fills_the_screen(self): + w = self.widget(24, 80) + w.append_output("dev", "\r\n".join("shell-%02d" % i for i in range(20)) + + "\r\n$ nano\r\n") + w.append_output("dev", fixture("nano_vt100.bin")) + screen = self.visible_screen(w) + self.assertEqual(len(screen), 24) + self.assertIn("GNU nano", screen[0]) + self.assertTrue(screen[-1].startswith("^X Exit")) + self.assertFalse([l for l in screen if l.startswith("shell-")], + "画面にシェルの履歴が混ざっている") + + def test_the_view_stays_at_the_bottom(self): + w = self.widget(24, 80) + w.append_output("dev", "\r\n".join("l%02d" % i for i in range(60))) + bar = w._terminals["dev"].verticalScrollBar() + self.assertEqual(bar.value(), bar.maximum()) + + +class ResizeKeepsContentTest(WidgetRenderTest): + """窓を縮めても一文字も失わないこと (実機試験で報告)。""" + + KEY = "ssh-ed25519 " + "A" * 68 + " user@example.com" + + def test_a_long_line_survives_repeated_shrinking(self): + w = self.widget(24, 120) + w.append_output("dev", "$ cat ~/.ssh/authorized_keys\r\n" + + self.KEY + "\r\n$ ") + for cols in (100, 80, 60, 40, 30): + w._grid_size = lambda t, c=cols: (24, c) + w._apply_grid_size("dev") + joined = "".join(self.screen_text(w).split("\n")) + self.assertIn(self.KEY.replace(" ", ""), + joined.replace(" ", ""), + "%d 桁へ縮めたときに欠けた" % cols) + + +class NoticeTest(WidgetRenderTest): + """アプリ自身の案内が階段状にならないこと。 + + 端末の LF は「1 行下へ」で行頭には戻らない。アプリの文言は普通の + 改行で書かれているので、そのまま流すと切断バナーが桁ずれする。 + """ + + def test_a_banner_stays_left_aligned(self): + w = self.widget(24, 80) + w.append_output("dev", "lab-rtr#") # プロンプト表示中 + w.show_notice("dev", + "\n\n====\nセッションが切断されました\n====\n") + printed = [l for l in self.screen_text(w).split("\n") if l.strip()] + for line in printed[1:]: + self.assertFalse(line.startswith(" "), + "案内が字下げされている: %r" % line) + + def test_device_output_is_untouched(self): + # 機器からの LF は端末の意味のまま扱う (show の桁揃えが崩れる) + w = self.widget(24, 80) + w.append_output("dev", "abc\ndef") + self.assertEqual(self.screen_text(w).split("\n")[1], " def") + + +class LogRecordingTest(WidgetRenderTest): + def test_tabs_are_kept_in_the_log(self): + """タブは桁を作る文字なので、落とすと表が潰れる。""" + import io as _io + import tempfile + w = self.widget(24, 80) + path = os.path.join(tempfile.gettempdir(), "netbelt_tab_log.txt") + w._log_files["dev"] = _io.open(path, "w", encoding="utf-8") + try: + w.append_output("dev", "Interface\tStatus\r\nGi0/0\tup\r\n") + w._log_files["dev"].close() + self.assertEqual(_io.open(path, encoding="utf-8").read(), + "Interface\tStatus\nGi0/0\tup\n") + finally: + os.remove(path) + + class SplitInvarianceTest(WidgetRenderTest): """受信がどこで切れても同じ画面になること。切れ目対応は パーサが状態として持つので、ウィジェットに継ぎ足し処理は無い。""" @@ -114,6 +223,33 @@ def test_terminal_state_starts_over_but_the_record_stays(self): class SelectionSurvivesOutputTest(WidgetRenderTest): + def test_a_selection_survives_the_screen_scrolling(self): + """画面がスクロールしても範囲選択が消えないこと。 + + v1.1.0 で直した性質。画面が 1 行上がると全行がずれるので、 + 素朴に描き直すと選択が Qt に潰される。show run をコピーしよう + としている最中に機器が syslog を 1 行吐くだけで起きる。 + """ + from PyQt6.QtGui import QTextCursor + w = self.widget() + terminal = w._terminals["dev"] + rows = terminal._screen.rows + w.append_output("dev", + "\r\n".join("line %02d" % i for i in range(rows + 8))) + self.assertGreater(len(terminal._screen.history), 0, + "スクロールが起きていない (前提が崩れている)") + + for word in ("line 05", "line 12", "line %02d" % (rows + 5)): + text = terminal.toPlainText() + cursor = terminal.textCursor() + cursor.setPosition(text.index(word)) + cursor.movePosition(QTextCursor.MoveOperation.Right, + QTextCursor.MoveMode.KeepAnchor, len(word)) + terminal.setTextCursor(cursor) + w.append_output("dev", "\r\nsyslog message") + self.assertEqual(terminal.textCursor().selectedText(), word, + "%s の選択が消えた" % word) + def test_incoming_output_does_not_break_a_selection(self): # コピーしようと選択している最中に機器がログを吐いても、 # 選択が消えたり置き換わったりしないこと (旧実装から守る性質) From c97c4d4fece1d9410bef4313ca1e3ca1fbc8af3e Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:47:48 +0900 Subject: [PATCH 18/29] test(connect): stop pinning the line endings of a failure notice The thread-safety check matched the failure notice as an exact source literal, so switching it to CRLF for the terminal broke a test that has nothing to say about line endings. It now matches the call shape instead: three emits through the signal, no direct widget call. Both mutations - routing one through the widget, and dropping one of the three - are still caught. Co-Authored-By: Claude Opus 5 --- tests/test_connect_thread_safety.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/tests/test_connect_thread_safety.py b/tests/test_connect_thread_safety.py index 953fbca..9b3f9f0 100644 --- a/tests/test_connect_thread_safety.py +++ b/tests/test_connect_thread_safety.py @@ -67,14 +67,22 @@ def test_worker_threads_do_not_touch_widgets(self): + "\n ".join(offenders)) def test_connection_failure_goes_through_signal(self): - """接続失敗の通知が3種ともシグナル経由であること。""" + """接続失敗の通知が3種ともシグナル経由であること。 + + 見ているのはスレッドの安全性であって文言ではないので、 + 改行の書き方 (端末向けの CRLF) には依存させない。 + """ + import re + + through_signal = re.findall( + r'\.output_received\.emit\("[\\rn]*接続失敗', self.src) self.assertEqual( - self.src.count('.output_received.emit("\\n接続失敗\\n")'), 3, + len(through_signal), 3, "接続失敗の通知がシグナル経由になっていない") - self.assertNotIn( - 'self.terminal_widget.append_output(device_name, "\\n接続失敗\\n")', - self.src, - "ウィジェットの直接呼び出しが残っている") + direct = re.findall( + r'terminal_widget\.\w+\([^)]*接続失敗', self.src) + self.assertEqual( + direct, [], "ウィジェットの直接呼び出しが残っている") if __name__ == "__main__": From 62c7a1f6e8f9edf8b4625db6e31d350ecf1d41ad Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:00:43 +0900 Subject: [PATCH 19/29] fix(terminal): remember which line breaks are only wrapping Resizing narrow and back left output permanently chopped up: ls /etc/ came back with its columns split mid-line, and because the chopped rows had already scrolled into the record, later commands inherited the mess. The screen split long lines on the way down but had no way to tell a wrap from a line break the device actually sent, so widening could not put them back together. Each row now carries a flag saying whether it continues onto the next, set only where autowrap actually carried a character over. Reflow joins each run of continued rows into one logical line before splitting it at the new width, so narrowing and widening is a round trip. The flags ride along through scrolls, insertions, erases and the alternate-screen swap, and a row cleared to the end of line stops being a continuation. The renderer uses the same flag: a continued row goes into the document without a newline after it, so a line that scrolled off while the window was narrow is one long line in the record and re-wraps freely at whatever width the window has later. Co-Authored-By: Claude Opus 5 --- src/core/terminal/screen.py | 97 +++++++++++++++++++++------- src/ui/terminal_widget.py | 15 +++-- tests/test_terminal_screen.py | 34 ++++++++++ tests/test_terminal_widget_render.py | 21 ++++++ 4 files changed, 140 insertions(+), 27 deletions(-) diff --git a/src/core/terminal/screen.py b/src/core/terminal/screen.py index 2300f36..82b6a62 100644 --- a/src/core/terminal/screen.py +++ b/src/core/terminal/screen.py @@ -40,6 +40,11 @@ def reset(self): """RIS 相当。履歴とタイトルだけは残す (セッションの記録なので)。""" self.lines = self._blank_lines() self._other = self._blank_lines() # 裏画面 (代替画面用) + # wrapped[r] = その行は折り返しで次の行へ続いている。 + # 機器が送った改行との区別が付かないと、窓を戻したときに + # 繋ぎ直せず、出力が刻まれたまま残る + self.wrapped = [False] * self.rows + self._other_wrapped = [False] * self.rows self.alt_active = False self.cursor_row = 0 self.cursor_col = 0 @@ -86,7 +91,7 @@ def text(self): for line in self.lines] def take_new_history(self): - """前回から増えた履歴行を返して忘れる。""" + """前回から増えた履歴を (行, 折り返しで続くか) で返して忘れる。""" new = self._new_history self._new_history = [] return new @@ -121,22 +126,26 @@ def set_size(self, rows, cols): if self.alt_active: cursor = self._saved_main[:2] if self._saved_main else (0, 0) + marks = self._other_wrapped else: cursor = (self.cursor_row, self.cursor_col) - reflowed, cursor = self._reflow(main, cols, cursor) + marks = self.wrapped + reflowed, marks, cursor = self._reflow(main, marks, cols, cursor) # 入りきらない分は、まず下の空行から捨てる。中身が無いので # 捨てても記録は減らない。それでも余るときだけ上を履歴へ送る while (len(reflowed) > rows and cursor[0] < len(reflowed) - 1 and all(c == BLANK for c in reflowed[-1])): reflowed.pop() + marks.pop() while len(reflowed) > rows: removed = reflowed.pop(0) self.history.append(removed) - self._new_history.append(removed) + self._new_history.append((removed, marks.pop(0))) cursor = (max(0, cursor[0] - 1), cursor[1]) while len(reflowed) < rows: reflowed.append([BLANK] * cols) + marks.append(False) del alt[rows:] for line in alt: @@ -147,12 +156,15 @@ def set_size(self, rows, cols): if self.alt_active: self._other[:] = reflowed + self._other_wrapped = marks + self.wrapped = [False] * rows # 代替画面は組み直さない if self._saved_main: self._saved_main = (min(cursor[0], rows - 1), min(cursor[1], cols - 1), self._saved_main[2]) else: self.lines[:] = reflowed + self.wrapped = marks self.cursor_row = min(cursor[0], rows - 1) self.cursor_col = min(cursor[1], cols - 1) self.rows, self.cols = rows, cols @@ -162,30 +174,53 @@ def set_size(self, rows, cols): self._pending_wrap = False self.dirty = set(range(rows)) - def _reflow(self, lines, cols, cursor): - """行の並びを cols 幅で組み直す。(新しい行, 新しいカーソル) を返す。""" + def _reflow(self, lines, wrapped, cols, cursor): + """行の並びを cols 幅で組み直す。 + + 折り返しで続いている行はいったん繋いでから割り直す。繋がずに + 割るだけだと、窓を縮めて戻したときに刻まれたまま残る + (ls /etc/ のような出力が実機試験で崩れた)。 + + (新しい行, 新しい印, 新しいカーソル) を返す。 + """ cursor_row, cursor_col = cursor - out = [] + out, out_wrapped = [], [] new_cursor = (0, 0) - for r, line in enumerate(lines): - cells = list(line) - end = len(cells) - while end and cells[end - 1] == BLANK: - end -= 1 - if r == cursor_row: - end = max(end, cursor_col) # カーソル手前の空白は残す - cells = cells[:end] + r = 0 + while r < len(lines): + # 続いている行をひとかたまり (論理行) にする + cells, offset, hit = [], None, False + while True: + row = list(lines[r]) + end = len(row) + while end and row[end - 1] == BLANK: + end -= 1 + if r == cursor_row: + end = max(end, cursor_col) # カーソル手前の空白は残す + offset = len(cells) + cursor_col + hit = True + if r < len(wrapped) and wrapped[r]: + end = len(row) # 続くなら右端まで中身 + cells.extend(row[:end]) + if not (r < len(wrapped) and wrapped[r] + and r + 1 < len(lines)): + break + r += 1 + r += 1 + first = len(out) if not cells: out.append([BLANK] * cols) + out_wrapped.append(False) else: for i in range(0, len(cells), cols): chunk = cells[i:i + cols] chunk.extend([BLANK] * (cols - len(chunk))) out.append(chunk) - if r == cursor_row: - new_cursor = (first + cursor_col // cols, cursor_col % cols) - return out, new_cursor + out_wrapped.append(i + cols < len(cells)) + if hit: + new_cursor = (first + offset // cols, offset % cols) + return out, out_wrapped, new_cursor # ---- 印字と C0 ------------------------------------------------- @@ -195,7 +230,7 @@ def _print(self, text): continue if self._pending_wrap: # 右端の 1 文字あとの折り返し self.cursor_col = 0 - self._linefeed() + self._linefeed(from_wrap=True) if self._g[self._charset] == "0": ch = DEC_GRAPHICS.get(ch, ch) line = self.lines[self.cursor_row] @@ -227,8 +262,10 @@ def _ctrl(self, ch): self._charset = "(" # BEL・NUL などは何もしない - def _linefeed(self): + def _linefeed(self, from_wrap=False): self._pending_wrap = False + # 折り返しで送られたのか、機器が改行を送ったのかを覚える + self.wrapped[self.cursor_row] = from_wrap if self.cursor_row == self.scroll_bottom: self._scroll_up(1) elif self.cursor_row + 1 < self.rows: @@ -237,17 +274,21 @@ def _linefeed(self): def _scroll_up(self, n): for _ in range(n): removed = self.lines.pop(self.scroll_top) + removed_wrap = self.wrapped.pop(self.scroll_top) self.lines.insert(self.scroll_bottom, self._blank_line()) + self.wrapped.insert(self.scroll_bottom, False) if (not self.alt_active and self.scroll_top == 0 and self.scroll_bottom == self.rows - 1): self.history.append(removed) - self._new_history.append(removed) + self._new_history.append((removed, removed_wrap)) self.dirty.update(range(self.scroll_top, self.scroll_bottom + 1)) def _scroll_down(self, n): for _ in range(n): self.lines.pop(self.scroll_bottom) + self.wrapped.pop(self.scroll_bottom) self.lines.insert(self.scroll_top, self._blank_line()) + self.wrapped.insert(self.scroll_top, False) self.dirty.update(range(self.scroll_top, self.scroll_bottom + 1)) # ---- CSI ------------------------------------------------------- @@ -382,10 +423,15 @@ def _switch_screen(self, to_alt, with_cursor): if to_alt and with_cursor: self._saved_main = (self.cursor_row, self.cursor_col, self.attr) self.lines, self._other = self._other, self.lines + # 折り返しの印も画面と一緒に入れ替える。裏へ回ったメイン画面の + # 印を失うと、戻ってきたときに組み直しで繋ぎ直せなくなる + self.wrapped, self._other_wrapped = ( + self._other_wrapped, self.wrapped) self.alt_active = to_alt if to_alt: # 代替画面は白紙で始まる for r in range(self.rows): self.lines[r] = self._blank_line() + self.wrapped[r] = False self._move(0, 0) elif with_cursor and self._saved_main: row, col, attr = self._saved_main @@ -419,9 +465,9 @@ def _erase_display(self, mode): for r in range(self.rows): if any(c != BLANK for c in self.lines[r]): last = r - for line in self.lines[:last + 1]: + for r, line in enumerate(self.lines[:last + 1]): self.history.append(line) - self._new_history.append(line) + self._new_history.append((line, self.wrapped[r])) if wipes_all: rng = range(0, self.rows) elif mode == 0: @@ -432,6 +478,7 @@ def _erase_display(self, mode): rng = range(0, self.cursor_row) for r in rng: self.lines[r] = self._blank_line() + self.wrapped[r] = False self.dirty.update(rng) self._pending_wrap = False @@ -445,6 +492,8 @@ def _erase_line(self, mode): rng = range(0, self.cols) for c in rng: line[c] = BLANK + if mode != 1: # 行末まで消したら続きは無い + self.wrapped[self.cursor_row] = False self.dirty.add(self.cursor_row) self._pending_wrap = False @@ -455,10 +504,14 @@ def _shift_lines(self, n, insert): for _ in range(n): if insert: self.lines.pop(self.scroll_bottom) + self.wrapped.pop(self.scroll_bottom) self.lines.insert(self.cursor_row, self._blank_line()) + self.wrapped.insert(self.cursor_row, False) else: self.lines.pop(self.cursor_row) + self.wrapped.pop(self.cursor_row) self.lines.insert(self.scroll_bottom, self._blank_line()) + self.wrapped.insert(self.scroll_bottom, False) self.dirty.update(range(self.cursor_row, self.scroll_bottom + 1)) self._pending_wrap = False diff --git a/src/ui/terminal_widget.py b/src/ui/terminal_widget.py index 34868d5..0e24f18 100644 --- a/src/ui/terminal_widget.py +++ b/src/ui/terminal_widget.py @@ -712,23 +712,28 @@ def _render_screen(self, terminal: QTextEdit) -> None: screen = terminal._screen region = terminal._region - for line in screen.take_new_history(): + for line, wrapped in screen.take_new_history(): + # 折り返しで続いている行は、改行で切らずに次の行と繋げる。 + # 切ると、窓を縮めている間に流れた出力が刻まれたまま記録に + # 残り、窓を戻しても直らない + cells = list(line) if wrapped else self._visible_cells(line) + text = "".join(cell[0] for cell in cells) # 押し出された行は、画面領域の先頭として文書にもう書いて # ある。同じ内容なら書き直さず、記録との境目を進めるだけに # する。書き直すと画面領域が丸ごと入れ替わり、そこにある # 範囲選択が消える (機器がログを 1 行吐くだけで起きる) - text = "".join(cell[0] for cell in self._visible_cells(line)) probe = QTextCursor(terminal.document()) probe.setPosition(region.position()) probe.movePosition(QTextCursor.MoveOperation.EndOfBlock, QTextCursor.MoveMode.KeepAnchor) - if (probe.selectedText() == text + if (not wrapped and probe.selectedText() == text and not probe.atEnd()): region.setPosition(probe.position() + 1) continue - for run, attr in self._runs(self._visible_cells(line)): + for run, attr in self._runs(cells): region.insertText(run, self._char_format(attr)) - region.insertText("\n", QTextCharFormat()) + if not wrapped: + region.insertText("\n", QTextCharFormat()) cell_rows = [self._visible_cells(line) for line in screen.lines] # カーソルの行は、カーソルの桁まで空白を残す。プロンプト末尾の diff --git a/tests/test_terminal_screen.py b/tests/test_terminal_screen.py index 1915534..019d50d 100644 --- a/tests/test_terminal_screen.py +++ b/tests/test_terminal_screen.py @@ -369,6 +369,40 @@ def test_shrinking_rows_saves_written_lines_to_history(self): self.assertEqual(s.text()[0], "l14") self.assertEqual((s.cursor_row, s.cursor_col), (9, 3)) + def test_narrowing_then_widening_puts_the_line_back(self): + """窓を戻したら元どおりに繋がること。 + + 折り返しで続いている行と、機器が改行を送った切れ目とを + 区別していないと、縮めたときに刻まれたまま戻らない。 + 実機試験で ls /etc/ の出力が崩れたのがこれ。 + """ + line = "x" * 100 + s = Screen(24, 120) + feed(s, line) + s.set_size(24, 40) + self.assertEqual(s.text()[:3], ["x" * 40, "x" * 40, "x" * 20]) + s.set_size(24, 120) + self.assertEqual(s.text()[0], line) + self.assertEqual(s.text()[1], "") + + def test_a_real_line_break_is_never_joined(self): + """機器が送った改行は、広げても繋がないこと。""" + s = Screen(24, 40) + feed(s, "first\r\nsecond") + s.set_size(24, 120) + self.assertEqual(s.text()[:2], ["first", "second"]) + + def test_columns_survive_a_round_trip(self): + """桁で並んだ出力 (ls の多段組) が往復で崩れないこと。""" + rows = ["a.conf".ljust(20) + "b.conf".ljust(20) + "c.conf", + "d.conf".ljust(20) + "e.conf".ljust(20) + "f.conf"] + s = Screen(24, 80) + feed(s, "\r\n".join(rows)) + before = s.text() + s.set_size(24, 30) + s.set_size(24, 80) + self.assertEqual(s.text(), before) + def test_the_screen_behind_an_alternate_screen_is_not_lost(self): """vi を開いている間に窓を縮めても、裏のシェル画面を捨てないこと。""" s = feed(Screen(), "\r\n".join("shell-%02d" % i for i in range(24))) diff --git a/tests/test_terminal_widget_render.py b/tests/test_terminal_widget_render.py index 979e485..cc110cb 100644 --- a/tests/test_terminal_widget_render.py +++ b/tests/test_terminal_widget_render.py @@ -128,6 +128,27 @@ class ResizeKeepsContentTest(WidgetRenderTest): KEY = "ssh-ed25519 " + "A" * 68 + " user@example.com" + def test_a_narrow_spell_leaves_no_chopped_lines_behind(self): + """窓を戻したら、刻まれた行が残らないこと。 + + 実機試験で「ls /etc/ のあと窓を縮めて戻すと表示が崩れ、 + 以後のコマンドにも残る」と報告された。狭い間に履歴へ流れた + 行も繋がっていなければならない。 + """ + long_line = "x" * 100 + w = self.widget(24, 40) + w.append_output("dev", "\r\n".join([long_line] * 30) + "\r\n$ ") + self.assertGreater(len(w._terminals["dev"]._screen.history), 0, + "履歴へ流れていない (前提が崩れている)") + w._grid_size = lambda t: (24, 100) + w._apply_grid_size("dev") + + printed = [l for l in self.screen_text(w).split("\n") if l.strip()] + chopped = [l for l in printed + if l != long_line and l.rstrip() != "$"] + self.assertEqual(chopped, [], "刻まれた行が残っている") + self.assertEqual(printed.count(long_line), 30) + def test_a_long_line_survives_repeated_shrinking(self): w = self.widget(24, 120) w.append_output("dev", "$ cat ~/.ssh/authorized_keys\r\n" From 73e5081f9af86f7afc5b9bc05537c3b143caf639 Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:12:45 +0900 Subject: [PATCH 20/29] fix(terminal): stop re-splitting lines when the window resizes Testing on real gear showed output being eaten one piece per resize cycle: shrink to the minimum and restore, and the tail of a wrapped line was gone; do it again and more went, until only the first 20 characters - one minimum-width row - were left. The cause was reflow itself. Recomputing every line break on each resize means the document the renderer had already settled no longer lines up with what the model hands over, and each pass shaved a bit more off. Chasing that with a smarter renderer only moved the seam. So the model no longer re-splits. A resize widens short rows to the new width and never truncates or re-wraps anything already written - which is what real terminals do, and what this app did before v1.2.0. Rows may now be longer than the screen is wide; erasing uses each row's own length, and the view wraps long lines for display. Lines that must leave a shrinking screen still go to history rather than being dropped, including the main screen parked behind vi. Verified against the lab box: five shrink-to-minimum-and-back cycles leave the document byte-identical, where before the first cycle already lost the end of the key. Co-Authored-By: Claude Opus 5 --- src/core/terminal/screen.py | 155 ++++++++++----------------- src/ui/terminal_widget.py | 8 +- tests/test_terminal_screen.py | 41 ++++--- tests/test_terminal_widget_render.py | 36 ++++--- 4 files changed, 118 insertions(+), 122 deletions(-) diff --git a/src/core/terminal/screen.py b/src/core/terminal/screen.py index 82b6a62..4f48a86 100644 --- a/src/core/terminal/screen.py +++ b/src/core/terminal/screen.py @@ -45,6 +45,7 @@ def reset(self): # 繋ぎ直せず、出力が刻まれたまま残る self.wrapped = [False] * self.rows self._other_wrapped = [False] * self.rows + self._reflowed = False self.alt_active = False self.cursor_row = 0 self.cursor_col = 0 @@ -102,6 +103,16 @@ def take_dirty(self): self.dirty = set() return dirty + def take_reflowed(self): + """前回の描画のあとに組み直しが起きたかを返して忘れる。 + + 組み直すと行の切れ目が全部変わるので、描画側が持っている + 「前回描いた内容」との突き合わせが当てにならなくなる。 + """ + reflowed = self._reflowed + self._reflowed = False + return reflowed + def take_responses(self): """機器へ送り返すべき応答 (DSR/DA) を返して忘れる。""" responses = self.responses @@ -109,118 +120,67 @@ def take_responses(self): return responses def set_size(self, rows, cols): - """端末の大きさを変える。書かれた内容は一文字も捨てない。 + """端末の大きさを変える。書かれた行には触らない。 - 桁が狭くなったら、はみ出す分は切らずに次の行へ送る (組み直し)。 - 切り捨てると、窓を縮めただけで出力の末尾が永久に失われる。 - 行が足りなくなった分は上から履歴へ送る。 + 桁が広がったら足りない分を空白で埋めるだけ。狭くなっても + 切らないし、割り直しもしない。割り直すと窓を往復するたびに + 行の切れ目が変わり、そのたびに中身が削れていった。実端末も + 既に書かれた行は組み直さない。狭いときの見た目は表示側が + 折り返して面倒を見る。 - 代替画面 (vi 等) の中身だけは組み直さない。アプリはサイズ変更を - 受けて描き直すので、組み直しても上書きされるだけ。ただし裏へ - 退避しているメイン画面は、見えていなくても記録なので守る。 + 行数が減ったぶんは、まず下の空行を捨て、足りなければ上の行を + 履歴へ送る (書かれた行を黙って消さないため)。 """ if (rows, cols) == (self.rows, self.cols) or rows < 1 or cols < 1: return - main = self._other if self.alt_active else self.lines - alt = self.lines if self.alt_active else self._other + for line in self.lines + self._other: + if len(line) < cols: + line.extend([BLANK] * (cols - len(line))) + # メイン画面は記録なので、あふれたら履歴へ送る。代替画面 + # (vi 等) はアプリが描き直すので、切っても構わない if self.alt_active: - cursor = self._saved_main[:2] if self._saved_main else (0, 0) - marks = self._other_wrapped + main, main_marks = self._other, self._other_wrapped + alt, alt_marks = self.lines, self.wrapped + keep_row = (self._saved_main[0] if self._saved_main else 0) else: - cursor = (self.cursor_row, self.cursor_col) - marks = self.wrapped - reflowed, marks, cursor = self._reflow(main, marks, cols, cursor) - - # 入りきらない分は、まず下の空行から捨てる。中身が無いので - # 捨てても記録は減らない。それでも余るときだけ上を履歴へ送る - while (len(reflowed) > rows and cursor[0] < len(reflowed) - 1 - and all(c == BLANK for c in reflowed[-1])): - reflowed.pop() - marks.pop() - while len(reflowed) > rows: - removed = reflowed.pop(0) - self.history.append(removed) - self._new_history.append((removed, marks.pop(0))) - cursor = (max(0, cursor[0] - 1), cursor[1]) - while len(reflowed) < rows: - reflowed.append([BLANK] * cols) - marks.append(False) + main, main_marks = self.lines, self.wrapped + alt, alt_marks = self._other, self._other_wrapped + keep_row = self.cursor_row + + while len(main) > rows: + if keep_row < len(main) - 1 and all(c == BLANK for c in main[-1]): + main.pop() + main_marks.pop() + else: + self.history.append(main.pop(0)) + self._new_history.append((self.history[-1], + main_marks.pop(0))) + keep_row = max(0, keep_row - 1) + while len(main) < rows: + main.append([BLANK] * cols) + main_marks.append(False) + if self.alt_active: + if self._saved_main: + self._saved_main = (min(keep_row, rows - 1), + min(self._saved_main[1], cols - 1), + self._saved_main[2]) + else: + self.cursor_row = keep_row del alt[rows:] - for line in alt: - del line[cols:] - line.extend([BLANK] * (cols - len(line))) + del alt_marks[rows:] while len(alt) < rows: alt.append([BLANK] * cols) + alt_marks.append(False) - if self.alt_active: - self._other[:] = reflowed - self._other_wrapped = marks - self.wrapped = [False] * rows # 代替画面は組み直さない - if self._saved_main: - self._saved_main = (min(cursor[0], rows - 1), - min(cursor[1], cols - 1), - self._saved_main[2]) - else: - self.lines[:] = reflowed - self.wrapped = marks - self.cursor_row = min(cursor[0], rows - 1) - self.cursor_col = min(cursor[1], cols - 1) self.rows, self.cols = rows, cols self.scroll_top, self.scroll_bottom = 0, rows - 1 self.cursor_row = min(self.cursor_row, rows - 1) self.cursor_col = min(self.cursor_col, cols - 1) self._pending_wrap = False self.dirty = set(range(rows)) - - def _reflow(self, lines, wrapped, cols, cursor): - """行の並びを cols 幅で組み直す。 - - 折り返しで続いている行はいったん繋いでから割り直す。繋がずに - 割るだけだと、窓を縮めて戻したときに刻まれたまま残る - (ls /etc/ のような出力が実機試験で崩れた)。 - - (新しい行, 新しい印, 新しいカーソル) を返す。 - """ - cursor_row, cursor_col = cursor - out, out_wrapped = [], [] - new_cursor = (0, 0) - r = 0 - while r < len(lines): - # 続いている行をひとかたまり (論理行) にする - cells, offset, hit = [], None, False - while True: - row = list(lines[r]) - end = len(row) - while end and row[end - 1] == BLANK: - end -= 1 - if r == cursor_row: - end = max(end, cursor_col) # カーソル手前の空白は残す - offset = len(cells) + cursor_col - hit = True - if r < len(wrapped) and wrapped[r]: - end = len(row) # 続くなら右端まで中身 - cells.extend(row[:end]) - if not (r < len(wrapped) and wrapped[r] - and r + 1 < len(lines)): - break - r += 1 - r += 1 - - first = len(out) - if not cells: - out.append([BLANK] * cols) - out_wrapped.append(False) - else: - for i in range(0, len(cells), cols): - chunk = cells[i:i + cols] - chunk.extend([BLANK] * (cols - len(chunk))) - out.append(chunk) - out_wrapped.append(i + cols < len(cells)) - if hit: - new_cursor = (first + offset // cols, offset % cols) - return out, out_wrapped, new_cursor + self._reflowed = True # ---- 印字と C0 ------------------------------------------------- @@ -484,12 +444,14 @@ def _erase_display(self, mode): def _erase_line(self, mode): line = self.lines[self.cursor_row] + # 行は桁より長いことがある (窓を縮めても切らないため)。 + # 消すときは行の実際の長さで見る if mode == 0: - rng = range(self.cursor_col, self.cols) + rng = range(self.cursor_col, len(line)) elif mode == 1: rng = range(0, self.cursor_col + 1) else: - rng = range(0, self.cols) + rng = range(0, len(line)) for c in rng: line[c] = BLANK if mode != 1: # 行末まで消したら続きは無い @@ -530,7 +492,8 @@ def _shift_chars(self, n, insert): def _erase_chars(self, n): line = self.lines[self.cursor_row] - for c in range(self.cursor_col, min(self.cols, self.cursor_col + n)): + for c in range(self.cursor_col, + min(len(line), self.cursor_col + n)): line[c] = BLANK self.dirty.add(self.cursor_row) self._pending_wrap = False diff --git a/src/ui/terminal_widget.py b/src/ui/terminal_widget.py index 0e24f18..7de7329 100644 --- a/src/ui/terminal_widget.py +++ b/src/ui/terminal_widget.py @@ -712,6 +712,11 @@ def _render_screen(self, terminal: QTextEdit) -> None: screen = terminal._screen region = terminal._region + # 組み直しの直後は、文書に残っている「今の画面」が組み直し前の + # ものなので、内容が一致しても同じ行とは限らない。境目を進める + # 近道は使えない (使うと押し出された行が書かれずに消える) + reflowed = screen.take_reflowed() + for line, wrapped in screen.take_new_history(): # 折り返しで続いている行は、改行で切らずに次の行と繋げる。 # 切ると、窓を縮めている間に流れた出力が刻まれたまま記録に @@ -726,7 +731,8 @@ def _render_screen(self, terminal: QTextEdit) -> None: probe.setPosition(region.position()) probe.movePosition(QTextCursor.MoveOperation.EndOfBlock, QTextCursor.MoveMode.KeepAnchor) - if (not wrapped and probe.selectedText() == text + if (not reflowed and not wrapped + and probe.selectedText() == text and not probe.atEnd()): region.setPosition(probe.position() + 1) continue diff --git a/tests/test_terminal_screen.py b/tests/test_terminal_screen.py index 019d50d..908f784 100644 --- a/tests/test_terminal_screen.py +++ b/tests/test_terminal_screen.py @@ -336,16 +336,32 @@ def test_wider_lines_keep_their_content(self): self.assertEqual(s.text()[0], "show version") self.assertEqual(len(s.lines[0]), 132) - def test_narrowing_wraps_instead_of_cutting(self): - """桁が狭くなっても一文字も捨てないこと。 + def test_narrowing_leaves_a_written_line_alone(self): + """桁が狭くなっても、書かれた行には触らないこと。 - 以前は del line[cols:] で切り捨てており、窓を縮めるだけで - 出力の末尾が永久に失われていた (実機試験で報告された)。 - 長い鍵や show 出力を読んでいる最中に窓を縮めると消える。 + 切り捨てると窓を縮めただけで末尾が永久に失われ、割り直すと + 窓を往復するたびに切れ目が変わって中身が削れていった + (どちらも実機試験で出た)。実端末も書かれた行は組み直さない。 + 狭いときの見た目は表示側が折り返して面倒を見る。 """ s = feed(Screen(), "0123456789") s.set_size(24, 8) - self.assertEqual(s.text()[:2], ["01234567", "89"]) + self.assertEqual(s.text()[0], "0123456789") + + def test_a_round_trip_changes_nothing(self): + """縮めて戻す、を繰り返しても中身が一文字も変わらないこと。 + + 実機試験で「最小→戻すを繰り返すたびに 1 行ずつ消えていく」と + 報告された。組み直しをやめたので、往復は何もしないのと同じ。 + """ + key = "ssh-ed25519 " + "A" * 68 + " user@example.com" + s = Screen(24, 120) + feed(s, "$ cat ~/.ssh/authorized_keys\r\n" + key + "\r\n$ ") + before = s.text() + for _ in range(5): + s.set_size(5, 20) + s.set_size(24, 120) + self.assertEqual(s.text(), before) def test_repeated_narrowing_still_keeps_everything(self): key = "ssh-ed25519 " + "A" * 68 + " user@example.com" @@ -369,21 +385,20 @@ def test_shrinking_rows_saves_written_lines_to_history(self): self.assertEqual(s.text()[0], "l14") self.assertEqual((s.cursor_row, s.cursor_col), (9, 3)) - def test_narrowing_then_widening_puts_the_line_back(self): - """窓を戻したら元どおりに繋がること。 + def test_a_long_line_stays_one_line_through_a_round_trip(self): + """長い行は、縮めても刻まれず、戻しても元のままであること。 - 折り返しで続いている行と、機器が改行を送った切れ目とを - 区別していないと、縮めたときに刻まれたまま戻らない。 - 実機試験で ls /etc/ の出力が崩れたのがこれ。 + 実機試験で ls /etc/ の出力が刻まれたまま戻らなくなった。 + 刻まないので、戻す必要も無い。 """ line = "x" * 100 s = Screen(24, 120) feed(s, line) s.set_size(24, 40) - self.assertEqual(s.text()[:3], ["x" * 40, "x" * 40, "x" * 20]) - s.set_size(24, 120) self.assertEqual(s.text()[0], line) self.assertEqual(s.text()[1], "") + s.set_size(24, 120) + self.assertEqual(s.text()[0], line) def test_a_real_line_break_is_never_joined(self): """機器が送った改行は、広げても繋がないこと。""" diff --git a/tests/test_terminal_widget_render.py b/tests/test_terminal_widget_render.py index cc110cb..40b3ca0 100644 --- a/tests/test_terminal_widget_render.py +++ b/tests/test_terminal_widget_render.py @@ -128,26 +128,38 @@ class ResizeKeepsContentTest(WidgetRenderTest): KEY = "ssh-ed25519 " + "A" * 68 + " user@example.com" - def test_a_narrow_spell_leaves_no_chopped_lines_behind(self): - """窓を戻したら、刻まれた行が残らないこと。 + def test_output_printed_wide_survives_a_narrow_spell(self): + """広い窓で出た行は、縮めて戻しても元のまま見えること。 - 実機試験で「ls /etc/ のあと窓を縮めて戻すと表示が崩れ、 - 以後のコマンドにも残る」と報告された。狭い間に履歴へ流れた - 行も繋がっていなければならない。 + 実機試験で「ls /etc/ のあと窓を縮めて戻すと崩れ、以後の + コマンドにも残る」と報告された。組み直しをやめたので、 + 書かれた行は窓の大きさに関係なくそのまま。 + """ + long_line = "x" * 100 + w = self.widget(24, 110) + w.append_output("dev", "\r\n".join([long_line] * 5) + "\r\n$ ") + # カーソルより下の空行は何も持たないので比較から外す + before = self.screen_text(w).rstrip("\n") + for rows, cols in ((5, 20), (24, 110)): + w._grid_size = lambda t, r=rows, c=cols: (r, c) + w._apply_grid_size("dev") + self.assertEqual(self.screen_text(w).rstrip("\n"), before) + + def test_a_narrow_spell_loses_no_characters(self): + """狭い窓で出た行は、折り返しはそのままでも欠けないこと。 + + 実端末と同じで、既に書かれた行は組み直さない。ただし + 一文字も失ってはいけない。 """ long_line = "x" * 100 w = self.widget(24, 40) w.append_output("dev", "\r\n".join([long_line] * 30) + "\r\n$ ") - self.assertGreater(len(w._terminals["dev"]._screen.history), 0, - "履歴へ流れていない (前提が崩れている)") w._grid_size = lambda t: (24, 100) w._apply_grid_size("dev") - printed = [l for l in self.screen_text(w).split("\n") if l.strip()] - chopped = [l for l in printed - if l != long_line and l.rstrip() != "$"] - self.assertEqual(chopped, [], "刻まれた行が残っている") - self.assertEqual(printed.count(long_line), 30) + body = "".join(l for l in self.screen_text(w).split("\n") + if l.strip() and l.rstrip() != "$") + self.assertEqual(body.count("x"), 100 * 30) def test_a_long_line_survives_repeated_shrinking(self): w = self.widget(24, 120) From c92e02d4292146f7e7f4a0d4b14d9b9b521c072a Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:23:14 +0900 Subject: [PATCH 21/29] feat(ui): show the terminal size in the status bar Working out why output vanished on resize took several rounds partly because nobody could say how wide the window had been at the time. The size the app reports to the device is now visible while you work. It sits in the status bar's permanent area, so it never competes with the transient showMessage notices, and it follows the visible tab rather than whichever terminal happened to resize last. Co-Authored-By: Claude Opus 5 --- src/ui/main_window.py | 20 ++++++++ tests/test_status_bar_terminal_size.py | 64 ++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 tests/test_status_bar_terminal_size.py diff --git a/src/ui/main_window.py b/src/ui/main_window.py index 49a7007..f3f951c 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -369,9 +369,25 @@ def _create_main_widget(self): def _create_status_bar(self): """ステータスバー作成""" + from PyQt6.QtWidgets import QLabel + self.status_bar = QStatusBar() self.setStatusBar(self.status_bar) + # 端末の大きさ。右端の常設欄に置くので、showMessage の + # 一時メッセージとは場所を取り合わない + self.terminal_size_label = QLabel("") + self.terminal_size_label.setToolTip( + "いま機器へ伝えている端末の大きさ(桁×行)") + self.status_bar.addPermanentWidget(self.terminal_size_label) self.status_bar.showMessage("準備完了") + + def _show_terminal_size(self, device_name: str) -> None: + """表示中のターミナルの大きさを、ステータスバーへ出す。""" + if not device_name: + self.terminal_size_label.setText("") + return + cols, rows = self.terminal_widget.grid_size_for(device_name) + self.terminal_size_label.setText("%d x %d" % (cols, rows)) def _load_devices(self): """設定ファイルから接続先リストを読み込み""" @@ -858,6 +874,8 @@ def _on_terminal_tab_changed(self, device_name: str) -> None: # いま見ている機器に SFTP が無いなら、前の機器のものを残さない self.sftp_panel.clear() + self._show_terminal_size(device_name) + def _find_group_of_device(self, device_name: str): """機器名から所属グループを返す(見つからなければNone)""" for group in self.config_manager.get_groups(): @@ -974,6 +992,8 @@ def _on_terminal_resized(self, device_name: str, cols: int, rows: int): conn = self.connections.get(device_name) if conn is not None and hasattr(conn, "set_terminal_size"): conn.set_terminal_size(cols, rows) + if device_name == self.terminal_widget.get_current_tab_name(): + self.terminal_size_label.setText("%d x %d" % (cols, rows)) def _on_tab_closed(self, device_name: str): """ diff --git a/tests/test_status_bar_terminal_size.py b/tests/test_status_bar_terminal_size.py new file mode 100644 index 0000000..d456b2b --- /dev/null +++ b/tests/test_status_bar_terminal_size.py @@ -0,0 +1,64 @@ +"""ステータスバーに端末の大きさを出す。 + +不具合の切り分けで「そのとき窓が何桁だったか」が分からず往復した +ので、いま機器へ伝えている大きさをその場で見えるようにした。 + +一時メッセージ (showMessage) とは場所を取り合わないこと、他の表示を +壊さないことを確かめる。 +""" +import os +import sys +import unittest + +sys.path.insert(0, "src") + + +class TerminalSizeStatusTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PyQt6.QtWidgets import QApplication + cls.app = QApplication.instance() or QApplication([]) + + def window(self): + from ui.main_window import MainWindow + return MainWindow() + + def test_the_label_lives_in_the_permanent_area(self): + """常設欄に置くこと。showMessage と場所を取り合わない。""" + w = self.window() + w.status_bar.showMessage("接続しました") + w._show_terminal_size("") + self.assertEqual(w.status_bar.currentMessage(), "接続しました") + self.assertIsNotNone(w.terminal_size_label.parent()) + + def test_nothing_is_shown_without_a_terminal(self): + w = self.window() + w._show_terminal_size("") + self.assertEqual(w.terminal_size_label.text(), "") + + def test_the_size_appears_for_an_open_terminal(self): + w = self.window() + w.terminal_widget.create_terminal_tab("dev") + w._show_terminal_size("dev") + cols, rows = w.terminal_widget.grid_size_for("dev") + self.assertEqual(w.terminal_size_label.text(), + "%d x %d" % (cols, rows)) + + def test_a_resize_updates_the_shown_size(self): + w = self.window() + w.terminal_widget.create_terminal_tab("dev") + w._on_terminal_resized("dev", 132, 43) + self.assertEqual(w.terminal_size_label.text(), "132 x 43") + + def test_a_resize_of_a_hidden_tab_does_not_overwrite_it(self): + """見えていないタブの大きさで、表示中のものを上書きしないこと。""" + w = self.window() + w.terminal_widget.create_terminal_tab("shown") + w._on_terminal_resized("shown", 100, 30) + w._on_terminal_resized("other", 40, 10) + self.assertEqual(w.terminal_size_label.text(), "100 x 30") + + +if __name__ == "__main__": + unittest.main() From b3b7c9d1747fb9c34fd190cf4f09b0d7bf3ebc05 Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:28:08 +0900 Subject: [PATCH 22/29] feat(terminal): let terminal tabs be dragged into a new order Requested while testing. The tool-area tabs already work this way; terminal tabs did not. Nothing had to change underneath: a tab is matched to its device by name and widget, and every index in this file is read at the moment of the event it belongs to, never stored. Tests cover what a reorder could plausibly break - output reaching the right terminal, closing the tab you actually clicked, the current-tab name, and reconnection reusing the same tab from its new position. All three mutations (disabling the move, closing by a fixed index, matching a tab without checking its name) are caught. Co-Authored-By: Claude Opus 5 --- src/ui/terminal_widget.py | 3 ++ tests/test_terminal_tab_reorder.py | 74 ++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 tests/test_terminal_tab_reorder.py diff --git a/src/ui/terminal_widget.py b/src/ui/terminal_widget.py index 7de7329..00baeb8 100644 --- a/src/ui/terminal_widget.py +++ b/src/ui/terminal_widget.py @@ -379,6 +379,9 @@ def _create_ui(self): # タブウィジェット self.tab_widget = QTabWidget() self.tab_widget.setTabsClosable(True) + # 横ドラッグで並べ替えられる。機器の対応付けはタブ名と + # ウィジェットで持っていて、位置には依存しない + self.tab_widget.setMovable(True) self.tab_widget.tabCloseRequested.connect(self._close_tab) self.tab_widget.currentChanged.connect(self._on_current_tab_changed) diff --git a/tests/test_terminal_tab_reorder.py b/tests/test_terminal_tab_reorder.py new file mode 100644 index 0000000..6fe5792 --- /dev/null +++ b/tests/test_terminal_tab_reorder.py @@ -0,0 +1,74 @@ +"""ターミナルのタブを並べ替えられること。 + +機器との対応付けはタブ名とウィジェットで持っており、タブの位置には +依存しない。並べ替えたあとでも、閉じる・切り替える・出力を流すが +正しい機器に当たることを確かめる。 +""" +import os +import sys +import unittest + +sys.path.insert(0, "src") + + +class TabReorderTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PyQt6.QtWidgets import QApplication + cls.app = QApplication.instance() or QApplication([]) + + def widget_with_tabs(self, *names): + from ui.terminal_widget import TerminalWidget + w = TerminalWidget() + for name in names: + w.create_terminal_tab(name) + return w + + def tab_order(self, w): + return [w.tab_widget.tabText(i) for i in range(w.tab_widget.count())] + + def test_the_tab_bar_allows_dragging(self): + w = self.widget_with_tabs("a") + self.assertTrue(w.tab_widget.isMovable()) + + def test_reordering_moves_the_tab(self): + w = self.widget_with_tabs("rtr1", "rtr2", "rtr3") + w.tab_widget.tabBar().moveTab(2, 0) + self.assertEqual(self.tab_order(w), ["rtr3", "rtr1", "rtr2"]) + + def test_output_still_reaches_the_right_terminal(self): + w = self.widget_with_tabs("rtr1", "rtr2", "rtr3") + w.tab_widget.tabBar().moveTab(2, 0) + w.append_output("rtr2", "hostname rtr2") + self.assertIn("hostname rtr2", w._terminals["rtr2"].toPlainText()) + self.assertNotIn("hostname rtr2", w._terminals["rtr3"].toPlainText()) + + def test_closing_after_a_reorder_closes_the_one_clicked(self): + w = self.widget_with_tabs("rtr1", "rtr2", "rtr3") + w.tab_widget.tabBar().moveTab(2, 0) # rtr3, rtr1, rtr2 + closed = [] + w.tab_closed.connect(closed.append) + w._close_tab(0) # 先頭 = rtr3 + self.assertEqual(closed, ["rtr3"]) + self.assertEqual(self.tab_order(w), ["rtr1", "rtr2"]) + self.assertNotIn("rtr3", w._terminals) + + def test_the_current_tab_name_follows_the_new_order(self): + w = self.widget_with_tabs("rtr1", "rtr2", "rtr3") + w.tab_widget.tabBar().moveTab(2, 0) + w.tab_widget.setCurrentIndex(0) + self.assertEqual(w.get_current_tab_name(), "rtr3") + + def test_reusing_a_tab_finds_it_wherever_it_sits(self): + """再接続はタブ名で探すので、位置が変わっても同じタブを使う。""" + w = self.widget_with_tabs("rtr1", "rtr2", "rtr3") + w.tab_widget.tabBar().moveTab(0, 2) # rtr2, rtr3, rtr1 + before = w._terminals["rtr1"] + again = w.create_terminal_tab("rtr1") + self.assertIs(again, before) + self.assertEqual(w.tab_widget.count(), 3) + + +if __name__ == "__main__": + unittest.main() From b8409cf767fe223362e742ffc058d783cdc4f43f Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:39:36 +0900 Subject: [PATCH 23/29] fix(terminal): do not pad a line that wraps into the next one Widening the window put a block of spaces in the middle of a key: the two halves of the wrapped line rejoined with the gap between them, because widening had padded the first row out to the new width and the padding was carried into the join. A wrapped row's length is the record of where it wrapped, so a resize now leaves it alone and only pads rows that end on their own. Rows can therefore be shorter than the screen is wide, and printing past the end of one grows it on demand. Co-Authored-By: Claude Opus 5 --- src/core/terminal/screen.py | 14 ++++++++++--- tests/test_terminal_screen.py | 31 ++++++++++++++++++++++++++++ tests/test_terminal_widget_render.py | 17 +++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/core/terminal/screen.py b/src/core/terminal/screen.py index 4f48a86..a5d1238 100644 --- a/src/core/terminal/screen.py +++ b/src/core/terminal/screen.py @@ -133,9 +133,14 @@ def set_size(self, rows, cols): """ if (rows, cols) == (self.rows, self.cols) or rows < 1 or cols < 1: return - for line in self.lines + self._other: - if len(line) < cols: - line.extend([BLANK] * (cols - len(line))) + # 折り返しで次へ続く行は埋めない。行の長さがそのまま「どこで + # 折り返したか」なので、埋めると繋いだときに埋め草ぶんの隙間が + # 開く (窓を広げると鍵の途中に空白が入る、として報告された) + for lines, marks in ((self.lines, self.wrapped), + (self._other, self._other_wrapped)): + for r, line in enumerate(lines): + if len(line) < cols and not (r < len(marks) and marks[r]): + line.extend([BLANK] * (cols - len(line))) # メイン画面は記録なので、あふれたら履歴へ送る。代替画面 # (vi 等) はアプリが描き直すので、切っても構わない @@ -194,6 +199,9 @@ def _print(self, text): if self._g[self._charset] == "0": ch = DEC_GRAPHICS.get(ch, ch) line = self.lines[self.cursor_row] + if self.cursor_col >= len(line): + # 折り返し行は埋めていないので、書くときに伸ばす + line.extend([BLANK] * (self.cursor_col + 1 - len(line))) line[self.cursor_col] = (ch, self.attr) self.dirty.add(self.cursor_row) if self.cursor_col + 1 < self.cols: diff --git a/tests/test_terminal_screen.py b/tests/test_terminal_screen.py index 908f784..958b5e7 100644 --- a/tests/test_terminal_screen.py +++ b/tests/test_terminal_screen.py @@ -400,6 +400,37 @@ def test_a_long_line_stays_one_line_through_a_round_trip(self): s.set_size(24, 120) self.assertEqual(s.text()[0], line) + def test_widening_does_not_pad_a_wrapped_line(self): + """折り返し行を、広げたときに空白で埋めないこと。 + + 行の長さがそのまま「どこで折り返したか」なので、埋めると + 繋いだときに埋め草ぶんの隙間が開く。実機試験で、窓を広げると + 鍵の途中に空白が入ると報告された。 + """ + line = "x" * 100 + s = Screen(24, 73) + feed(s, line) + self.assertTrue(s.wrapped[0]) + s.set_size(24, 120) + self.assertEqual(len(s.lines[0]), 73, "折り返し行が埋められた") + joined = ("".join(c[0] for c in s.lines[0]) + + "".join(c[0] for c in s.lines[1]).rstrip()) + self.assertEqual(joined, line) + + def test_a_short_line_is_still_padded(self): + """折り返していない行は、これまでどおり広げること。""" + s = feed(Screen(24, 40), "short") + s.set_size(24, 100) + self.assertEqual(len(s.lines[0]), 100) + + def test_writing_past_the_end_of_a_wrapped_line_still_works(self): + """埋めていない行の先へ書いても落ちないこと。""" + s = Screen(24, 40) + feed(s, "y" * 60) # row0 は 40 セルのまま折り返す + s.set_size(24, 100) + feed(s, "\x1b[1;90HZ") # 90 桁目へ書く + self.assertEqual(s.text()[0][89], "Z") + def test_a_real_line_break_is_never_joined(self): """機器が送った改行は、広げても繋がないこと。""" s = Screen(24, 40) diff --git a/tests/test_terminal_widget_render.py b/tests/test_terminal_widget_render.py index 40b3ca0..d75a49e 100644 --- a/tests/test_terminal_widget_render.py +++ b/tests/test_terminal_widget_render.py @@ -145,6 +145,23 @@ def test_output_printed_wide_survives_a_narrow_spell(self): w._apply_grid_size("dev") self.assertEqual(self.screen_text(w).rstrip("\n"), before) + def test_a_wrapped_line_rejoins_without_a_gap(self): + """折り返した行が履歴へ入るとき、隙間なく繋がること。 + + 実機試験で、窓を広げてから流すと鍵の途中に空白の塊が + 入ると報告された。広げたときに折り返し行を埋めていたのが原因。 + """ + line = "x" * 100 + w = self.widget(24, 73) + w.append_output("dev", line + "\r\n") + w._grid_size = lambda t: (24, 120) + w._apply_grid_size("dev") + w.append_output("dev", "\r\n".join("f-%02d" % i for i in range(30))) + + found = [l for l in self.screen_text(w).split("\n") + if l.startswith("x")] + self.assertEqual(found, [line]) + def test_a_narrow_spell_loses_no_characters(self): """狭い窓で出た行は、折り返しはそのままでも欠けないこと。 From 12a3b2bf6720cffffec8eadd5fb9fe8bcfa318f4 Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:52:32 +0900 Subject: [PATCH 24/29] feat(ui): let the device list be hidden like the tool area Requested while testing. The tool area could already be collapsed by its splitter handle, hidden from its tab bar's context menu, and toggled from the View menu; the device list had none of that. It now has the same three ways in and out. Every device-tree context menu carries the entry - on a device, on a group, and on empty space - so it does not matter where the click lands. The console group's menu used to return early and show nothing at all; it now shows at least this. Bringing it back gives it a width again: a list restored after being dragged to zero would otherwise come back invisible and read as "it won't come back". The terminal stays non-collapsible, being the part of the window there is no point in hiding. Co-Authored-By: Claude Opus 5 --- src/ui/device_tree.py | 55 ++++++++++------ src/ui/main_window.py | 29 ++++++++- tests/test_device_list_hiding.py | 107 +++++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 21 deletions(-) create mode 100644 tests/test_device_list_hiding.py diff --git a/src/ui/device_tree.py b/src/ui/device_tree.py index f05dec5..c2d76b5 100644 --- a/src/ui/device_tree.py +++ b/src/ui/device_tree.py @@ -18,6 +18,7 @@ class DeviceTree(QWidget): group_add_requested = pyqtSignal() # グループ追加要求 group_edit_requested = pyqtSignal(str) # グループ編集要求(グループ名) group_delete_requested = pyqtSignal(str) # グループ削除要求(グループ名) + hide_requested = pyqtSignal() # このエリアを隠す要求(戻すのは表示メニュー) # 一般的なボーレート値 BAUD_RATES = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600] @@ -173,10 +174,19 @@ def refresh_serial_ports(self): # 再度追加 self._add_serial_ports_group() + def _add_hide_action(self, menu): + """どの右クリックメニューにも「非表示」を足す。 + + 機器の上・グループの上・空欄のどこで押しても同じように隠せる + ようにする。戻すのは表示メニューから。 + """ + menu.addSeparator() + return menu.addAction("接続先リストを非表示") + def _show_context_menu(self, position): """ 右クリックメニューを表示 - + Args: position: クリック位置 """ @@ -237,11 +247,15 @@ def _show_context_menu(self, position): duplicate_action = menu.addAction("複製") baudrate_actions = [] + hide_action = self._add_hide_action(menu) + # メニュー実行 action = menu.exec(self.tree.viewport().mapToGlobal(position)) - + # アクション処理 - if action == connect_action: + if action == hide_action: + self.hide_requested.emit() + elif action == connect_action: self.device_connect.emit(group_name, device_data) elif action == edit_action and edit_action is not None: self.device_edit.emit(group_name, device_data) @@ -287,11 +301,14 @@ def _show_empty_area_menu(self, position): """ menu = QMenu(self) add_group_action = menu.addAction("グループを追加") - + hide_action = self._add_hide_action(menu) + action = menu.exec(self.tree.viewport().mapToGlobal(position)) - + if action == add_group_action: self.group_add_requested.emit() + elif action == hide_action: + self.hide_requested.emit() def _show_group_menu(self, position, group_item: QTreeWidgetItem): """ @@ -302,22 +319,22 @@ def _show_group_menu(self, position, group_item: QTreeWidgetItem): group_item: グループアイテム """ group_name = group_item.text(0) - - # コンソール接続グループは編集・削除不可 - if group_name == "コンソール接続": - return - menu = QMenu(self) - edit_action = menu.addAction("グループを編集") - - # Defaultグループは削除不可 - delete_action = None - if group_name != "Default": - delete_action = menu.addAction("グループを削除") - + + # コンソール接続グループは編集・削除不可。それでも非表示は出す + edit_action = delete_action = None + if group_name != "コンソール接続": + edit_action = menu.addAction("グループを編集") + # Defaultグループは削除不可 + if group_name != "Default": + delete_action = menu.addAction("グループを削除") + hide_action = self._add_hide_action(menu) + action = menu.exec(self.tree.viewport().mapToGlobal(position)) - - if action == edit_action: + + if action == hide_action: + self.hide_requested.emit() + elif action == edit_action and edit_action is not None: self.group_edit_requested.emit(group_name) elif action == delete_action and delete_action is not None: self.group_delete_requested.emit(group_name) diff --git a/src/ui/main_window.py b/src/ui/main_window.py index f3f951c..0b926da 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -247,6 +247,11 @@ def _create_menu_bar(self): self.snmp_panel_action.triggered.connect(self._toggle_snmp_panel) view_menu.addSeparator() + self.toggle_device_list_action = view_menu.addAction("接続先リスト表示/非表示") + self.toggle_device_list_action.setCheckable(True) + self.toggle_device_list_action.setChecked(True) + self.toggle_device_list_action.triggered.connect( + self._toggle_device_list) self.toggle_tool_area_action = view_menu.addAction("ツールエリア表示/非表示") self.toggle_tool_area_action.setCheckable(True) self.toggle_tool_area_action.setChecked(True) @@ -304,6 +309,7 @@ def _create_main_widget(self): self.device_tree.group_add_requested.connect(self._on_add_group) self.device_tree.group_edit_requested.connect(self._on_edit_group) self.device_tree.group_delete_requested.connect(self._on_delete_group) + self.device_tree.hide_requested.connect(self._toggle_device_list) splitter.addWidget(self.device_tree) # 右側: ターミナル @@ -330,9 +336,11 @@ def _create_main_widget(self): self.tool_tabs.tabBar().customContextMenuRequested.connect(self._tool_area_context_menu) splitter.addWidget(self.tool_tabs) splitter.setSizes([250, 650, 300]) - splitter.setCollapsible(0, False) + # 接続先リストとツールエリアはハンドルを引いて畳める。 + # ターミナルは本体なので畳ませない + splitter.setCollapsible(0, True) splitter.setCollapsible(1, False) - splitter.setCollapsible(2, True) # ツールエリアはハンドルで折り畳み可 + splitter.setCollapsible(2, True) layout.addWidget(splitter) @@ -1504,6 +1512,23 @@ def _toggle_tool_area(self): if hasattr(self, "toggle_tool_area_action"): self.toggle_tool_area_action.setChecked(show) + # 接続先リストを戻すときの幅。畳んだ状態から出すと 0 のままなので、 + # 何も見えず「戻らない」と受け取られる + DEVICE_LIST_WIDTH = 250 + + def _toggle_device_list(self): + """接続先リストの表示/非表示を切り替える。""" + show = self.device_tree.isHidden() + self.device_tree.setVisible(show) + if show: + sizes = self.main_splitter.sizes() + if sizes and sizes[0] < 40: + spare = max(sizes[1] - self.DEVICE_LIST_WIDTH, 100) + self.main_splitter.setSizes( + [self.DEVICE_LIST_WIDTH, spare] + sizes[2:]) + if hasattr(self, "toggle_device_list_action"): + self.toggle_device_list_action.setChecked(show) + def _toggle_sftp_panel(self): """SFTPクライアントパネルの表示/非表示を切り替え""" self._select_tool_tab("sftp") diff --git a/tests/test_device_list_hiding.py b/tests/test_device_list_hiding.py new file mode 100644 index 0000000..e01b56e --- /dev/null +++ b/tests/test_device_list_hiding.py @@ -0,0 +1,107 @@ +"""接続先リストを隠せること。 + +ツールエリアと同じ 3 つの経路を用意する: + - 仕切りをドラッグして畳む + - 右クリックメニューから隠す + - 表示メニューで出し入れする(隠したあと戻す唯一の道) + +隠すのは簡単でも戻せないと困るので、戻したときに幅が 0 のままに +ならないことも見る。 +""" +import os +import sys +import unittest + +sys.path.insert(0, "src") + + +class DeviceListHidingTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PyQt6.QtWidgets import QApplication + cls.app = QApplication.instance() or QApplication([]) + + def window(self): + from ui.main_window import MainWindow + return MainWindow() + + def test_the_handle_can_collapse_the_list(self): + """仕切りをドラッグして畳めること。""" + w = self.window() + self.assertTrue(w.main_splitter.isCollapsible(0)) + + def test_the_terminal_can_never_be_collapsed(self): + """ターミナルは本体なので畳ませないこと。""" + w = self.window() + self.assertFalse(w.main_splitter.isCollapsible(1)) + + def test_the_context_menu_asks_the_window_to_hide_it(self): + """右クリックの「非表示」がウィンドウへ伝わること。""" + w = self.window() + self.assertFalse(w.device_tree.isHidden()) + w.device_tree.hide_requested.emit() + self.assertTrue(w.device_tree.isHidden()) + + def test_the_view_menu_brings_it_back(self): + w = self.window() + w._toggle_device_list() + self.assertTrue(w.device_tree.isHidden()) + self.assertFalse(w.toggle_device_list_action.isChecked()) + w._toggle_device_list() + self.assertFalse(w.device_tree.isHidden()) + self.assertTrue(w.toggle_device_list_action.isChecked()) + + def test_coming_back_from_a_collapsed_handle_has_width(self): + """ハンドルで幅 0 まで畳んだあとでも、戻したら見えること。""" + w = self.window() + sizes = w.main_splitter.sizes() + w.main_splitter.setSizes([0, sum(sizes[:2])] + sizes[2:]) + w._toggle_device_list() # 隠す + w._toggle_device_list() # 戻す + self.assertFalse(w.device_tree.isHidden()) + self.assertGreaterEqual(w.main_splitter.sizes()[0], 100) + + def test_the_tool_area_still_toggles_on_its_own(self): + """ツールエリアの表示切替を巻き込んでいないこと。""" + w = self.window() + w._toggle_device_list() + self.assertFalse(w.tool_tabs.isHidden()) + w._toggle_tool_area() + self.assertTrue(w.tool_tabs.isHidden()) + self.assertTrue(w.device_tree.isHidden()) + + +class DeviceTreeMenuTest(unittest.TestCase): + """どの右クリックメニューにも非表示が出ること。""" + + @classmethod + def setUpClass(cls): + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PyQt6.QtWidgets import QApplication + cls.app = QApplication.instance() or QApplication([]) + + def test_the_hide_entry_is_appended_to_any_menu(self): + from PyQt6.QtWidgets import QMenu + from ui.device_tree import DeviceTree + tree = DeviceTree() + menu = QMenu() + action = tree._add_hide_action(menu) + self.assertEqual(action.text(), "接続先リストを非表示") + self.assertIn(action, menu.actions()) + + def test_the_console_group_menu_is_no_longer_empty(self): + """コンソール接続グループでも、隠す道は残すこと。 + + 以前は何も出さずに帰っていたので、そこを右クリックすると + メニューが出なかった。 + """ + import inspect + from ui.device_tree import DeviceTree + source = inspect.getsource(DeviceTree._show_group_menu) + self.assertNotIn("return", source.split("コンソール接続")[1][:200], + "コンソール接続で早々に帰っている") + + +if __name__ == "__main__": + unittest.main() From 9c5819192820805392db10c10f298963af6b5899 Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:29:19 +0900 Subject: [PATCH 25/29] fix(terminal): never index a line by the screen's width Two release blockers found by the pre-release audit. Erasing to the start of a line, or deleting characters, crashed the app outright. Since b8409cf a wrapped row is left at its wrap width instead of being padded, so a row can be narrower than the screen - while the cursor may still sit anywhere up to the last column. EL 1, ED 1 and DCH indexed the row by the cursor column and raised IndexError, which takes the whole app down. Every place that touches a row now bounds itself by that row's own length. A sweep of 8316 combinations of sequence, parameter, cursor column and row/width mismatch now completes without an exception. The Catalyst capture was also being stored wrong. With no .gitattributes and core.autocrlf on, git decided the file was text - it contains only CR, LF and BS - and stripped all 147 CRs on the way into the index: 7058 bytes in the working tree, 6911 in the commit. Windows checkouts put the CRs back, so nothing showed locally, but a Linux clone and the Source code archive attached to each release - the GPL corresponding source - carried the broken file, where test_terminal_cisco fails on the column of the ^ marker. Verified by cloning with core.autocrlf=input: 6911 bytes and a failing test before, 7058 bytes and 7 passing after. Co-Authored-By: Claude Opus 5 --- .gitattributes | 9 + src/core/terminal/screen.py | 14 +- tests/fixtures/cisco_ios_vt100.bin | 294 ++++++++++++++--------------- tests/test_terminal_screen.py | 26 +++ 4 files changed, 192 insertions(+), 151 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..bd68ea0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# 実機から採った受信バイトは、1 バイトも変えずに持つ。 +# +# core.autocrlf が有効な環境では、git が中身を見てテキストと判断した +# ファイルの CRLF を LF へ正規化する。cisco_ios_vt100.bin は制御文字が +# CR/LF/BS しか無く平文に近いため、これに巻き込まれて CR が 147 個 +# すべて剥がされていた。作業ツリーでは元へ戻るので気づけず、 +# Linux での clone や、リリースに添付される Source code アーカイブ +# (GPL の対応ソース)だけが壊れた状態で配られていた。 +tests/fixtures/*.bin -text diff --git a/src/core/terminal/screen.py b/src/core/terminal/screen.py index a5d1238..1eefb40 100644 --- a/src/core/terminal/screen.py +++ b/src/core/terminal/screen.py @@ -452,12 +452,14 @@ def _erase_display(self, mode): def _erase_line(self, mode): line = self.lines[self.cursor_row] - # 行は桁より長いことがある (窓を縮めても切らないため)。 - # 消すときは行の実際の長さで見る + # 行の長さは桁数と一致しない。窓を縮めても切らないので長いことが + # あり、折り返しで続く行は広げても埋めないので短いこともある。 + # カーソルは桁数まで動けるので、必ず行の実際の長さで抑える + end = min(self.cursor_col + 1, len(line)) if mode == 0: - rng = range(self.cursor_col, len(line)) + rng = range(min(self.cursor_col, len(line)), len(line)) elif mode == 1: - rng = range(0, self.cursor_col + 1) + rng = range(0, end) else: rng = range(0, len(line)) for c in rng: @@ -488,6 +490,10 @@ def _shift_lines(self, n, insert): def _shift_chars(self, n, insert): """ICH / DCH。行の右端は詰まる・押し出される。""" line = self.lines[self.cursor_row] + # カーソルが行の実際の長さより右にあることがある (折り返しで + # 続く行は広げても埋めないため)。そこで詰めても意味が無い + if self.cursor_col >= len(line): + return for _ in range(n): if insert: line.pop() diff --git a/tests/fixtures/cisco_ios_vt100.bin b/tests/fixtures/cisco_ios_vt100.bin index c5dd841..5d373c8 100644 --- a/tests/fixtures/cisco_ios_vt100.bin +++ b/tests/fixtures/cisco_ios_vt100.bin @@ -1,147 +1,147 @@ - - - -lab-rtr>show version -Cisco IOS XE Software, Version 17.18.02 -Cisco IOS Software [IOSXE], Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 17.18.2, RELEASE SOFTWARE (fc3) -Technical Support: http://www.cisco.com/techsupport -Copyright (c) 1986-2025 by Cisco Systems, Inc. -Compiled Fri 19-Dec-25 03:49 by mcpre - - -Cisco IOS-XE software, Copyright (c) 2005-2025 by cisco Systems, Inc. -All rights reserved. Certain components of Cisco IOS-XE software are -licensed under the GNU General Public License ("GPL") Version 2.0. The -software code licensed under GPL Version 2.0 is free software that comes -with ABSOLUTELY NO WARRANTY. You can redistribute and/or modify such -GPL code under the terms of GPL Version 2.0. For more details, see the -documentation or "License Notice" file accompanying the IOS-XE software, -or the applicable URL provided on the flyer accompanying the IOS-XE -software. - - -ROM: IOS-XE ROMMON - -lab-rtr uptime is 2 days, 11 hours, 43 minutes -Uptime for this control processor is 2 days, 11 hours, 46 minutes - --More--   -lab-rtr>how ip interface brief - ^ -% Invalid input detected at '^' marker. - -lab-rtr>show ip route -Codes: L - local, C - connected, S - static, R - RIP, M - mobile, B - BGP - D - EIGRP, EX - EIGRP external, O - OSPF, IA - OSPF inter area - N1 - OSPF NSSA external type 1, N2 - OSPF NSSA external type 2 - E1 - OSPF external type 1, E2 - OSPF external type 2, m - OMP - n - NAT, Ni - NAT inside, No - NAT outside, Nd - NAT DIA - i - IS-IS, su - IS-IS summary, L1 - IS-IS level-1, L2 - IS-IS level-2 - ia - IS-IS inter area, * - candidate default, U - per-user static route - H - NHRP, G - NHRP registered, g - NHRP registration summary - o - ODR, P - periodic downloaded static route, l - LISP - a - application route - + - replicated route, % - next hop override, p - overrides from PfR - & - replicated local route overrides by connected - -Gateway of last resort is 192.0.2.1 to network 0.0.0.0 - -S* 0.0.0.0/0 [1/0] via 192.0.2.1 - 192.0.2.0/8 is variably subnetted, 3 subnets, 2 masks -C 192.0.2.0/24 is directly connected, GigabitEthernet1 -L 192.0.2.187/32 is directly connected, GigabitEthernet1 -L 192.0.2.234/32 is directly connected, GigabitEthernet1 -lab-rtr>show interfaces -GigabitEthernet1 is up, line protocol is up - Hardware is vNIC, address is 0000.5e00.5302 (bia 0000.5e00.5302) - Internet address is 192.0.2.234/24 - MTU 1500 bytes, BW 1000000 Kbit/sec, DLY 10 usec, - reliability 255/255, txload 1/255, rxload 1/255 - Encapsulation ARPA, loopback not set - Keepalive set (10 sec) - Full Duplex, 1000Mbps, link type is auto, media type is Virtual - output flow-control is unsupported, input flow-control is unsupported - ARP type: ARPA, ARP Timeout 04:00:00 - Last input 00:00:00, output 00:00:00, output hang never - Last clearing of "show interface" counters never - Input queue: 0/375/0/0 (size/max/drops/flushes); Total output drops: 0 - Queueing strategy: fifo - Output queue: 0/40 (size/max) - 5 minute input rate 1000 bits/sec, 2 packets/sec - 5 minute output rate 3000 bits/sec, 3 packets/sec - 80122 packets input, 7124598 bytes, 0 no buffer - Received 0 broadcasts (0 IP multicasts) - 0 runts, 0 giants, 0 throttles - 0 input errors, 0 CRC, 0 frame, 0 overrun, 0 ignored - 0 watchdog, 0 multicast, 0 pause input - --More--   74728 packets output, 33646647 bytes, 0 underruns - Output 0 broadcasts (0 IP multicasts) - 0 output errors, 0 collisions, 0 interface resets - 0 unknown protocol drops - 0 babbles, 0 late collision, 0 deferred - 0 lost carrier, 0 no carrier, 0 pause output - 0 output buffer failures, 0 output buffers swapped out -GigabitEthernet2 is administratively down, line protocol is down - Hardware is vNIC, address is 0000.5e00.5303 (bia 0000.5e00.5303) - MTU 1500 bytes, BW 1000000 Kbit/sec, DLY 10 usec, - reliability 255/255, txload 1/255, rxload 1/255 - Encapsulation ARPA, loopback not set - Keepalive set (10 sec) - Full Duplex, 1000Mbps, link type is auto, media type is Virtual - output flow-control is unsupported, input flow-control is unsupported - ARP type: ARPA, ARP Timeout 04:00:00 - Last input never, output 2d11h, output hang never - Last clearing of "show interface" counters never - Input queue: 0/375/0/0 (size/max/drops/flushes); Total output drops: 0 - Queueing strategy: fifo - Output queue: 0/40 (size/max) - 5 minute input rate 0 bits/sec, 0 packets/sec - 5 minute output rate 0 bits/sec, 0 packets/sec - --More--   0 packets input, 0 bytes, 0 no buffer - Received 0 broadcasts (0 IP multicasts) - 0 runts, 0 giants, 0 throttles - 0 input errors, 0 CRC, 0 frame, 0 overrun, 0 ignored - 0 watchdog, 0 multicast, 0 pause input - 1 packets output, 364 bytes, 0 underruns - Output 0 broadcasts (0 IP multicasts) - 0 output errors, 0 collisions, 0 interface resets - 0 unknown protocol drops - 0 babbles, 0 late collision, 0 deferred - 1 lost carrier, 0 no carrier, 0 pause output - 0 output buffer failures, 0 output buffers swapped out -GigabitEthernet3 is administratively down, line protocol is down - Hardware is vNIC, address is 0000.5e00.5301 (bia 0000.5e00.5301) - MTU 1500 bytes, BW 1000000 Kbit/sec, DLY 10 usec, - reliability 255/255, txload 1/255, rxload 1/255 - Encapsulation ARPA, loopback not set - Keepalive set (10 sec) - Full Duplex, 1000Mbps, link type is auto, media type is Virtual - output flow-control is unsupported, input flow-control is unsupported - ARP type: ARPA, ARP Timeout 04:00:00 - Last input never, output 2d11h, output hang never - Last clearing of "show interface" counters never - --More--   -lab-rtr>show verzz  sion -Cisco IOS XE Software, Version 17.18.02 -Cisco IOS Software [IOSXE], Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 17.18.2, RELEASE SOFTWARE (fc3) -Technical Support: http://www.cisco.com/techsupport -Copyright (c) 1986-2025 by Cisco Systems, Inc. -Compiled Fri 19-Dec-25 03:49 by mcpre - - -Cisco IOS-XE software, Copyright (c) 2005-2025 by cisco Systems, Inc. -All rights reserved. Certain components of Cisco IOS-XE software are -licensed under the GNU General Public License ("GPL") Version 2.0. The -software code licensed under GPL Version 2.0 is free software that comes -with ABSOLUTELY NO WARRANTY. You can redistribute and/or modify such -GPL code under the terms of GPL Version 2.0. For more details, see the -documentation or "License Notice" file accompanying the IOS-XE software, -or the applicable URL provided on the flyer accompanying the IOS-XE -software. - - -ROM: IOS-XE ROMMON - -lab-rtr uptime is 2 days, 11 hours, 44 minutes -Uptime for this control processor is 2 days, 11 hours, 46 minutes - --More--   -lab-rtr>xit + + + +lab-rtr>show version +Cisco IOS XE Software, Version 17.18.02 +Cisco IOS Software [IOSXE], Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 17.18.2, RELEASE SOFTWARE (fc3) +Technical Support: http://www.cisco.com/techsupport +Copyright (c) 1986-2025 by Cisco Systems, Inc. +Compiled Fri 19-Dec-25 03:49 by mcpre + + +Cisco IOS-XE software, Copyright (c) 2005-2025 by cisco Systems, Inc. +All rights reserved. Certain components of Cisco IOS-XE software are +licensed under the GNU General Public License ("GPL") Version 2.0. The +software code licensed under GPL Version 2.0 is free software that comes +with ABSOLUTELY NO WARRANTY. You can redistribute and/or modify such +GPL code under the terms of GPL Version 2.0. For more details, see the +documentation or "License Notice" file accompanying the IOS-XE software, +or the applicable URL provided on the flyer accompanying the IOS-XE +software. + + +ROM: IOS-XE ROMMON + +lab-rtr uptime is 2 days, 11 hours, 43 minutes +Uptime for this control processor is 2 days, 11 hours, 46 minutes + --More--   +lab-rtr>how ip interface brief + ^ +% Invalid input detected at '^' marker. + +lab-rtr>show ip route +Codes: L - local, C - connected, S - static, R - RIP, M - mobile, B - BGP + D - EIGRP, EX - EIGRP external, O - OSPF, IA - OSPF inter area + N1 - OSPF NSSA external type 1, N2 - OSPF NSSA external type 2 + E1 - OSPF external type 1, E2 - OSPF external type 2, m - OMP + n - NAT, Ni - NAT inside, No - NAT outside, Nd - NAT DIA + i - IS-IS, su - IS-IS summary, L1 - IS-IS level-1, L2 - IS-IS level-2 + ia - IS-IS inter area, * - candidate default, U - per-user static route + H - NHRP, G - NHRP registered, g - NHRP registration summary + o - ODR, P - periodic downloaded static route, l - LISP + a - application route + + - replicated route, % - next hop override, p - overrides from PfR + & - replicated local route overrides by connected + +Gateway of last resort is 192.0.2.1 to network 0.0.0.0 + +S* 0.0.0.0/0 [1/0] via 192.0.2.1 + 192.0.2.0/8 is variably subnetted, 3 subnets, 2 masks +C 192.0.2.0/24 is directly connected, GigabitEthernet1 +L 192.0.2.187/32 is directly connected, GigabitEthernet1 +L 192.0.2.234/32 is directly connected, GigabitEthernet1 +lab-rtr>show interfaces +GigabitEthernet1 is up, line protocol is up + Hardware is vNIC, address is 0000.5e00.5302 (bia 0000.5e00.5302) + Internet address is 192.0.2.234/24 + MTU 1500 bytes, BW 1000000 Kbit/sec, DLY 10 usec, + reliability 255/255, txload 1/255, rxload 1/255 + Encapsulation ARPA, loopback not set + Keepalive set (10 sec) + Full Duplex, 1000Mbps, link type is auto, media type is Virtual + output flow-control is unsupported, input flow-control is unsupported + ARP type: ARPA, ARP Timeout 04:00:00 + Last input 00:00:00, output 00:00:00, output hang never + Last clearing of "show interface" counters never + Input queue: 0/375/0/0 (size/max/drops/flushes); Total output drops: 0 + Queueing strategy: fifo + Output queue: 0/40 (size/max) + 5 minute input rate 1000 bits/sec, 2 packets/sec + 5 minute output rate 3000 bits/sec, 3 packets/sec + 80122 packets input, 7124598 bytes, 0 no buffer + Received 0 broadcasts (0 IP multicasts) + 0 runts, 0 giants, 0 throttles + 0 input errors, 0 CRC, 0 frame, 0 overrun, 0 ignored + 0 watchdog, 0 multicast, 0 pause input + --More--   74728 packets output, 33646647 bytes, 0 underruns + Output 0 broadcasts (0 IP multicasts) + 0 output errors, 0 collisions, 0 interface resets + 0 unknown protocol drops + 0 babbles, 0 late collision, 0 deferred + 0 lost carrier, 0 no carrier, 0 pause output + 0 output buffer failures, 0 output buffers swapped out +GigabitEthernet2 is administratively down, line protocol is down + Hardware is vNIC, address is 0000.5e00.5303 (bia 0000.5e00.5303) + MTU 1500 bytes, BW 1000000 Kbit/sec, DLY 10 usec, + reliability 255/255, txload 1/255, rxload 1/255 + Encapsulation ARPA, loopback not set + Keepalive set (10 sec) + Full Duplex, 1000Mbps, link type is auto, media type is Virtual + output flow-control is unsupported, input flow-control is unsupported + ARP type: ARPA, ARP Timeout 04:00:00 + Last input never, output 2d11h, output hang never + Last clearing of "show interface" counters never + Input queue: 0/375/0/0 (size/max/drops/flushes); Total output drops: 0 + Queueing strategy: fifo + Output queue: 0/40 (size/max) + 5 minute input rate 0 bits/sec, 0 packets/sec + 5 minute output rate 0 bits/sec, 0 packets/sec + --More--   0 packets input, 0 bytes, 0 no buffer + Received 0 broadcasts (0 IP multicasts) + 0 runts, 0 giants, 0 throttles + 0 input errors, 0 CRC, 0 frame, 0 overrun, 0 ignored + 0 watchdog, 0 multicast, 0 pause input + 1 packets output, 364 bytes, 0 underruns + Output 0 broadcasts (0 IP multicasts) + 0 output errors, 0 collisions, 0 interface resets + 0 unknown protocol drops + 0 babbles, 0 late collision, 0 deferred + 1 lost carrier, 0 no carrier, 0 pause output + 0 output buffer failures, 0 output buffers swapped out +GigabitEthernet3 is administratively down, line protocol is down + Hardware is vNIC, address is 0000.5e00.5301 (bia 0000.5e00.5301) + MTU 1500 bytes, BW 1000000 Kbit/sec, DLY 10 usec, + reliability 255/255, txload 1/255, rxload 1/255 + Encapsulation ARPA, loopback not set + Keepalive set (10 sec) + Full Duplex, 1000Mbps, link type is auto, media type is Virtual + output flow-control is unsupported, input flow-control is unsupported + ARP type: ARPA, ARP Timeout 04:00:00 + Last input never, output 2d11h, output hang never + Last clearing of "show interface" counters never + --More--   +lab-rtr>show verzz  sion +Cisco IOS XE Software, Version 17.18.02 +Cisco IOS Software [IOSXE], Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 17.18.2, RELEASE SOFTWARE (fc3) +Technical Support: http://www.cisco.com/techsupport +Copyright (c) 1986-2025 by Cisco Systems, Inc. +Compiled Fri 19-Dec-25 03:49 by mcpre + + +Cisco IOS-XE software, Copyright (c) 2005-2025 by cisco Systems, Inc. +All rights reserved. Certain components of Cisco IOS-XE software are +licensed under the GNU General Public License ("GPL") Version 2.0. The +software code licensed under GPL Version 2.0 is free software that comes +with ABSOLUTELY NO WARRANTY. You can redistribute and/or modify such +GPL code under the terms of GPL Version 2.0. For more details, see the +documentation or "License Notice" file accompanying the IOS-XE software, +or the applicable URL provided on the flyer accompanying the IOS-XE +software. + + +ROM: IOS-XE ROMMON + +lab-rtr uptime is 2 days, 11 hours, 44 minutes +Uptime for this control processor is 2 days, 11 hours, 46 minutes + --More--   +lab-rtr>xit diff --git a/tests/test_terminal_screen.py b/tests/test_terminal_screen.py index 958b5e7..1ad549d 100644 --- a/tests/test_terminal_screen.py +++ b/tests/test_terminal_screen.py @@ -423,6 +423,32 @@ def test_a_short_line_is_still_padded(self): s.set_size(24, 100) self.assertEqual(len(s.lines[0]), 100) + def test_no_command_crashes_when_the_line_is_not_as_wide_as_the_screen(self): + """行の長さと桁数が食い違っても、どの命令でも落ちないこと。 + + 窓を縮めても行を切らず、折り返しで続く行は広げても埋めないので、 + 行は桁数より長いことも短いこともある。カーソルは桁数まで動ける + ので、行の実際の長さを見ずに触ると IndexError でアプリごと落ちる。 + 実際 ESC[1K と ESC[P で落ちていた。 + """ + finals = "@ABCDEFGHIJKLMPSTXZdfghlmnrst`" + params = ("", "0", "1", "2", "5", "99", "1;99", "99;99") + for label, setup, size in ( + ("短い行", "y" * 60, (24, 120)), # 折り返し行を広げた + ("長い行", "z" * 120, (24, 20))): # 長い行を縮めた + for col in (1, 40, 100, 120): + for final in finals: + for param in params: + with self.subTest(kind=label, col=col, + seq=param + final): + s = Screen(24, 40) + p = Parser() + s.apply(p.feed(setup)) + s.set_size(*size) + s.apply(p.feed("\x1b[1;%dH" % col)) + s.apply(p.feed("\x1b[" + param + final)) + s.apply(p.feed("X")) # 続けて書けること + def test_writing_past_the_end_of_a_wrapped_line_still_works(self): """埋めていない行の先へ書いても落ちないこと。""" s = Screen(24, 40) From 8f1bbd71a99d3476e55b96bbc3b31524cb22db39 Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:32:28 +0900 Subject: [PATCH 26/29] feat(update): tell people what changed, not just how to install The update notice showed the release body, which held only the install guide and licence notice - nothing about what had changed. Releases now put the CHANGELOG section for that version at the top of the body, with the standing notice below it (moved to .github/release-body.md so the workflow can compose the two). The licence text and the GPL source offer stay exactly as they were. The app asks for the release list rather than just the newest one, so someone who skipped a few versions sees each of them in order, newest first, capped at five with a line saying how many were left out. It is the same single request as before, so the unauthenticated rate limit is unaffected; drafts and prereleases are filtered out here rather than by the endpoint, and the list is re-sorted by version instead of trusting its order. One trap found by running the workflow's PowerShell locally: -like reads [ and ] as a character class, so "## [1.2.0]*" matches nothing and the release would have failed at the tag. It uses StartsWith. Co-Authored-By: Claude Opus 5 --- .github/release-body.md | 49 ++++++++++++ .github/workflows/build-release.yml | 79 +++++++------------ CHANGELOG.md | 81 +++++++++++++++++++ src/__version__.py | 2 +- src/core/version_manager.py | 77 ++++++++++++++++-- tests/test_update_notes.py | 117 ++++++++++++++++++++++++++++ 6 files changed, 349 insertions(+), 56 deletions(-) create mode 100644 .github/release-body.md create mode 100644 tests/test_update_notes.py diff --git a/.github/release-body.md b/.github/release-body.md new file mode 100644 index 0000000..c17742d --- /dev/null +++ b/.github/release-body.md @@ -0,0 +1,49 @@ +## NetBelt ポータブル版 + +### 📄 ライセンスと対応するソースコード + +NetBelt は GNU General Public License v3.0 で配布しています。 + +この実行ファイルには PyQt6 (GPL-3.0)、Qt (LGPL-3.0)、paramiko (LGPL-2.1-or-later) +などが組み込まれています。**この実行ファイルに対応するソースコード**は、 +本リリースに自動添付される Source code アーカイブ、および +https://github.com/gomanizm/NetBelt のタグ `{{TAG}}` で入手できます。 +ビルドは同梱の `requirements.txt`(バージョン固定済み)と `NetBelt.spec`、 +Python 3.11 で再現できます。 + +組み込まれた第三者パッケージの一覧・ライセンス本文・配布元は、 +ZIP 内の `THIRD-PARTY-NOTICES.txt` を参照してください。 + +### 📦 パッケージ内容 +- `NetBelt.exe` - アプリケーション本体 +- `README.txt` - 使い方ガイド +- `LICENSE.txt` - NetBelt のライセンス(GPL-3.0) +- `THIRD-PARTY-NOTICES.txt` - 同梱した第三者パッケージのライセンス表示 +- `CHANGELOG.txt` - この版で変わったこと +- `.sha256` - ZIP のチェックサム。自動更新がダウンロード後に照合し、 + 一致しなければ更新を中止します(破損の検知が目的で、GitHub 自体が + 侵害された場合の改ざんは検知できません) +- `mibs/` - MIB ファイルの配置先(既定では空。README を参照) + +### 🚀 インストール方法 +1. ZIPファイルをダウンロード +2. 任意のフォルダに展開 +3. `NetBelt.exe` をダブルクリックして起動 + +### 💻 動作環境 +- Windows 10 / Windows 11 (64bit) +- Pythonのインストールは不要 +- すべての依存ファイルが含まれています + +### ✨ 主な機能 +- SSH、Telnet、シリアル接続に対応 +- デバイス管理とグループ化 +- マクロ機能 +- SNMP Trap / Syslog 受信(カスタムMIB対応) +- SFTP ファイル転送 + +### 📝 初回起動 +- 初回起動時に `config.json` が自動生成されます +- MIB ファイルは同梱していません。`mibs/README.md` の手順で配置してください + +詳しい使い方は [README.md](https://github.com/gomanizm/NetBelt/blob/main/README.md) をご覧ください。 diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 64c4a5d..3bbcedb 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -101,6 +101,34 @@ jobs: name: NetBelt-Portable path: NetBelt-v*-Windows-Portable.zip + # 更新の通知はリリース本文をそのまま出す。案内文だけだと、利用者は + # 何が変わったのか分からない。CHANGELOG のこの版の節を先頭へ置き、 + # 案内文 (.github/release-body.md) をその下へ続ける。 + - name: Build release notes + if: startsWith(github.ref, 'refs/tags/') + shell: pwsh + run: | + $version = '${{ steps.version.outputs.version }}' + $lines = Get-Content CHANGELOG.md -Encoding utf8 + $start = $null + $end = $lines.Count + # -like は [ ] を文字クラスとして解釈するので使えない。 + # "## [1.2.0]*" は「1 . 2 0 のどれか1文字」という意味になり一致しない + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i].StartsWith("## [$version]")) { $start = $i + 1; continue } + if ($null -ne $start -and $lines[$i].StartsWith('## [')) { $end = $i; break } + } + if ($null -eq $start) { throw "CHANGELOG.md に [$version] の節がありません" } + $section = ($lines[$start..($end - 1)] -join "`n").Trim() + if (-not $section) { throw "[$version] の節が空です" } + + $template = (Get-Content .github/release-body.md -Encoding utf8) -join "`n" + $template = $template.Replace('{{TAG}}', $env:GITHUB_REF_NAME) + + $body = "## v$version で変わったこと`n`n$section`n`n---`n`n$template" + Set-Content -Path release_notes.md -Value $body -Encoding utf8 + Write-Host "本文 $($body.Length) 文字 (うち変更点 $($section.Length) 文字)" + - name: Create Release if: startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@v3 @@ -108,56 +136,7 @@ jobs: files: | NetBelt-v*-Windows-Portable.zip NetBelt-v*-Windows-Portable.zip.sha256 - body: | - ## NetBelt ポータブル版 - - ### 📄 ライセンスと対応するソースコード - - NetBelt は GNU General Public License v3.0 で配布しています。 - - この実行ファイルには PyQt6 (GPL-3.0)、Qt (LGPL-3.0)、paramiko (LGPL-2.1-or-later) - などが組み込まれています。**この実行ファイルに対応するソースコード**は、 - 本リリースに自動添付される Source code アーカイブ、および - https://github.com/gomanizm/NetBelt のタグ `${{ github.ref_name }}` で入手できます。 - ビルドは同梱の `requirements.txt`(バージョン固定済み)と `NetBelt.spec`、 - Python 3.11 で再現できます。 - - 組み込まれた第三者パッケージの一覧・ライセンス本文・配布元は、 - ZIP 内の `THIRD-PARTY-NOTICES.txt` を参照してください。 - - ### 📦 パッケージ内容 - - `NetBelt.exe` - アプリケーション本体 - - `README.txt` - 使い方ガイド - - `LICENSE.txt` - NetBelt のライセンス(GPL-3.0) - - `THIRD-PARTY-NOTICES.txt` - 同梱した第三者パッケージのライセンス表示 - - `CHANGELOG.txt` - この版で変わったこと - - `.sha256` - ZIP のチェックサム。自動更新がダウンロード後に照合し、 - 一致しなければ更新を中止します(破損の検知が目的で、GitHub 自体が - 侵害された場合の改ざんは検知できません) - - `mibs/` - MIB ファイルの配置先(既定では空。README を参照) - - ### 🚀 インストール方法 - 1. ZIPファイルをダウンロード - 2. 任意のフォルダに展開 - 3. `NetBelt.exe` をダブルクリックして起動 - - ### 💻 動作環境 - - Windows 10 / Windows 11 (64bit) - - Pythonのインストールは不要 - - すべての依存ファイルが含まれています - - ### ✨ 主な機能 - - SSH、Telnet、シリアル接続に対応 - - デバイス管理とグループ化 - - マクロ機能 - - SNMP Trap / Syslog 受信(カスタムMIB対応) - - SFTP ファイル転送 - - ### 📝 初回起動 - - 初回起動時に `config.json` が自動生成されます - - MIB ファイルは同梱していません。`mibs/README.md` の手順で配置してください - - 詳しい使い方は [README.md](https://github.com/gomanizm/NetBelt/blob/main/README.md) をご覧ください。 + body_path: release_notes.md draft: false prerelease: false env: diff --git a/CHANGELOG.md b/CHANGELOG.md index ca9f9da..4f9d4ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,87 @@ このファイルの形式は [Keep a Changelog](https://keepachangelog.com/ja/1.1.0/) に、 バージョン番号は [Semantic Versioning](https://semver.org/lang/ja/) に従います。 +## [1.2.0] - 2026-08-27 + +ターミナルの描画を作り直しました。これまでは受信した文字を書き足していく +だけで、行と桁からなる「画面」という概念を持っていませんでした。そのため +`nano` や `vi` が「23 行目の 1 桁目へ」と指示しても行き先が無く、画面ぶんの +描画がすべて同じ場所に重なっていました。 + +**設定や接続先の作り直しは不要です。** これまでの `config.json` をそのまま +使えます。 + +### 追加 +- **`nano` や `vi` の画面が崩れなくなった。** これまでは「対応していません」と + 案内を出すのが精一杯でした。タイトルバー・編集領域・下部のショートカット + 2 段が、実際の画面として組み上がります。`less` や `top` のような画面を + 作るコマンドも同じ仕組みで扱いますが、すべてのコマンドを試したわけでは + ないため、崩れるものが残っている可能性はあります。 +- **全画面のコマンドの画面は、記録にも残ります。** `nano` や `vi` を閉じても + 開く前の画面は戻らず、編集中の画面がそのままスクロールバックと + セッションログに残ります。NetBelt は端末種別を `vt100` として接続するため、 + これらのコマンドが画面を退避する仕組み(代替画面)を使わないためです。 + 編集内容を記録に残したくない場合は、ログ記録を止めてください + (スクロールバックには残りますが、画面上だけでファイルには残りません)。 +- **色と装飾を表示するようになった。** 文字色・背景色・太字・下線・反転に + 対応しました。基本 16 色に加えて 256 色パレットと RGB 直接指定も扱えます。 + 反転表示は、設定した文字色と背景色を入れ替えて表示します。 +- **ウィンドウの大きさを機器へ伝えるようになった。** これまでは 80×24 固定で、 + ウィンドウを広げても `show` の出力が 80 桁で折り返していました。接続時と + リサイズ時に実際の行数・桁数を伝えます(RFC 4254 §6.2 / §6.7)。 + この通知は SSH のみです。Telnet とシリアルでは通知しませんが、 + 画面の描画・色・折り返しはこれまでどおり同じように働きます。 +- **アプリケーションが指定する入力方式に従うようになった。** `nano` が + 要求するカーソルキーの形式(DECCKM)と、bash のブラケットペーストに + 対応しました。貼り付けが 1 行ずつ実行されず、まとめて編集できます。 +- **ステータスバーに端末の大きさを表示するようにした。** いま機器へ伝えて + いる桁数と行数が右端に出ます。 +- **ターミナルのタブをドラッグで並べ替えられるようにした。** +- **接続先リストを隠せるようにした。** ツールエリアと同じく、仕切りを + ドラッグする・右クリックする・表示メニューから切り替える、の 3 通りです。 + +### 修正 +- **ウィンドウを縮めると、出力の末尾が失われていた。** 長い行を画面の幅で + 切り捨てていたため、`cat` した鍵や幅の広い `show` 出力を読んでいる最中に + ウィンドウを縮めると、はみ出した部分が永久に失われていました。 + リサイズでは、すでに書かれた行に手を触れないようにしました。 +- **暴走したパラメータでアプリが固まっていた。** `ESC[999999999S` のような + 値を受け取ると画面処理が何十分も回り続け、その間メモリも増え続けるため、 + 強制終了するしかありませんでした。繰り返し回数を画面の大きさで頭打ちに + しました。 +- **`clear` を打っても画面が空にならなかった。** 消去後に表示領域が詰まり、 + それまでの出力がせり上がってきていました。記録は残したまま、画面だけが + 空になります。 +- **切断バナーやエラー文が階段状にずれていた。** アプリ自身が出す案内は + 改行だけで書かれており、端末では改行は桁位置を戻さないため、プロンプトが + 出ている状態で切断すると文字が右へずれていきました。 +- **出力が届くと範囲選択が消えることがあった。** 画面がスクロールすると + 表示領域を丸ごと書き換えていたため、`show` の出力をコピーしようとして + いる最中に機器がログを 1 行吐くだけで選択が外れていました。 +- **セッションログからタブ文字が落ちていた。** 桁で揃えた表が詰まって + 記録されていました。 +- **履歴を消す命令(`ESC[3J`)で、逆に画面が消えていた。** NetBelt は + セッションの記録を消さない方針なので、この命令では何もしません。 +- **折り返しを無効にしても、直後の 1 文字だけが次の行へ流れていた。** + +### 変更 +- 受信の解釈を、正規表現による逐次処理から仕様どおりの状態機械へ置き換え + ました。受信がどこで途切れても解釈が変わりません。実機から採取した + データの全ての切れ目で検証しています。 +- 画面の大きさが変わったとき、すでに書かれた行は組み直しません(実端末と + 同じ挙動です)。狭い状態で出力された行は、その折り返しのまま残ります。 + ただし表示領域から流れて記録に入った行は 1 本の行として保存されるため、 + ウィンドウの幅に合わせて折り返し直されます。 +- 全画面アプリを検知して案内を出す仕組みを廃止しました。実際に表示できる + ようになったため不要になりました。 + +### 開発メモ +作り直しは仕様から行っています。既存のターミナル実装のソースコードは +読んでいません(読んで書き直すと、構造や命名が似たときに派生物とみなされる +余地が残るため)。参照したのは Paul Williams の DEC 互換パーサ状態遷移図、 +ECMA-48(= ISO/IEC 6429)、ISO/IEC 2022、RFC 4254、および標準に無い部分に +ついては XTerm Control Sequences です。 + ## [1.1.1] - 2026-08-26 公開後に見つかった不具合の修正です。 diff --git a/src/__version__.py b/src/__version__.py index 7983402..3b899db 100644 --- a/src/__version__.py +++ b/src/__version__.py @@ -2,6 +2,6 @@ NetBelt バージョン情報 """ -__version__ = "1.1.1" +__version__ = "1.2.0" GITHUB_REPO = "gomanizm/NetBelt" APP_NAME = "NetBelt" \ No newline at end of file diff --git a/src/core/version_manager.py b/src/core/version_manager.py index e372f80..a2e5d5e 100644 --- a/src/core/version_manager.py +++ b/src/core/version_manager.py @@ -24,7 +24,13 @@ class VersionManager: """バージョン管理とアップデート機能を提供するクラス""" CURRENT_VERSION = __version__ - GITHUB_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest" + # 1回で複数世代ぶんを取る。/releases/latest だと最新1件しか返らず、 + # 何世代か飛ばしている利用者へ「その間に何が変わったか」を出せない。 + # 一覧なら要求は同じ1回のままで、下書きと事前公開は自分で除ける。 + GITHUB_API_URL = ( + f"https://api.github.com/repos/{GITHUB_REPO}/releases?per_page=10") + # 通知に載せる世代数の上限。全部並べると読まれない + MAX_NOTES_GENERATIONS = 5 UPDATE_DIR = os.path.join(tempfile.gettempdir(), f"{APP_NAME}Updates") def __init__(self, github_token: Optional[str] = None): @@ -82,6 +88,63 @@ def _seg(s): return 0 + @staticmethod + def _published(payload) -> list: + """公開済みのリリースだけを、新しい順で返す。 + + 一覧には下書きと事前公開も混ざる。並び順は GitHub が新しい順で + 返すが、当てにせず版で並べ直す。 + """ + if isinstance(payload, dict): # 単一リリースを渡された場合 + payload = [payload] + if not isinstance(payload, list): + return [] + published = [r for r in payload + if isinstance(r, dict) + and not r.get('draft') and not r.get('prerelease') + and r.get('tag_name')] + return sorted( + published, + key=lambda r: VersionManager._version_key( + r.get('tag_name', '').lstrip('v')), + reverse=True) + + @staticmethod + def _version_key(version: str) -> tuple: + """並べ替え用に版を数値の組へ。読めない部分は 0 として扱う。""" + parts = [] + for chunk in str(version).split('.')[:4]: + digits = ''.join(c for c in chunk if c.isdigit()) + parts.append(int(digits) if digits else 0) + while len(parts) < 4: + parts.append(0) + return tuple(parts) + + def collect_notes(self, releases: list) -> str: + """いま入っている版より新しいリリースの内容を、まとめて返す。 + + リリースノートに README や告知しか無いと、利用者は何が変わったのか + 分からない。何世代か飛ばしている場合は、その間のぶんも並べる。 + 多すぎると読まれないので上限を設ける。 + """ + newer = [r for r in releases + if self.compare_versions( + self.CURRENT_VERSION, + r.get('tag_name', '').lstrip('v')) < 0] + if not newer: + return releases[0].get('body', '') if releases else '' + + shown = newer[:self.MAX_NOTES_GENERATIONS] + blocks = [] + for release in shown: + version = release.get('tag_name', '').lstrip('v') + body = (release.get('body') or '').strip() + blocks.append("# v%s\n\n%s" % (version, body or '(内容なし)')) + if len(newer) > len(shown): + blocks.append("(さらに古い %d 世代ぶんは省略しました)" + % (len(newer) - len(shown))) + return "\n\n---\n\n".join(blocks) + def check_for_updates(self, timeout: int = 10) -> Optional[Dict]: """ GitHubから最新バージョンを確認 @@ -107,9 +170,13 @@ def check_for_updates(self, timeout: int = 10) -> Optional[Dict]: response = requests.get(self.GITHUB_API_URL, headers=headers, timeout=timeout) response.raise_for_status() - - data = response.json() - + + payload = response.json() + releases = self._published(payload) + if not releases: + return None + data = releases[0] + # 最新バージョンを取得 latest_version = data.get('tag_name', '').lstrip('v') @@ -146,7 +213,7 @@ def check_for_updates(self, timeout: int = 10) -> Optional[Dict]: return { 'available': is_newer, 'version': latest_version, - 'release_notes': data.get('body', ''), + 'release_notes': self.collect_notes(releases), 'download_url': download_url, 'sha256_url': sha256_url, 'published_at': data.get('published_at', '') diff --git a/tests/test_update_notes.py b/tests/test_update_notes.py new file mode 100644 index 0000000..352fed2 --- /dev/null +++ b/tests/test_update_notes.py @@ -0,0 +1,117 @@ +"""更新の通知に、何が変わったかを出す。 + +これまでリリース本文には案内文しか入っておらず、通知を見ても何が +変わったのか分からなかった。いまはリリース本文の先頭に CHANGELOG の +その版の節が入る。加えて、何世代か飛ばしている利用者には、その間の +ぶんも並べる。 + +GitHub API は叩かない。応答を作り物で与える。 +""" +import sys +import unittest +from unittest import mock + +sys.path.insert(0, "src") + + +def release(version, body="", draft=False, prerelease=False): + return { + "tag_name": "v" + version, + "body": body, + "draft": draft, + "prerelease": prerelease, + "assets": [ + {"name": "NetBelt-v%s-Windows-Portable.zip" % version, + "url": "https://example.com/%s.zip" % version}, + {"name": "NetBelt-v%s-Windows-Portable.zip.sha256" % version, + "url": "https://example.com/%s.sha256" % version}, + ], + "published_at": "2026-08-27T00:00:00Z", + } + + +def check(releases, current="1.0.0"): + from core.version_manager import VersionManager + + response = mock.Mock() + response.json.return_value = releases + response.raise_for_status.return_value = None + with mock.patch("core.version_manager.requests.get", + return_value=response), \ + mock.patch.object(VersionManager, "CURRENT_VERSION", current): + return VersionManager().check_for_updates() + + +class NotesSpanTest(unittest.TestCase): + def test_the_notes_cover_every_version_that_was_skipped(self): + info = check([release("1.3.0", "3 の話"), + release("1.2.0", "2 の話"), + release("1.1.0", "1 の話")], current="1.0.0") + notes = info["release_notes"] + for body in ("3 の話", "2 の話", "1 の話"): + self.assertIn(body, notes) + # 新しいものから並べる + self.assertLess(notes.index("3 の話"), notes.index("1 の話")) + + def test_versions_already_installed_are_left_out(self): + info = check([release("1.3.0", "3 の話"), + release("1.2.0", "2 の話"), + release("1.1.0", "1 の話")], current="1.2.0") + notes = info["release_notes"] + self.assertIn("3 の話", notes) + self.assertNotIn("2 の話", notes) + self.assertNotIn("1 の話", notes) + + def test_each_block_says_which_version_it_is(self): + info = check([release("1.3.0", "3 の話"), + release("1.2.0", "2 の話")], current="1.1.0") + self.assertIn("# v1.3.0", info["release_notes"]) + self.assertIn("# v1.2.0", info["release_notes"]) + + def test_too_many_generations_are_capped(self): + from core.version_manager import VersionManager + many = [release("1.%d.0" % n, "%d の話" % n) + for n in range(9, 0, -1)] + info = check(many, current="1.0.0") + notes = info["release_notes"] + shown = sum(1 for n in range(1, 10) if ("# v1.%d.0" % n) in notes) + self.assertEqual(shown, VersionManager.MAX_NOTES_GENERATIONS) + self.assertIn("省略", notes) + + +class ReleaseSelectionTest(unittest.TestCase): + def test_drafts_and_prereleases_are_skipped(self): + info = check([release("2.0.0", "下書き", draft=True), + release("1.9.0", "事前公開", prerelease=True), + release("1.3.0", "本物")], current="1.0.0") + self.assertEqual(info["version"], "1.3.0") + self.assertNotIn("下書き", info["release_notes"]) + self.assertNotIn("事前公開", info["release_notes"]) + + def test_the_newest_wins_even_if_the_list_is_out_of_order(self): + info = check([release("1.1.0", "1 の話"), + release("1.3.0", "3 の話"), + release("1.2.0", "2 の話")], current="1.0.0") + self.assertEqual(info["version"], "1.3.0") + + def test_the_download_still_comes_from_the_newest_release(self): + info = check([release("1.3.0"), release("1.2.0")], current="1.0.0") + self.assertIn("1.3.0", info["download_url"]) + self.assertIn("1.3.0", info["sha256_url"]) + + def test_an_empty_list_is_not_an_update(self): + self.assertIsNone(check([], current="1.0.0")) + + def test_a_single_release_object_still_works(self): + """API が 1 件だけの辞書を返しても壊れないこと。""" + info = check(release("1.3.0", "3 の話"), current="1.0.0") + self.assertEqual(info["version"], "1.3.0") + self.assertIn("3 の話", info["release_notes"]) + + def test_nothing_newer_means_no_update(self): + info = check([release("1.0.0", "同じ")], current="1.0.0") + self.assertFalse(info["available"]) + + +if __name__ == "__main__": + unittest.main() From 8ea0d44ad65addeb1b5a9eb6fa4b1460ac382133 Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:36:47 +0900 Subject: [PATCH 27/29] fix(ui): bring back a device list that was dragged to zero width Splitter positions are remembered across restarts, so a list collapsed to nothing and left that way came back the same on the next launch - visible as far as Qt was concerned, invisible to the person looking at it. The View menu then appeared dead, because the first click hid a list that was already unseen and only the second brought it back. A pane with no width now counts as hidden. Also corrects the changelog. Everything previously listed under "fixed" was a fault introduced during this rewrite and caught before release, not something a v1.1.1 user ever met - v1.1.1 has no screen model at all. Listing them tells people they were suffering from bugs they never had, so the section now names only what was genuinely broken for them: full-screen apps collapsing to one line, and output wrapping at 80 columns however wide the window. Three claims that overstated the work are corrected too, and the device queries the app now answers on its own (cursor position, device attributes), the pager's page size following the window height, and full-width characters counting as one column are all written down. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 47 +++++++++++++++----------------- src/ui/main_window.py | 26 +++++++++++------- tests/test_device_list_hiding.py | 22 ++++++++++++--- 3 files changed, 56 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f9d4ab..e610f7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,46 +36,43 @@ - **アプリケーションが指定する入力方式に従うようになった。** `nano` が 要求するカーソルキーの形式(DECCKM)と、bash のブラケットペーストに 対応しました。貼り付けが 1 行ずつ実行されず、まとめて編集できます。 -- **ステータスバーに端末の大きさを表示するようにした。** いま機器へ伝えて - いる桁数と行数が右端に出ます。 +- **ステータスバーに端末の大きさを表示するようにした。** いま表示中の + ターミナルの桁数と行数が右端に出ます(SSH ではこの値を機器へ伝えて + います。Telnet とシリアルでは表示だけです)。 - **ターミナルのタブをドラッグで並べ替えられるようにした。** - **接続先リストを隠せるようにした。** ツールエリアと同じく、仕切りを ドラッグする・右クリックする・表示メニューから切り替える、の 3 通りです。 +- **更新の通知に、何が変わったかを出すようにした。** これまでは使い方の + 案内しか出ておらず、更新して何が変わるのか分かりませんでした。 + 何世代か飛ばしている場合は、その間のぶんもまとめて表示します + (多すぎると読まれないため、5 世代までとし、残りは件数だけ添えます)。 ### 修正 -- **ウィンドウを縮めると、出力の末尾が失われていた。** 長い行を画面の幅で - 切り捨てていたため、`cat` した鍵や幅の広い `show` 出力を読んでいる最中に - ウィンドウを縮めると、はみ出した部分が永久に失われていました。 - リサイズでは、すでに書かれた行に手を触れないようにしました。 -- **暴走したパラメータでアプリが固まっていた。** `ESC[999999999S` のような - 値を受け取ると画面処理が何十分も回り続け、その間メモリも増え続けるため、 - 強制終了するしかありませんでした。繰り返し回数を画面の大きさで頭打ちに - しました。 -- **`clear` を打っても画面が空にならなかった。** 消去後に表示領域が詰まり、 - それまでの出力がせり上がってきていました。記録は残したまま、画面だけが - 空になります。 -- **切断バナーやエラー文が階段状にずれていた。** アプリ自身が出す案内は - 改行だけで書かれており、端末では改行は桁位置を戻さないため、プロンプトが - 出ている状態で切断すると文字が右へずれていきました。 -- **出力が届くと範囲選択が消えることがあった。** 画面がスクロールすると - 表示領域を丸ごと書き換えていたため、`show` の出力をコピーしようとして - いる最中に機器がログを 1 行吐くだけで選択が外れていました。 -- **セッションログからタブ文字が落ちていた。** 桁で揃えた表が詰まって - 記録されていました。 -- **履歴を消す命令(`ESC[3J`)で、逆に画面が消えていた。** NetBelt は - セッションの記録を消さない方針なので、この命令では何もしません。 -- **折り返しを無効にしても、直後の 1 文字だけが次の行へ流れていた。** +- **`nano` や `vi` を開くと画面が 1 行に潰れていた。** 上の「追加」の + とおり、画面として組み上がるようになりました。 +- **ウィンドウを広げても、出力が 80 桁で折り返したままだった。** + 端末の大きさを 80×24 に固定して機器へ伝えていたためです。 ### 変更 - 受信の解釈を、正規表現による逐次処理から仕様どおりの状態機械へ置き換え ました。受信がどこで途切れても解釈が変わりません。実機から採取した - データの全ての切れ目で検証しています。 + データを分割して検証しています(Linux 側の 3 種は全ての切れ目、 + 機器から採った 7058 バイトは 7 バイトおき)。 - 画面の大きさが変わったとき、すでに書かれた行は組み直しません(実端末と 同じ挙動です)。狭い状態で出力された行は、その折り返しのまま残ります。 ただし表示領域から流れて記録に入った行は 1 本の行として保存されるため、 ウィンドウの幅に合わせて折り返し直されます。 - 全画面アプリを検知して案内を出す仕組みを廃止しました。実際に表示できる ようになったため不要になりました。 +- 機器から端末への問い合わせ(カーソル位置・装置種別)に、自動で答える + ようになりました。答えないと待ち続けるアプリケーションがあるためです。 + 送るのは問い合わせへの応答だけで、こちらから何かを送ることはありません。 +- 端末の高さを機器へ伝えるようになったため、機器側のページャ(`--More--`) + の区切りがウィンドウの高さで変わります。ウィンドウが高いほど一度に + 多く表示されます。 +- 全角文字は 1 桁として数えます。日本語を含む行の折り返し位置が、 + 実際の見た目とずれることがあります。機器の出力は英数字が中心のため、 + 影響は Linux 側での日本語編集に限られます。 ### 開発メモ 作り直しは仕様から行っています。既存のターミナル実装のソースコードは diff --git a/src/ui/main_window.py b/src/ui/main_window.py index 0b926da..964e1ab 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -1517,17 +1517,23 @@ def _toggle_tool_area(self): DEVICE_LIST_WIDTH = 250 def _toggle_device_list(self): - """接続先リストの表示/非表示を切り替える。""" - show = self.device_tree.isHidden() - self.device_tree.setVisible(show) - if show: - sizes = self.main_splitter.sizes() - if sizes and sizes[0] < 40: - spare = max(sizes[1] - self.DEVICE_LIST_WIDTH, 100) - self.main_splitter.setSizes( - [self.DEVICE_LIST_WIDTH, spare] + sizes[2:]) + """接続先リストの表示/非表示を切り替える。 + + 仕切りを幅 0 まで引いた状態は、見た目は隠れているのに + ウィジェットとしては表示中。分割位置は次回起動へ持ち越されるので、 + そのまま終了すると「表示メニューを押しても何も起きない」 + (実際には一度隠してから出し直している)ように見える。 + 幅が無いものは隠れていると見なす。 + """ + sizes = self.main_splitter.sizes() + hidden = self.device_tree.isHidden() or (sizes and sizes[0] < 40) + self.device_tree.setVisible(hidden) + if hidden and sizes and sizes[0] < 40: + spare = max(sizes[1] - self.DEVICE_LIST_WIDTH, 100) + self.main_splitter.setSizes( + [self.DEVICE_LIST_WIDTH, spare] + sizes[2:]) if hasattr(self, "toggle_device_list_action"): - self.toggle_device_list_action.setChecked(show) + self.toggle_device_list_action.setChecked(hidden) def _toggle_sftp_panel(self): """SFTPクライアントパネルの表示/非表示を切り替え""" diff --git a/tests/test_device_list_hiding.py b/tests/test_device_list_hiding.py index e01b56e..8cce7dd 100644 --- a/tests/test_device_list_hiding.py +++ b/tests/test_device_list_hiding.py @@ -52,13 +52,27 @@ def test_the_view_menu_brings_it_back(self): self.assertFalse(w.device_tree.isHidden()) self.assertTrue(w.toggle_device_list_action.isChecked()) - def test_coming_back_from_a_collapsed_handle_has_width(self): - """ハンドルで幅 0 まで畳んだあとでも、戻したら見えること。""" + def test_one_click_brings_back_a_list_collapsed_to_zero(self): + """幅 0 まで畳んだ状態からは、1 回で戻ること。 + + 分割位置は次回起動へ持ち越される。幅 0 のまま終了したあと、 + 表示メニューを押しても何も起きない(実際には一度隠してから + 出し直している)ように見えると、戻せないと受け取られる。 + """ w = self.window() sizes = w.main_splitter.sizes() w.main_splitter.setSizes([0, sum(sizes[:2])] + sizes[2:]) - w._toggle_device_list() # 隠す - w._toggle_device_list() # 戻す + w._toggle_device_list() # 1 回で戻る + self.assertFalse(w.device_tree.isHidden()) + self.assertGreaterEqual(w.main_splitter.sizes()[0], 100) + + def test_coming_back_from_hiding_has_width(self): + """隠してから戻したときも、幅があること。""" + w = self.window() + sizes = w.main_splitter.sizes() + w.main_splitter.setSizes([0, sum(sizes[:2])] + sizes[2:]) + w.device_tree.setVisible(False) + w._toggle_device_list() self.assertFalse(w.device_tree.isHidden()) self.assertGreaterEqual(w.main_splitter.sizes()[0], 100) From b204c59ee1cbd6f901c683d3b609f278f5ad4179 Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:45:28 +0900 Subject: [PATCH 28/29] fix(update): render the release notes instead of showing the markup The notes box printed the release body verbatim, so a changelog arrived as a wall of #, ### and - with the text buried in it. Now that the body carries the changelog rather than an install guide, that markup is most of what is on screen. It renders as markdown, falling back to plain text if that ever fails. Checked end to end for a v1.1.0 user finding v1.3.0: all three skipped versions arrive in the dialog, in order, with the version already installed left out. Co-Authored-By: Claude Opus 5 --- src/ui/dialogs/update_dialog.py | 8 ++++++- tests/test_update_notes.py | 39 +++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/ui/dialogs/update_dialog.py b/src/ui/dialogs/update_dialog.py index 39f1a80..7646e0b 100644 --- a/src/ui/dialogs/update_dialog.py +++ b/src/ui/dialogs/update_dialog.py @@ -139,7 +139,13 @@ def _setup_ui(self): # リリースノート表示エリア self.notes_text = QTextEdit() self.notes_text.setReadOnly(True) - self.notes_text.setPlainText(self.update_info.get('release_notes', 'リリースノートがありません')) + # リリースノートは Markdown で書かれている。そのまま出すと + # 見出しの # や箇条書きの - が記号のまま並んで読みにくい + notes = self.update_info.get('release_notes') or 'リリースノートがありません' + try: + self.notes_text.setMarkdown(notes) + except Exception: + self.notes_text.setPlainText(notes) self.notes_text.setMaximumHeight(150) layout.addWidget(self.notes_text) diff --git a/tests/test_update_notes.py b/tests/test_update_notes.py index 352fed2..8c76e7a 100644 --- a/tests/test_update_notes.py +++ b/tests/test_update_notes.py @@ -113,5 +113,44 @@ def test_nothing_newer_means_no_update(self): self.assertFalse(info["available"]) +class DialogDisplayTest(unittest.TestCase): + """通知として実際に何が見えるか。""" + + @classmethod + def setUpClass(cls): + import os + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PyQt6.QtWidgets import QApplication + cls.app = QApplication.instance() or QApplication([]) + + def shown(self, releases, current="1.1.0"): + from ui.dialogs.update_dialog import UpdateDialog + info = check(releases, current=current) + # ダイアログを変数で持つ。手放すと Qt 側が中身ごと片付けてしまう + self.dialog = UpdateDialog(None, info) + return self.dialog.notes_text.toPlainText() + + def test_every_skipped_version_reaches_the_dialog(self): + text = self.shown([release("1.3.0", "### 追加\n- 機能C"), + release("1.2.0", "### 追加\n- 機能B"), + release("1.1.1", "### 修正\n- 不具合A")]) + for word in ("1.3.0", "機能C", "1.2.0", "機能B", "1.1.1", "不具合A"): + self.assertIn(word, text) + + def test_markdown_is_rendered_not_shown_as_symbols(self): + """見出しや箇条書きの記号が、そのまま並ばないこと。""" + text = self.shown([release("1.3.0", "### 追加\n- 機能C")]) + self.assertNotIn("###", text) + self.assertNotIn("# v1.3.0", text) + self.assertIn("機能C", text) + + def test_an_empty_body_does_not_leave_the_box_blank(self): + from ui.dialogs.update_dialog import UpdateDialog + info = check([release("1.3.0", "")], current="1.1.0") + info["release_notes"] = "" + self.dialog = UpdateDialog(None, info) + self.assertIn("ありません", self.dialog.notes_text.toPlainText()) + + if __name__ == "__main__": unittest.main() From 511b453861328754b577b6de790cbeee2adf73fb Mon Sep 17 00:00:00 2001 From: gomanizm <15093967+gomanizm@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:14:15 +0900 Subject: [PATCH 29/29] ci: verify the release notes before the tag, not after The step that assembles the release body only ran on a tag, so the first time it executed was the release itself - and it had already been caught failing once locally (PowerShell reads [ ] in -like as a character class, so the changelog heading never matched). Publishing is still tag-only, but the assembly now runs on a manual dispatch too, prints the first lines it produced, and fails if a template placeholder survived. Co-Authored-By: Claude Opus 5 --- .github/workflows/build-release.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 3bbcedb..cd7b7bd 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -104,8 +104,9 @@ jobs: # 更新の通知はリリース本文をそのまま出す。案内文だけだと、利用者は # 何が変わったのか分からない。CHANGELOG のこの版の節を先頭へ置き、 # 案内文 (.github/release-body.md) をその下へ続ける。 + # タグのときだけに絞らない。公開はしないので手動実行でも走らせ、 + # 本文の組み立てが壊れていないことをタグを打つ前に確かめられるようにする - name: Build release notes - if: startsWith(github.ref, 'refs/tags/') shell: pwsh run: | $version = '${{ steps.version.outputs.version }}' @@ -128,6 +129,9 @@ jobs: $body = "## v$version で変わったこと`n`n$section`n`n---`n`n$template" Set-Content -Path release_notes.md -Value $body -Encoding utf8 Write-Host "本文 $($body.Length) 文字 (うち変更点 $($section.Length) 文字)" + Write-Host "--- 先頭 8 行 ---" + Get-Content release_notes.md -Encoding utf8 -TotalCount 8 | Write-Host + if ($body -match '\{\{TAG\}\}') { throw 'テンプレートの置換が残っています' } - name: Create Release if: startsWith(github.ref, 'refs/tags/')