diff --git a/README.md b/README.md
index ab35392..db8a1ee 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,9 @@
> 현재 개발 경로는 둘로 분리되어 있습니다. 아래의 기존 구현 범위는 비교용 루트 프로토타입을 설명합니다. 고객 요구처럼 원본 UI와 클릭 이벤트를 먼저 1:1 이관하는 새 병렬 앱의 범위와 실행 방법은 [원본 호환 마이그레이션 기준선](docs/LEGACY_PARITY_REBASELINE.md)을 참조하세요.
+원본 이벤트를 코드 단위로 다시 검증하는 현재 작업 순서와 완료 범위는
+[동작 동등성 작업 기록](docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md)을 기준으로 판단합니다.
+
현재 기준 기술 스택은 다음과 같습니다.
- .NET 8 / C#
@@ -21,7 +24,7 @@
- 드래그 정렬 및 위/아래 이동
- 선택 삭제
- 로컬 저장/불러오기
-- Oracle/MariaDB 4개 국내 시장 종목 검색과 실행본 `종목.ini` 31개 컷 선택
+- Oracle/MariaDB 4개 국내 시장 종목 검색과 CP949 `Res\종목.ini` 컷 선택(`%LOCALAPPDATA%` 외부 override → 패키지 후보 → 검증된 31개 내장 목록)
- 기존 운영 흐름을 반영한 `PREPARE` / `TAKE IN` / `NEXT` / `TAKE OUT` UI
- `F8`, `Esc` 단축키와 원본 배경 선택용 F2의 안전 예약
- 원본의 공급자 비의존 SQL/데이터 요청 71개를 .NET 8 Core 프로젝트로 이관
@@ -50,8 +53,9 @@ Oracle/MariaDB 조회 계층과 Tornado/K3D 어댑터의 코드·계약·자동
1. `MBN_STOCK_WEBVIEW.sln`을 엽니다.
2. 솔루션 구성을 `Debug`, 플랫폼을 `x64`로 선택합니다.
-3. `MBN_STOCK_WEBVIEW`를 시작 프로젝트로 설정합니다.
-4. 실행 프로필이 `MBN_STOCK_WEBVIEW (Package)`인지 확인합니다.
+3. 공유 시작 프로필 `Legacy Parity App (VS F5)` 또는
+ `src\MBN_STOCK_WEBVIEW.LegacyParityApp`을 시작 프로젝트로 선택합니다.
+4. 실행 프로필이 `MBN_STOCK_WEBVIEW.LegacyParityApp - Explicit DryRun (Package)`인지 확인합니다.
5. `F5`로 빌드·배포·실행합니다.
이 프로젝트는 MSIX 패키지 ID가 필요한 앱입니다. `bin` 아래의 EXE를 직접 실행하지 말고 반드시 Package 프로필이나 설치된 MSIX로 실행하세요.
@@ -85,7 +89,7 @@ Tornado2의 `PGM` 창은 본 프로그램의 KTAP 명령을 렌더링하는 출
MSIX 생성:
```powershell
-dotnet build MBN_STOCK_WEBVIEW.csproj `
+dotnet build .\src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyParityApp.csproj `
-c Release `
-p:Platform=x64 `
-p:GenerateAppxPackageOnBuild=true `
@@ -103,9 +107,9 @@ MSIX 설치 폴더는 읽기 전용입니다. 로그, 플레이리스트, 운영
## 프로젝트 구조
```text
-MBN_STOCK_WEBVIEW.csproj WinUI 3 / WebView2 / 단일 프로젝트 MSIX
-MainWindow.xaml(.cs) 네이티브 창, WebView 초기화, 보안 경계, 메시지 브리지
-Web/ FarPoint를 대체하는 로컬 운영 UI
+src/MBN_STOCK_WEBVIEW.LegacyParityApp/ WinUI 3 / WebView2 / 원본 동작 호환 MSIX 앱
+ MainWindow*.cs, Web/ 네이티브 권위·보안 경계와 로컬 운영 UI
+MBN_STOCK_WEBVIEW.csproj 이전 프로토타입 앱(기본 F5 대상 아님)
src/MBN_STOCK_WEBVIEW.Core/ 이관된 데이터 요청 및 공급자 비의존 Core
src/MBN_STOCK_WEBVIEW.Infrastructure/ Oracle/MariaDB 공급자·설정·복원력·health
src/MBN_STOCK_WEBVIEW.Playout/ x64 Tornado/K3D STA·COM 어댑터와 안전 게이트
diff --git a/docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md b/docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md
new file mode 100644
index 0000000..683ec7e
--- /dev/null
+++ b/docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md
@@ -0,0 +1,288 @@
+# MBN_STOCK_N 동작 동등성 작업 기록
+
+기준 원본은 `C:\Users\MD\source\repos\MBN_STOCK_N`이며 항상 읽기 전용으로 분석한다.
+대상은 `MBN_STOCK_WEBVIEW.LegacyParityApp`이다. 화면 모양만 비슷하게 만드는 것이 아니라
+원본 이벤트의 입력, 상태 전이, DB 조회, K3D 호출 순서와 화면 피드백을 코드 단위로 대조한다.
+
+## 판정 절차
+
+1. 원본 이벤트 핸들러와 관련 필드를 추적한다.
+2. 정상 동작과 명백한 원본 버그를 구분한다.
+3. 정상 동작을 특성화 테스트로 고정한다.
+4. C# authoritative state와 WebView 표시를 함께 수정한다.
+5. 안전 게이트를 유지한 상태에서 Debug/Release x64 빌드와 전체 테스트를 실행한다.
+
+## Slice 1: 선택 행, F8, NEXT, TAKE OUT
+
+상태: 코드 및 자동 테스트 완료, 실제 PGM 명령은 실행하지 않음.
+
+원본 근거:
+
+- `MainForm.cs:1078` `PlayList_CellClick`
+- `MainForm.cs:1915` `btnTake_Click`
+- `MainForm.cs:2020` `ONAirMode`
+- `MainForm.cs:2081` `btnTakeout_Click`
+- `MainForm.cs:2120` `btnNext_Click`
+- `MainForm.cs:2176` `Next_Scene`
+- `MainForm.cs:2495` `Show_PlayList`
+
+고정한 계약:
+
+- 운영자 선택 후보와 현재 PROGRAM 행을 독립 상태로 유지한다.
+- F8은 IDLE/PREPARED/PROGRAM 모두에서 그 시점의 선택 후보를 새로 Prepare 후 Play한다.
+- 선택 후보가 비활성이면 아래 방향의 첫 활성 행만 사용하며 순환하지 않는다.
+- NEXT는 선택 후보가 아니라 현재 PROGRAM 행을 기준으로 Page NEXT 또는 다음 활성 컷을 결정한다.
+- 선택 후보는 회색, 현재 PROGRAM 행은 분홍으로 동시에 표시한다.
+- Page NEXT 결과를 상태 표시뿐 아니라 플레이리스트 Page 셀에도 남긴다.
+- F8/PREPARE는 페이지형 컷을 page 1로 다시 시작한다.
+- 마지막 항목에서는 NEXT를 비활성화하고 vendor 명령을 보내지 않는다.
+- TAKE OUT은 `StopAll` 범위이며 IDLE에서도 연결된 엔진에 긴급 정리를 요청할 수 있다.
+
+남은 세부 감사:
+
+- Page 총수를 행에 저장된 원본 `current/total`에서 고정할지, PREPARE 시 fresh DB 결과로
+ 다시 계산할지에 대한 동등성은 PageN 전용 Slice에서 다시 판정한다.
+- column 3의 정확한 한글 문자열로 Page NEXT를 분기하던 원본 조건과 typed builder
+ 식별자를 사용하는 현재 조건의 차이도 같은 Slice에서 검증한다.
+
+유지한 안전 차이:
+
+- SDK Play callback이 pending이면 TAKE OUT 이외 명령을 보내지 않는다.
+- timeout 또는 `OutcomeUnknown`은 재시도하지 않고 격리한다.
+- 결과가 확인되기 전에는 새 PROGRAM 행으로 확정하지 않는다.
+- PROGRAM 중 구조 변경은 현재 단계에서 계속 차단한다.
+
+재현하지 않은 원본 버그:
+
+- 마지막 컷에서 이전 `m_ShowIndex` 때문에 같은 scene을 다시 Play하는 동작
+- 빈 `catch`로 잘못된 page 문자열과 DB/엔진 오류를 숨기는 동작
+- Home/End가 timer와 색상 상태를 깨뜨리는 동작
+- 고정 크기 `s_buf[100]`에 의존하는 Space 토글
+
+## Slice 2: 체크박스, 키보드, 이동, 삭제, drag
+
+상태: 코드 및 자동 테스트 완료, 변경을 합친 package의 실제 마우스 검증은 아직 수행하지 않음.
+
+원본 근거:
+
+- `MainForm.cs:1078` `PlayList_CellClick`
+- `MainForm.cs:1464` `btnup_Click`
+- `MainForm.cs:1579` `Spd_UpDown`
+- `MainForm.cs:1649` `btndelete_Click`
+- `MainForm.cs:1730` `btntdelete_Click`
+- `MainForm.cs:3140` `checkBox1_CheckedChanged`
+- `MainForm.cs:3622` `PlayList_DragDrop`
+- `MainForm.cs:3805` `PlayList_KeyPress`
+- `MainForm.cs:3887` `ProcessCmdKey`
+- `MainForm.Designer.cs:1598` `AllowRowMove`
+
+고정한 계약:
+
+- F8 후보, 현재 PROGRAM, Ctrl/Shift 삭제 집합과 키보드 focus를 서로 다른 상태로 유지한다.
+- F8/PREPARE 성공과 disabled-row skip은 실제 F8 후보만 옮기며 `bufdel`에 해당하는 삭제 집합과
+ `m_newrow/m_oldrow`에 해당하는 Shift anchor를 바꾸지 않는다.
+- 체크박스 열을 누르면 삭제 집합과 Shift anchor는 그대로 두고 그 행만 F8 후보가 된다.
+- Space는 포커스된 실제 행의 현재 enabled 값을 반전하며 별도 100칸 배열을 사용하지 않는다.
+- Home/End는 원본처럼 자동 갱신 timer를 먼저 멈추고 첫/마지막 행을 후보로 만들며 그 행으로
+ scroll한다. DEL 집합, Shift anchor, 현재 입력/ListBox focus, PROGRAM과 Page 표시는 바꾸지 않는다.
+- Up/Down은 마지막 활성 행 하나만 인접 행과 교환하고 이동한 stable row identity를 후보로 유지한다.
+- playlist drag도 Ctrl/Shift 집합 전체가 아니라 실제 drag source 한 행만 before/after로 이동한다.
+- 선택 삭제 뒤 기존 후보가 살아 있으면 같은 row identity를 유지하고, 삭제됐으면 다음 생존 행,
+ 다음 행이 없으면 이전 생존 행을 명시적인 후보로 만든다.
+- 빈 목록에 첫 행을 추가하거나 DB playlist를 불러오면 첫 행이 화면의 후보와 실제 F8 대상이
+ 되지만, 실제 행 클릭 전에는 DEL 집합에 암묵적으로 들어가지 않는다. 화면에 후보가 없는데
+ 내부적으로 0번 행을 재생하던 fallback은 제거했다.
+- PREPARED/PROGRAM/pending/OutcomeUnknown에서는 enabled, 이동, 삭제, row/cut drag를 Web과
+ native 양쪽에서 차단한다. 후보 선택과 컷 단순 선택은 로컬 상태이므로 허용한다.
+
+재현하지 않은 원본 버그:
+
+- `bufdel[100]`, `s_buf[100]`의 제한, 중복 인덱스와 stale selection
+- 선택 삭제가 PROGRAM 행이나 현재 송출 순서를 손상하는 동작
+- 이동·삭제 뒤 `m_rowidx`, `m_newrow`, `m_crow` 숫자 인덱스가 다른 항목을 가리키는 동작
+- Home/End 뒤 focus와 후보가 서로 다른 행을 가리키는 동작
+- 컷 drag payload를 무시하거나 playlist 다중 삭제 집합을 다중 행 drag로 확대하는 동작
+
+## Slice 3: PageN 문자열 분기와 저장 total
+
+상태: 코드 및 집중 자동 테스트 완료, 실제 PGM 명령은 실행하지 않음.
+
+원본 근거:
+
+- `MainForm.cs:2120` `btnNext_Click`
+- `MainForm.cs:2176` `Next_Scene`
+- `MainForm.cs:2495` `Show_PlayList`
+
+고정한 계약:
+
+- 실제 operator row는 column 3 `GraphicType`이 정확히 `5단 표그래프`, `6종목 현재가`,
+ `12종목 현재가`일 때만 같은 행 Page NEXT를 허용한다.
+- 위 세 문자열은 각각 5·6·12개 단위와 연결하고, row의 `current/total` 중 total을
+ 1..20 범위에서 읽어 PREPARE부터 TAKE OUT까지 고정한다.
+- on-air 중 DB 결과가 늘어도 저장 total 뒤의 새 page를 노출하지 않고, 줄어든 결과로
+ 현재/다음 page를 만들 수 없으면 vendor NEXT 전에 fail-closed한다.
+- 같은 paged scene code로 해석되더라도 column 3 문자열이 정확하지 않으면 일반 행으로
+ 취급하여 다음 enabled playlist row로 이동한다.
+- paged row의 마지막 page에서 다음 playlist row로 이동한 뒤에는 원본처럼 자동 갱신을
+ 다시 시작하지 않는다. 일반 행에서 다음 row로 이동한 경우에만 다시 시작한다.
+- 요구사항의 20 page cap, 0건 fail-closed, 마지막 row에서 재생 반복 금지는 유지한다.
+ 원본의 101/121/241건에서 21 page가 되는 off-by-one, `1/0`, stale scene 재생은 명시적인
+ 안전 교정이며 문자 그대로 재현하지 않는다.
+- timer refresh의 새 구현은 불완전할 수 있는 `GetPlayingScene` proxy가 아니라 adapter가
+ 보존한 on-air scene reference를 갱신한다는 실제 구현 계약으로 주석과 테스트 이름을 바로잡았다.
+
+자동 검증:
+
+- Core: 저장 total 고정, DB 증가/감소, refresh, 없는 다음 page 차단, 비정규 문자열의
+ playlist NEXT, metadata 없는 직접 Core 호출의 기존 fresh-plan 동작.
+- Application: 세 정확한 문자열의 5·6·12 mapping, suffix total 고정, 유사 문자열 차단,
+ 0/21 및 잘못된 `current/total` 거부.
+
+## Slice 4: 종목 검색과 런타임 컷 메뉴
+
+상태: 코드 및 자동 테스트 완료, 실제 DB/Tornado 명령은 실행하지 않음.
+
+원본 근거:
+
+- `MainForm.cs:399`, `MainForm.cs:596` `종목.ini`/`INI_parser`
+- `MainForm.cs:844` `button9_Click`
+- `MainForm.cs:859` `searchStock`
+- `MainForm.cs:3039` `txtSearch_KeyPress`
+- `MainForm.cs:3427` `listView1_MouseDown`
+- `MainForm.cs:3534` `listView1_MouseMove`
+- `MainForm.cs:3635` `PlayList_DragDrop`
+
+고정한 계약:
+
+- 빈 검색 버튼은 기존 결과·종목 선택·컷 선택을 유지하고, 빈 Enter만 전체 조회를 실행한다.
+- 새 조회는 종목 선택만 지우며 컷 선택은 유지한다. 이후 새 종목을 선택해 drag/drop하면
+ 남아 있던 컷을 새 종목 identity로 추가한다.
+- 컷 Ctrl/Shift, 정확한 좌표와 500ms 미만 double-click, 물리 순서 drag, 중복 drop을 유지한다.
+- 일반 시장의 `-` 구분행은 무시하고 NXT에서는 원본 경고 modal 순서를 유지한다.
+- CP949 `종목.ini`는 `%LOCALAPPDATA%` 외부 override, package 후보 순서로 읽는다. 둘 다 없거나
+ 유효하지 않으면 검증된 31개 내장 목록으로 폴백한다. 외부 파일이 존재하지만 잘못됐으면
+ 다른 파일로 우회하지 않고 즉시 폴백한다.
+- 최대 50개 물리행, 64 KiB, 정확한 `[종목]`, 알려진 컷과 구분행만 허용하고 부분 결과와
+ 실제 경로는 상태에 노출하지 않는다. 실제 INI 파일은 Git/MSIX에 넣지 않는다.
+- 런타임 권위본은 원본의 `bin\Debug\Res\종목.ini`이며 현재 내장 fallback과 SHA-256이 같다.
+ 이 파일과 `s5001`이 사용하는 `시간외단일가`를 유지한다. 소스 트리 `Res\종목.ini`의
+ 서로 다른 `시간외종가` 표기는 실제 실행 기준으로 되돌리지 않는다.
+
+## Slice 5: 전역 키, 배경과 DissolveTime
+
+상태: 코드 및 자동 테스트 완료, 실제 PGM 명령은 실행하지 않음.
+
+원본 근거:
+
+- `MainForm.cs:964` `MainForm_KeyDown`
+- `MainForm.cs:3887` `ProcessCmdKey`
+- `MainForm.cs:4008` `btnback_Click`
+- `MainForm.cs:4037` `ckback_CheckedChanged`
+- `MainForm.cs:488`, `MainForm.cs:1059` `ComboDi.SelectedIndex`
+
+고정한 계약:
+
+- modal에서 처리한 Escape/F2/F3/F8은 뒤의 playout key listener로 전달하지 않는다. 이름 있는
+ playlist modal의 Escape는 modal만 닫고 TAKE OUT을 보내지 않는다.
+- Delete는 원본 KeyPreview처럼 playlist 삭제 intent를 보내되 기본 키를 억제하지 않아,
+ 텍스트 입력이 focus를 가진 경우 문자 삭제도 정상 동작한다.
+- 시작 기본 배경은 `Cuts\video\기본.vrv`, F2/F3 기본 배경은 `Cuts\..\배경\기본.vrv`로
+ 분리하고 두 신뢰 root가 서로 대신하지 않게 했다.
+- 화면에 원본 `DissolveTime` 1..20을 표시하고 C#에는 인덱스 0..19만 보낸다. 기본 인덱스는
+ 6(화면 7)이며, PREPARE/F8 때 현재 전역 인덱스를 기존 playlist 모든 entry에 적용한다.
+ 행 생성 시 저장된 기본 6이 전역 선택을 덮어쓰던 경로는 제거했다.
+
+## Slice 6: 탭 재진입과 dragover 교환
+
+상태: 코드 및 자동 테스트 완료, 실제 DB 쓰기/Tornado 명령은 실행하지 않음.
+
+원본 근거:
+
+- `MainForm.cs:2937` `tabControl1_SelectedIndexChanged`
+- `MainForm.cs:3183` `tab_MouseDown`
+- `MainForm.cs:3214` `tab_MouseMove`
+- `MainForm.cs:3234` `tab_DragOver`
+
+고정한 계약:
+
+- 코스피/코스닥은 재진입마다 목록을 다시 조회하고 선택·비교쌍을 초기화한다.
+- 비교는 저장된 CP949 비교쌍을 매 재진입마다 다시 읽고, 해외종목·거래정지는 검색 결과,
+ 선택과 정렬 상태를 새 UC처럼 초기화한다. MA5/MA20은 MainForm 전역 상태로 유지한다.
+- 폐기된 탭의 늦은 비동기 결과는 activation generation으로 무시한다.
+- 좁은 탭을 넓은 탭 오른쪽으로 옮길 때는 `target.right - source.width` 초과, 왼쪽은
+ `target.left + source.width` 미만에서만 교환한다. 같거나 넓은 source는 진입 즉시 교환한다.
+- 같은 crossing의 반복 dragover와 busy 거부가 중복/누락 교환을 만들지 않게 했다.
+
+## Slice 7: UC1 트리 이벤트와 이동평균선 샘플링
+
+상태: 코드 및 자동 테스트 완료, 실제 DB/Tornado 명령은 실행하지 않음.
+
+원본 근거:
+
+- `Control/UC1.cs:121-139`, `1303-1340`, `1393-1407`
+- `Scene/s8010.cs:431-453`, `765-807`
+- `MainForm.cs:2495+`, `2937-2999`
+
+고정한 계약:
+
+- 루트 라벨 단일 클릭은 선택만 하며 disclosure만 펼침/접기를 수행한다.
+- 루트·리프 더블클릭은 펼침 상태를 바꾸지 않고 C# action을 정확히 한 번 보낸다.
+- 같은 탭의 상태 render는 open/selection/focus/scroll을 보존하고, 실제 탭 재진입은 원본 UC1처럼
+ Expand=true, 전체 펼침, 첫 루트 선택·focus·scroll로 초기화한다.
+- Busy/고정 섹션 배치 중에는 action뿐 아니라 로컬 disclosure 변경도 차단한다.
+- MA5/MA20은 행 추가 시 저장하지 않고 s8010 PREPARE, F8, NEXT, 자동 갱신의 장면 생성 시점마다
+ 현재 전역 체크 상태를 다시 읽는다. named playlist identity에도 MA 플래그를 저장하지 않는다.
+- 원본의 `uc_3 == null` 부모 더블클릭 예외와 부분 추가 버그는 재현하지 않는 명시적 결함 제외다.
+
+상세 근거와 결함 제외는 `docs/UC1_TREE_EVENT_PARITY.md`에 기록했다.
+
+## Slice 8: K3D 원시 인자, callback 게이트, 컷 ListView 경계
+
+상태: 코드 및 자동 테스트 완료, 실제 PGM 명령은 실행하지 않음.
+
+원본 근거:
+
+- `MainForm.cs:2628-2639` 공통 `SetBackgroundVideo(path, 2004, 10)`
+- `MainForm.cs`의 `SetSceneEffectType(10, FADE, ComboDi.SelectedIndex)` 호출
+- `MainForm.cs:2651+`, `1992`의 3,000,000초 Int32 곱셈/Timer.Interval 결과
+- `MainForm.cs:3427-3525`, `3566-3588`, `3635-3643` 컷 선택·드롭
+- `MainForm.cs:3887-3906` Home/End 후 base key 처리
+
+고정한 계약:
+
+- K3D scene effect 첫 인자는 bool로 정규화하지 않고 원본 raw `10`을 전달한다.
+- 공통 배경은 raw `(2004, 10)`, s5006 장면 내부 배경은 `(1, 1)`을 구분한다.
+- OnCutOut/OnStopAll 완료 전에는 PREPARE, F8, NEXT, 갱신과 중복 TAKE OUT을 SDK에 보내지 않고
+ Web 버튼과 운영 데이터 mutation도 잠근다. pending Play 중 비상 TAKE OUT 1회 경로는 유지한다.
+- 5076/5079/5080/5081은 원본의 unchecked Int32 overflow 뒤 Timer.Interval 예외 때문에 실제 자동
+ 갱신이 시작되지 않으므로 34.7일 타이머로 바꾸지 않고 명시적으로 비활성화한다.
+- 런타임 컷 메뉴 bridge 범위를 원본 물리행과 같은 0..49로 통일한다.
+- 연속 Shift/Ctrl+Shift는 Win32 ListView selection mark를 고정하고, 빈 영역 클릭은 선택을 지우되
+ native anchor는 유지한다. 빈 영역 뒤 stale 컷 drop은 0행이다.
+- PREPARED/PROGRAM 중 컷 pointer double-click의 암묵적 행 추가를 native/Web 양쪽에서 차단한다.
+- Home/End의 playlist/timer 부작용 뒤에도 검색 입력 caret과 검색결과 기본 선택 동작을 보존한다.
+- DissolveTime의 모든 외부·Core 경계는 원본 ComboBox index인 0..19만 허용한다.
+
+## Slice 9: 종목 ListBox와 컷 ListView 키보드
+
+상태: 코드 및 자동 테스트 완료, 실제 package 키보드 검증은 아직 수행하지 않음.
+
+고정한 계약:
+
+- 종목 결과는 하나의 tab stop을 갖고 ArrowUp/Down, Home/End와 10행 viewport의
+ PageUp/PageDown 9행 이동을 재현한다. double-click도 선택만 바꾸고 컷을 추가하지 않는다.
+- 종목 Home/End는 결과 선택과 focus를 보존한 채 전역 playlist 경계 부수 효과도 실행한다.
+- 컷 목록은 물리행과 separator를 포함해 ArrowUp/Down, Home/End, Space를 처리한다.
+- 컷 Ctrl/Shift/Ctrl+Shift는 Win32 selection mark 규칙을 C# 권위 상태에서 적용하고 키보드
+ 선택은 마우스 전용 `selectedRowsList`에 섞지 않는다. drop은 native 선택 집합을 사용한다.
+- PREPARED/PROGRAM/pending/unknown 또는 종목 미선택 상태에서는 컷 선택과 focus 이동을 함께
+ 차단해 DOM과 C# 상태가 어긋나지 않게 한다.
+
+## 다음 대조 순서
+
+1. 컷 ListView의 drag 시작 임계값과 실제 package 키보드 회귀
+2. GraphE/FSell/VI/PList/AList/ThemeA/EList modal별 남은 키·focus·취소 이벤트
+3. 자동 갱신 UI의 실제 실행 상태 기반 통합 테스트와 종료 상태 행렬
+4. Debug/Release x64와 새 package에서 실제 마우스·키보드·drag 회귀
+
+각 항목은 실제 원본 코드 근거와 회귀 테스트가 모두 생기기 전에는 완료로 판정하지 않는다.
diff --git a/docs/LEGACY_FEATURE_AUDIT.md b/docs/LEGACY_FEATURE_AUDIT.md
index bff0265..51c36b6 100644
--- a/docs/LEGACY_FEATURE_AUDIT.md
+++ b/docs/LEGACY_FEATURE_AUDIT.md
@@ -49,10 +49,10 @@ HELLO/BYE 연결은 2026-07-15에 확인했다. scene/PREPARE/TAKE/PGM은 실행
| LF-014 | P1 | ThemeA/EList | 완료 | 검색·신규·편집·삭제·종목 구성·Delete key, ThemeA exact-title 중복확인, EList opaque `rowId` drag 재정렬 이관. 실제 UI에서 ThemeA 500건 중복 read와 EList 5행 reorder 확인 | scene PGM은 LF-005 |
| LF-015 | P1 | UC5 해외 | 완료 | 해외업종·해외종목·해외지수 조회와 playlist 경로 이관 | s6001 13개 영상은 LF-001 |
| LF-016 | P1 | F2/F3 기본 배경 | 외부자산 필요 | sibling background root, picker, background-none와 trusted path 검사는 완료. 이 장비에 `기본.vrv`가 없음 | 제공 전 fail closed |
-| LF-017 | P1 | 시스템 종료 확인 | 완료 | 기본 취소 확인창, 중립 문구, 종료/취소 두 버튼과 자동 TAKE OUT/반대 명령 금지 경계를 코드·자동 테스트로 유지 | 현재 Release package-context 회귀는 LF-005와 함께 판정 |
+| LF-017 | P1 | 시스템 종료 확인 | 완료 | 원본 제목 `매일경제TV`, 중립 문구, 예/아니요와 기본 아니요를 유지하며 자동 TAKE OUT/반대 명령 금지 경계를 코드·자동 테스트로 유지 | 현재 Release package-context 회귀는 LF-005와 함께 판정 |
| LF-018 | P1 | 원본 비교쌍/수동 파일 | 완료 | `종목비교.dat` 8쌍 1회 import·재시작 marker, FSell 3×5행과 VI 9항목 trusted parser/store 검증 | 원본 파일은 계속 읽기 전용 |
| LF-019 | P1 | 실제 DB read→DTO→mutation | 완료 | Oracle/MariaDB 필수 scene selector 33/33, 전체 query 58건 PASS | 외부 asset/PGM 성공과 별도 |
-| LF-020 | P2 | FarPoint 대체 | 완료 | 실제 마우스로 playlist 다중행 reorder·checkbox 차단·탭 release 전 dragover 교환·EList reorder 확인. row drop 버블링의 cut append와 Chromium checkbox-origin target 문제를 수정하고 DOM 회귀로 고정 | 활성 Print/Excel export는 원본에도 없음 |
+| LF-020 | P2 | FarPoint 대체 | 현재 빌드 실측 제외 | source 단일 행 drag, F8 후보·PROGRAM·DEL 집합·Shift anchor 분리, 종목 ListBox와 컷 ListView 키보드/selection mark를 DOM/C# 회귀로 고정. 탭 dragover와 EList reorder는 기존 실측 유지 | 현재 변경을 합친 package에서 playlist 단일 행 drag와 키보드/focus를 다시 실측; 활성 Print/Excel export는 원본에도 없음 |
| LF-021 | P2 | 패키지 단일 인스턴스·모달 격리 | 완료 | 두 번째 activation 전달, 강제 종료 없음. modal에서 F2/F3/F8/Escape와 playlist key 격리 | 새 MSIX 전체 회귀 |
| LF-022 | P2 | 영구 운영 로그/bridge 오류 | 완료 | bounded/redacted log와 toast, malformed payload가 송출 상태를 바꾸지 않음 | authoritative Network Monitoring과 함께 판독 |
| LF-023 | P1 | NXT 저장 playlist restore | 외부 데이터 정합성 | stale code를 제목/current identity로 재검증하는 닫힌 경로는 완료. 현재 `v_all_stock` 결과가 비어 preview를 fail closed | DBA/data 정합성 없이는 direct-table 추정 금지 |
@@ -69,9 +69,9 @@ intent를 만들지 않는 것이 원본과 동등하다. 종료의 자동 `Stop
현재 `DRAG` 구현은 다음 세 C# 권위 경로를 포함한다.
-- playlist 행 drag는 선택된 비연속 행도 현재 표시 순서를 보존한 한 블록으로 임의
- before/after 위치에 옮긴다. 체크박스에서 시작한 drag와 PREPARED/PROGRAM/pending/unknown
- 상태의 변경은 차단한다.
+- playlist 행 drag는 FarPoint `AllowRowMove`의 source 행 하나를 임의 before/after 위치로
+ 옮긴다. Ctrl/Shift 집합은 삭제 선택으로만 유지하며, 체크박스에서 시작한 drag와
+ PREPARED/PROGRAM/pending/unknown 상태의 변경은 차단한다.
- 시장 탭은 drop 때가 아니라 dragover 대상에 들어갈 때 교환한다. source/target identity와
예상 두 인덱스가 C# 현재 순서와 일치할 때만 적용해 중복 메시지가 순서를 되돌리지 않는다.
- 원본 UC6 `FpSpread2.AllowDragDrop`은 ThemeA/EList 편집행의 opaque `rowId` drag로
@@ -82,6 +82,12 @@ ThemeA의 원본 `중복확인`도 이름만 담은 strict intent로 연결했
결과 불명은 `Indeterminate`이며 write나 추정 fallback이 없다. 원본처럼 edit 자기 행을
제외하지 않아 변경하지 않은 이름은 중복으로 판정된다.
+명백한 결함 또는 송출 안전을 해치는 동작은 동등성 예외로 유지한다. 마지막 행에 NEXT로
+도착한 뒤 stale `m_ShowIndex` 때문에 같은 scene을 다시 `Play(10)`하는 동작, 실패한 Page NEXT를
+성공 전에 화면 숫자에 반영하는 동작, `s_buf[100]` shadow 값 때문에 첫 Space가 실제 체크값과
+어긋나는 동작은 재현하지 않는다. 마지막 NEXT는 no-wrap/no-command, Page 표시는 성공 뒤 반영,
+Space는 현재 authoritative enabled 값을 반전한다.
+
## Development Live strict local authorization
[`DEVELOPMENT_LIVE_VISUAL_STUDIO.md`](DEVELOPMENT_LIVE_VISUAL_STUDIO.md)의 시작 구성은
diff --git a/docs/LEGACY_PARITY_VALIDATION_20260715.md b/docs/LEGACY_PARITY_VALIDATION_20260715.md
index 8800369..bce31af 100644
--- a/docs/LEGACY_PARITY_VALIDATION_20260715.md
+++ b/docs/LEGACY_PARITY_VALIDATION_20260715.md
@@ -32,7 +32,7 @@ Debug/Release 및 Web 회귀 수는 확정했지만, scene PREPARE/TAKE IN/PGM
| 독립 클릭 | UC4 테마, UC6 전문가, UC7 거래정지의 원본 단일 클릭과 UC4/UC6 행별 독립 처리 |
| 전용 입력 | GraphE, FSell, VI, PList, AList, ThemeA, EList를 WebView 화면과 C# intent로 연결; ThemeA exact-title 중복확인 포함 |
| 해외 | UC5 해외업종·해외종목·해외지수 조회와 playlist 생성 |
-| playlist | 단일/Ctrl/Shift 선택, enabled, 위/아래, 선택/전체 삭제와 비연속 다중 선택 block drag; PREPARED/PROGRAM/pending/unknown 변경 차단 |
+| playlist | F8 후보와 Ctrl/Shift 삭제 선택 분리, enabled, 위/아래, 선택/전체 삭제와 drag source 단일 행 이동; PREPARED/PROGRAM/pending/unknown 변경 차단 |
| FarPoint 대체 | Web table로 표현하되 원본 클릭·더블클릭·정렬·이동·저장, 탭 dragover 교환과 UC6/EList opaque 행 drag 의미 유지 |
상세 이벤트 대조는 [`OPERATOR_UI_PARITY.md`](OPERATOR_UI_PARITY.md)를 따른다.
@@ -43,8 +43,8 @@ Debug/Release 및 Web 회귀 수는 확정했지만, scene PREPARE/TAKE IN/PGM
[`LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md`](LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md)에 있다.
186개 구독/171개 고유 핸들러 수에는 선언만 있고 구독되지 않은 메서드를 넣지 않았다.
-- playlist drag는 opaque source/target `rowId`와 before/after만 Web에서 보내며, C#이 현재
- 선택 집합을 표시 순서대로 한 블록으로 이동한다. 체크박스 dragstart는 거부한다.
+- playlist drag는 opaque source/target `rowId`와 before/after만 Web에서 보내며, C#이 실제
+ drag source 한 행만 이동한다. Ctrl/Shift 집합은 삭제 선택으로 유지하고 체크박스 dragstart는 거부한다.
- 시장 탭은 dragover 대상 진입 시 source/target identity와 예상 인덱스를 보낸다. C# 현재
순서가 예상값과 일치할 때만 교환하므로 같은 전달의 재처리는 no-op이다.
- UC6의 `FpSpread2.AllowDragDrop`은 ThemeA/EList 편집 행의 opaque `rowId` 재정렬로
@@ -59,8 +59,10 @@ Debug/Release 및 Web 회귀 수는 확정했지만, scene PREPARE/TAKE IN/PGM
- `삼성` 검색 결과는 **63개**였고 `삼성출판사`를 선택했다. 컷 3개를 선택한 뒤 한 번의
drop으로 playlist에 정확히 3행을 추가했다.
-- playlist 여러 행을 실제 마우스로 선택해 한 블록으로 재정렬했고, enabled 체크박스에서
- 시작한 drag는 행 이동을 만들지 않았다.
+- 당시 package에서는 playlist 여러 행을 한 블록으로 재정렬했고, enabled 체크박스에서
+ 시작한 drag는 행 이동을 만들지 않았다. 이후 원본 `AllowRowMove` 재감사에서 블록 이동은
+ 근거 없는 확장으로 판정되어 현재 코드는 drag source 한 행만 이동하도록 교정됐다. 따라서
+ 이 항목의 과거 block-drag 실측은 현재 단일 행 drag의 package 실측으로 재사용하지 않는다.
- 시장 탭은 마우스 버튼을 놓기 전 dragover 대상 진입 시 순서가 바뀌었다.
- EList 편집기에서 추천행 **5개**의 순서를 drag로 변경했다.
- ThemeA 목록 **500건**을 불러온 상태에서 `중복확인` exact-title read가 성공했다.
@@ -74,7 +76,7 @@ Debug/Release 및 Web 회귀 수는 확정했지만, scene PREPARE/TAKE IN/PGM
조상 행으로 보고할 수 있어 단순 `event.target.closest("input")` 검사만으로는 부족했다.
pointerdown 때 checkbox origin의 행 ID를 기억하고 이어지는 row dragstart를 거부하며,
pointerup/pointercancel/dragend에서 상태를 지우도록 수정했다. thin-client 회귀 테스트는
-이 Chromium target 형태, 다중 선택 보존, row drop의 ancestor 무시, 외부 text 거부와 exact
+이 Chromium target 형태, drag source 한 행만 전송, row drop의 ancestor 무시, 외부 text 거부와 exact
selected-cut token의 단일 처리를 실제 DOM event 순서로 고정한다.
### Development Live 시작 구성 증적
@@ -168,6 +170,26 @@ drag만 5행 순서를 바꾸는 것도 확인했다. .NET Debug/Release 합계
scene PREPARE/TAKE/PGM을 실행하지 않은 이 Live 연결 회차를 전체 방송 송출 PASS로 확대하지
않는다.
+### 코드 대조 Slice 1~9 합본 추가 검증
+
+이후 원본 이벤트를 다시 코드 단위로 대조해 F8 후보·PROGRAM·DEL 다중선택·Shift anchor를
+분리하고, 런타임 컷 50행, 종목/컷 키보드, UC1 tree, s8010 이동평균 재샘플링, K3D raw 인자와
+OnCutOut/OnStopAll callback 게이트를 합쳤다. 이 합본의 검증 결과는 다음과 같다.
+
+| 항목 | 추가 검증 결과 |
+|---|---|
+| .NET Debug x64 | **2,584/2,584** 통과, 실패·건너뜀 0 |
+| .NET Release x64 | **2,584/2,584** 통과, 실패·건너뜀 0 |
+| Web Node | **456/456** 통과, 실패·취소·건너뜀·TODO 0 |
+| 솔루션 Debug/Release x64 | NuGet x64 복원 후 각각 경고 0 / 오류 0 |
+| Visual Studio 2026 MSBuild | VS **18.7.4**의 x64 MSBuild로 Debug/Release 솔루션 빌드 모두 성공 |
+| 최신 unsigned Release MSIX | **15,228,096 bytes**, SHA-256 `3D7FC0C0837756C860AC660DBCEA30BF543D261FE0EEEAE1CDA294FA95CC3BF1` |
+| 패키지 감사 | 75항목, `.t2s`·실설정·라이선스·인증서·K3D vendor 파일 0 |
+| package-context DryRun | 최신 Release layout 실행·응답 확인, 인자 없음, 앱 소유 TCP 0, 중립 종료창 `매일경제TV`/`예`/`아니요` 확인 후 정상 종료 |
+
+이 unsigned MSIX는 개발 package-context 실행 검증용이며 위 signed 배포 패키지를 대체하지
+않는다. 이 추가 회차에서도 scene/PREPARE/TAKE/NEXT/DB write와 Tornado 연결은 실행하지 않았다.
+
## 판정 경계
- 35/35 자동 장면 동등성은 모든 장면의 실제 PGM 출력 완료를 뜻하지 않는다.
diff --git a/docs/LEGACY_PLAYOUT_ANALYSIS.md b/docs/LEGACY_PLAYOUT_ANALYSIS.md
index 31aeaa9..e3c2a0d 100644
--- a/docs/LEGACY_PLAYOUT_ANALYSIS.md
+++ b/docs/LEGACY_PLAYOUT_ANALYSIS.md
@@ -18,7 +18,7 @@
새 `DynamicK3dSession`도 같은 순서를 사용한다. output channel이 명시된 경우에만 `EndTransactionOnChannel`을 사용하며 layout `10`은 `Prepare`/`Play`/`CutOut`에만 전달한다. transaction 중 실패하면 `RollbackTransaction`을 시도하고 원래 실패를 보존한다.
-공통 scene fade/background는 Web 입력이 아니라 로컬 trusted 설정이다. 원본 `ComboDi.SelectedIndex`에 맞춘 fade 기본값은 6이다. background kind와 scene root 아래 상대 asset, video loop를 `PlayoutSceneCompositionFactory`가 검사하며 `DryRun`도 실제 COM 전에 파일 존재, 확장자, root 탈출과 reparse point를 fail-closed 검증한다. 실제 asset 경로는 Web preview나 wire status에 노출하지 않는다.
+공통 scene background 경로는 Web 입력이 아니라 로컬 trusted 설정과 네이티브 파일 선택에서만 온다. fade는 원본 `DissolveTime`/`ComboDi.SelectedIndex`와 같은 0~19 폐쇄형 숫자 intent이며 기본 인덱스는 6(화면 표시 7)이다. K3D 호출도 원본의 raw Int32를 보존해 `SetSceneEffectType(10, FADE, index)`와 공통 `SetBackgroundVideo(path, 2004, 10)`을 사용하고, s5006 내부 배경의 `(1, 1)`과 섞지 않는다. background kind와 scene root 아래 상대 asset, video loop를 `PlayoutSceneCompositionFactory`가 검사하며 `DryRun`도 실제 COM 전에 파일 존재, 확장자, root 탈출과 reparse point를 fail-closed 검증한다. 실제 asset 경로는 Web preview나 wire status에 노출하지 않는다.
## PREPARE, TAKE IN, NEXT, TAKE OUT 상태 전이
@@ -41,6 +41,8 @@ timeout, dispatch 뒤 cancellation 또는 결과가 불명확한 COM 실패는 `
operator command를 시작할 때 timer를 먼저 멈춘다. TAKE IN과 playlist NEXT 성공 뒤 반복 refresh를 시작하고 Page NEXT 뒤에는 원본처럼 다시 시작하지 않는다. 새 runtime은 이전 tracked PLAY의 callback drain을 먼저 확인한 뒤 해당 cut의 첫 `m_time`, 이후 3초의 전체 cooldown을 시작한다. 원본의 UI timer보다 실제 cadence가 callback drain만큼 길어지는 의도적 안전 적응이며, callback 대기와 delay를 겹쳐 `playCompletionPending`이 사실상 계속 유지되는 상태를 방지한다.
+단, 5076/5079/5080/5081의 `m_time=3000000`은 원본에서 `m_time * 1000` Int32가 overflow되어 WinForms `Timer.Interval` 설정이 예외로 끝나므로 자동 refresh를 시작하지 않는다. 이를 34.7일짜리 정상 타이머로 해석하지 않는다.
+
마이그레이션의 same-scene refresh는 retained on-air scene에 fresh DTO를 적용해 `BeginTransaction → mutation → QueryVariables → EndTransaction → Prepare(10) → tracked Play(10)`을 한 번 수행한다. 원본의 선행·후행 PLAY 두 번 중 선행 replay와 불완전한 `GetPlayingScene` proxy를 사용하지 않는 명시적 안전 차이다. 모든 PLAY를 callback accounting과 연결하고 중복 송출을 피하기 위한 결정이며, Round H의 정확한 `PLAY = 4`, `SCENE_PLAYED = 4` 예산으로 검증했다. refresh generation과 공개 상태는 atomic epoch로 묶어 이전 CTS의 늦은 delay, success, fault 또는 completion이 새 generation이나 stopped 상태를 덮어쓰지 못한다.
마지막 부분 page는 남은 row만 채우고 나머지 object를 clear/hide한다. `s5088` NXT 비교와 조회 index는 모두 `i + pageIndex * 12`를 사용한다. page 경계값과 partial-page clearing은 자동 테스트로 검증했다.
@@ -74,6 +76,7 @@ vendor event handler의 `OnScenePlayed`, `OnCutOut`, `OnStopAll`은 managed call
- `OnScenePlayed` 성공 뒤에만 이전 retired scene을 unload/release한다.
- pending Play callback이 있으면 TAKE OUT을 제외한 PREPARE/TAKE IN/NEXT/timer refresh를 fail-closed 차단한다.
+- pending CutOut/StopAll callback이 있으면 중복 TAKE OUT을 포함한 모든 scene 명령을 차단하고, 일치하는 완료 callback을 drain한 뒤에만 다음 PREPARE를 허용한다.
- `CutOut`/`StopAll` dispatch 전에 completion counter를 올리고 동기 호출 실패 시 원복한다. 성공 callback은 대응 counter를 하나 줄이고, stop/cut으로 중단된 Play는 별도 `OnScenePlayed`가 없을 수 있으므로 pending Play accounting을 취소한다.
- pending lifecycle callback, queue overflow, callback failure 또는 connection generation 불일치가 있으면 Disconnect와 조기 unload를 하지 않고 session을 abandon/quarantine한다.
- 이전 generation의 늦은 callback은 현재 scene state를 변경하지 않는다.
diff --git a/docs/OPERATOR_UI_PARITY.md b/docs/OPERATOR_UI_PARITY.md
index 37d73f2..e9cc3d1 100644
--- a/docs/OPERATOR_UI_PARITY.md
+++ b/docs/OPERATOR_UI_PARITY.md
@@ -86,10 +86,10 @@ fixed/industry/comparison 트리의 원본 double-click과 독립적으로 처
| `tabControl1_SelectedIndexChanged` | 탭별 최초 데이터 조회 | 10개 identity별 C# activation gate; 최초 진입 1회 load, 재진입 snapshot 유지 |
| 탭 drag 이벤트 | 10개 탭 표시 순서 교환 | dragover 대상 진입 시 identity와 예상 위치를 C#이 검증해 교환; 중복 전달은 no-op, active tab·playlist 불변 |
| 검색 버튼/Enter | 종목 검색 | 원본 KOSPI→KOSDAQ→NXT 순서와 마지막 이름 일치 규칙 유지 |
-| 컷 click/double-click/drag | 원본 31컷 선택·추가 | plain/Ctrl/Shift/Ctrl+Shift와 원본 double-click 판정, batch 추가 |
-| playlist click/key/buttons/drag | 선택, enabled, 이동, 삭제, 다중 선택 행 이동 | 선택된 행은 원래 순서를 보존한 한 블록으로 임의 위치에 이동; C# playlist authority와 PREPARED/PROGRAM/pending/unknown mutation 차단 유지 |
+| 컷 click/double-click/key/drag | 원본 런타임 컷 선택·추가 | 최대 50 물리행, pointer double-click, Arrow/Home/End/Space와 Ctrl/Shift selection mark, batch 추가 |
+| playlist click/key/buttons/drag | F8 후보, 삭제 선택, enabled, 이동, 삭제, 단일 행 drag | F8 후보·PROGRAM·DEL 집합·Shift anchor를 분리하고 drag는 실제 source 한 행만 이동; 송출 mutation 차단 유지 |
| F2/F3 | 배경 선택·없음 | trusted sibling background root만 허용; 누락·reparse·escape fail closed |
-| F8 | TAKE IN | idle이면 내부 PREPARE → TAKE IN을 한 번에 실행 |
+| F8 | 선택 후보 TAKE IN | IDLE/PREPARED/PROGRAM에서 그 시점의 후보를 새 PREPARE → TAKE IN으로 실행; PROGRAM·후보·DEL 집합·Shift anchor는 독립 |
| NEXT | Page NEXT 또는 다음 행 | 같은 scene PageN 여부를 C#이 판정 |
| Escape | TAKE OUT | modal이 없을 때만 StopAll 기반 TAKE OUT intent |
| FormClosing | 종료 확인 | 기본 취소 확인창; 자동 TAKE OUT/반대 명령 없음 |
@@ -99,9 +99,10 @@ fixed/industry/comparison 트리의 원본 double-click과 독립적으로 처
FarPoint UI는 Web table/list로 표현한다. 다음 의미는 원본과 같이 유지한다.
- plain/Ctrl/Shift/Ctrl+Shift 선택과 현재 행
+- 첫 행은 자동 F8 후보지만 실제 클릭 전에는 DEL 선택이 아니며, F8/Home/End도 기존 DEL 집합과 Shift anchor를 덮어쓰지 않음
- column header 정렬과 현재 표시 순서
- 위/아래 이동, 선택/전체 삭제와 enabled 토글
-- playlist의 비연속 다중 선택을 한 블록으로 유지하는 행 drag와 임의 before/after drop
+- playlist의 실제 drag source 한 행을 이동하는 임의 before/after drop; 비연속 Ctrl/Shift 집합은 삭제 선택으로만 사용
- UC6/EList 추천 행의 opaque `rowId` 기반 drag 재정렬
- single-click과 double-click을 컨트롤별로 구분
- 부모 section 전체 추가는 모든 자식을 먼저 materialize한 뒤 한 번에 commit
@@ -117,7 +118,7 @@ Theme 결과 double-click은 원본의 5행 현재가 경로를 사용한다. PL
| 원본 동작 | Web 입력 | C# 권위와 중복 안전 |
|---|---|---|
-| `PlayList_DragDrop/DragOver` | 내부 custom MIME와 opaque source/target `rowId`, before/after | source가 선택 행이면 현재 선택 전체를 표시 순서대로 이동한다. 체크박스에서 시작한 drag는 거부하고 PREPARED/PROGRAM/pending/unknown에서는 변경하지 않는다. |
+| FarPoint `AllowRowMove` | 내부 custom MIME와 opaque source/target `rowId`, before/after | 실제로 끌어 잡은 source 한 행만 이동한다. 수동 Ctrl/Shift 삭제 선택을 다중 행 drag로 확대하지 않으며, 체크박스에서 시작한 drag와 PREPARED/PROGRAM/pending/unknown 변경을 거부한다. |
| `tab_DragOver/MouseDown/Move/Up` | drop을 기다리지 않고 dragover로 `hover-swap-tabs` 전송 | C# 탭 identity와 `expectedSourceIndex/expectedTargetIndex`가 현재 순서와 일치할 때만 교환하므로 같은 메시지의 중복 전달이 순서를 되돌리지 않는다. |
| UC6 `FpSpread2.AllowDragDrop` | ThemeA/EList 편집행의 내부 custom MIME와 opaque `rowId` | 워크플로가 임의 before/after 위치를 계산한다. 존재하지 않는 ID, 같은 행, 추가 payload 필드는 strict bridge에서 거부하거나 no-op 처리한다. |
@@ -129,7 +130,7 @@ reorder와 cut append가 함께 발생하지 않는다.
체크박스 origin도 브라우저 동작을 반영해 보강했다. Chromium의 `dragstart.target`은 실제
pointer가 체크박스에서 시작했어도 draggable 조상 행일 수 있다. pointerdown 때 checkbox
origin row를 기억해 이어지는 dragstart를 거부하고 pointerup/pointercancel/dragend에서 지운다.
-회귀 테스트는 이 target 형태, 다중선택 보존, row MIME의 조상 무시, 외부 text 거부와 exact
+회귀 테스트는 이 target 형태, 단일 source 행 이동, row MIME의 조상 무시, 외부 text 거부와 exact
selected-cut token의 1회 처리를 DOM event 순서로 재현한다.
### ThemeA 이름 중복확인
@@ -183,7 +184,9 @@ gate 때문에 `DryRun`으로 남았고 Tornado TCP 연결과 Network Monitoring
- `삼성` 검색 결과 **63개**, `삼성출판사` 선택, 선택한 컷 3개를 한 번의 drop으로
playlist 3행에 추가
-- playlist multi-row block reorder와 enabled 체크박스 origin drag 차단
+- 당시 package의 playlist multi-row block reorder와 enabled 체크박스 origin drag 차단.
+ block reorder는 이후 원본 재감사에서 source 단일 행 이동으로 교정됐으므로 현재 코드의
+ 단일 행 drag package 실측으로 간주하지 않음
- mouse release 전에 dragover로 시장 탭 순서 교환
- EList 추천행 **5개** drag reorder. 수량 입력칸에서 시작한 실제 마우스 drag는 순서를
바꾸지 않고, 행의 비입력 영역에서 시작한 drag만 순서를 바꾸는 것을 확인
@@ -203,9 +206,11 @@ Live 연결/해제 경로만 검증하며 scene/PGM 방송 송출 성공을 뜻
| `s5077` | 6 | 6종목 테마 경로 |
| `s5088` | 12 | 12종목 테마 경로 |
-`pageCount = min(20, ceil(itemCount / pageSize))`를 사용한다. 0행, 정확한 경계,
-마지막 부분 페이지, 20페이지 cap, 21페이지 요청 거부, 같은 scene의 `GetPlayingScene`
-Page NEXT와 다음 enabled playlist 항목 NEXT를 자동 테스트로 비교한다.
+행을 만들 때 `pageCount = min(20, ceil(itemCount / pageSize))`를 사용하고, 송출을
+시작할 때 operator row의 `current/total` suffix를 해당 on-air cycle 동안 고정한다.
+column 3이 정확히 `5단 표그래프`, `6종목 현재가`, `12종목 현재가`인 행만 Page NEXT를
+허용한다. 0행, 정확한 경계, 마지막 부분 페이지, 20페이지 cap, 21페이지 요청 거부,
+retained on-air scene Page NEXT와 다음 enabled playlist 항목 NEXT를 자동 테스트로 비교한다.
## 개발 DB write 증적
diff --git a/docs/OPERATOR_WORKFLOW.md b/docs/OPERATOR_WORKFLOW.md
index 5be86ae..e7b6f19 100644
--- a/docs/OPERATOR_WORKFLOW.md
+++ b/docs/OPERATOR_WORKFLOW.md
@@ -13,8 +13,8 @@
- KOSPI `T_STOCK`, KOSDAQ `T_KOSDAQ_STOCK`, NXT KOSPI `N_STOCK/N_ONLINE`, NXT KOSDAQ `N_KOSDAQ_STOCK/N_KOSDAQ_ONLINE`을 매개변수 쿼리로 검색한다.
- LIKE의 `%`, `_`, `!`는 escape하고 검색어·결과 수·열 스키마·종목 코드 형식을 제한한다.
- 검색 결과는 시장, 종목명, 종목 코드를 함께 보존하므로 원본처럼 이름을 다시 훑어 코드를 추정하지 않는다.
-- 실제 실행 자산 `bin/Debug/Res/종목.ini`의 31개 항목을 원본 순서로 표시한다.
-- 실행 자산 SHA-256은 `45DFB1804828F0E4A45F73D681AF0709CE5662872FAA49CFC9F7A0B940409E20`으로 고정했다. 소스 `RES/종목.ini`와 다른 `시간외단일가` 표기는 실행 자산을 기준으로 한다.
+- MSIX에서도 수정 가능한 `%LOCALAPPDATA%/MBN_STOCK_WEBVIEW/Res/종목.ini`를 먼저 읽고, 외부 파일이 없을 때만 패키지 실행 폴더의 CP949 `Res/종목.ini`를 확인한다. 유효한 파일은 원본 물리 순서와 `-` 구분행을 그대로 표시한다.
+- 외부 파일이 존재하지만 잘못됐거나, 두 후보가 모두 없거나 유효하지 않으면 부분 목록을 사용하지 않는다. 실행 자산 SHA-256 `45DFB1804828F0E4A45F73D681AF0709CE5662872FAA49CFC9F7A0B940409E20`에서 고정한 31개 내장 목록으로 폴백하며 경로 없는 경고를 표시한다. 실제 파일은 Git/MSIX에 넣지 않는다.
- NXT 종목은 원본과 같이 `1열판기본_현재가`와 `N5001`만 허용한다.
- 5일선·20일선은 컷 추가 시점의 사본이 아니라 현재 전역 체크 상태를 모든 캔들 항목에 동기화하고 PREPARE 직전에 다시 고정한다.
- 저장된 typed 종목 항목은 builder, alias, selection, 종목, 이동평균선이 모두 재생성 결과와 같을 때만 복원한다. 실패한 항목은 일반 catalog 경로로 우회하지 못한다.
diff --git a/docs/PLAYOUT.md b/docs/PLAYOUT.md
index e16da06..4481cc7 100644
--- a/docs/PLAYOUT.md
+++ b/docs/PLAYOUT.md
@@ -95,10 +95,10 @@ SDK가 기본 위치에 없다면 x64 SDK의 `TlbImp.exe` 절대 경로를 `-Tlb
| `sceneDirectory` | Test/Live에서 사용하는 외부 `.t2s` 루트의 절대 경로. `null`은 안전한 미설정 상태 |
| `outputChannel` | 확인된 전용 출력 채널. `null`은 안전한 미설정 상태 |
| `layoutIndex` | 씬 플레이어 layout 위치 |
-| `legacySceneFadeDuration` | 원본 `ComboDi.SelectedIndex`에 대응하는 fade. 기본값 6, 허용 범위 0~60 |
+| `legacySceneFadeDuration` | 원본 `ComboDi.SelectedIndex`에 대응하는 fade. 기본 인덱스 6(화면 7), 허용 범위 0~19(화면 표시 1~20) |
| `legacySceneBackgroundKind` | trusted 공통 배경 `None`, `Texture`, `Video`; Web은 임의 경로를 보내지 않고 F2/F3가 native picker/toggle을 호출한다. 현재 picker는 `sceneDirectory` 내부만 허용하므로 원본의 sibling `배경` root와는 아직 동등하지 않음 |
| `legacySceneBackgroundAssetPath` | `sceneDirectory` 아래의 상대 asset. 경로 탈출·reparse·누락 파일은 DryRun에서도 거부 |
-| `legacySceneBackgroundVideoLoopCount`, `legacySceneBackgroundVideoLoopInfinite` | 공통 video 배경의 bounded loop 설정 |
+| `legacySceneBackgroundVideoLoopCount`, `legacySceneBackgroundVideoLoopInfinite` | 공통 video 배경 설정. true는 원본 K3D raw Int32 `10`으로, false는 `0`으로 매핑하며 s5006의 raw `1`과 구분 |
| `testProcessWindowTitlePattern` | 전용 테스트 인스턴스만 식별하는 창 제목 패턴 |
| `testSceneAllowlist` | 실제 COM을 사용하는 `Test`와 `Live` 모두에서 허용한 scene name(code)의 폐쇄형 목록. 경로나 확장자는 넣지 않음(기존 설정 호환을 위해 이름 유지) |
| `trustedLiveOutputEnabled` | 운영자가 로컬 파일에서만 설정하는 라이브 1차 게이트 |
@@ -135,7 +135,7 @@ MBN_STOCK_PLAYOUT_MAXIMUM_RECONNECT_ATTEMPTS
MBN_STOCK_PLAYOUT_RECONNECT_ENABLED
```
-`testSceneAllowlist`, `trustedLiveOutputEnabled`, `maximumAutomaticRefreshesPerTakeIn`은 환경 변수로 변경할 수 없으며 로컬 설정 파일에서만 관리합니다. 이름은 기존 설정 호환을 위해 유지하지만 allowlist는 Test뿐 아니라 Live의 PREPARE, TAKE IN 재검사, NEXT와 timer refresh에도 적용되고 비어 있으면 실제 모드를 시작하지 않습니다. 자동 갱신 상한은 Web payload나 환경 변수로 늘릴 수 없고 COM dispatch 경계에서 다시 검사합니다. 공통 background/fade도 Web payload가 아니라 이 trusted 프로세스 설정 경계에서만 결정합니다. `PlayoutSceneCompositionFactory`는 첫 DryRun 또는 실제 PREPARE 전에 background asset의 상대 경로, 허용 확장자, 존재 여부와 reparse ancestry를 검사하며 실제 경로는 Web status/preview에 노출하지 않습니다. `SceneDirectory`는 Test/Live에서 존재하는 비-reparse 외부 디렉터리여야 하며, 엔진은 상대 `.t2s` 파일을 정규화해 이 루트 밖으로 나가는 경로를 거부합니다. scene file의 basename과 scene name 및 allowlist 항목도 서로 일치해야 합니다. 설정 파일은 실행 계정만 읽을 수 있도록 ACL을 제한합니다. 라이선스 키나 인증정보를 이 파일에 기록하지 않습니다.
+`testSceneAllowlist`, `trustedLiveOutputEnabled`, `maximumAutomaticRefreshesPerTakeIn`은 환경 변수로 변경할 수 없으며 로컬 설정 파일에서만 관리합니다. 이름은 기존 설정 호환을 위해 유지하지만 allowlist는 Test뿐 아니라 Live의 PREPARE, TAKE IN 재검사, NEXT와 timer refresh에도 적용되고 비어 있으면 실제 모드를 시작하지 않습니다. 자동 갱신 상한은 Web payload나 환경 변수로 늘릴 수 없고 COM dispatch 경계에서 다시 검사합니다. 공통 background는 Web 경로 payload가 아니라 trusted 프로세스 설정과 네이티브 파일 선택 경계에서만 결정합니다. fade는 원본 `DissolveTime` 20개 항목과 같은 폐쇄형 숫자 intent(0~19)로만 변경하며 기본값은 6입니다. `PlayoutSceneCompositionFactory`는 첫 DryRun 또는 실제 PREPARE 전에 background asset의 상대 경로, 허용 확장자, 존재 여부와 reparse ancestry를 검사하며 실제 경로는 Web status/preview에 노출하지 않습니다. `SceneDirectory`는 Test/Live에서 존재하는 비-reparse 외부 디렉터리여야 하며, 엔진은 상대 `.t2s` 파일을 정규화해 이 루트 밖으로 나가는 경로를 거부합니다. scene file의 basename과 scene name 및 allowlist 항목도 서로 일치해야 합니다. 설정 파일은 실행 계정만 읽을 수 있도록 ACL을 제한합니다. 라이선스 키나 인증정보를 이 파일에 기록하지 않습니다.
### KTAP 포트와 Network Monitoring 판정
diff --git a/docs/SCENE_EQUIVALENCE.md b/docs/SCENE_EQUIVALENCE.md
index 8309a21..74e8756 100644
--- a/docs/SCENE_EQUIVALENCE.md
+++ b/docs/SCENE_EQUIVALENCE.md
@@ -122,7 +122,7 @@ PREPARE의 공통 순서는 다음과 같다.
TAKE IN은 PREPARE 때의 오래된 DTO를 그대로 재생하지 않는다. 원본 `ONAirMode`와 같이 현재 playlist snapshot의 같은 entry/page를 실제 DB에서 다시 조회하고 새 scene을 `LoadScene` → transaction → `Prepare(10)`한 뒤, 그 prepare가 명확히 성공한 경우에만 `Play(10)`을 한 번 호출한다. PREPARE를 다시 누르는 원본 toggle은 `TakeOut(All)`/`StopAll`로 state를 정리한다. timeout 또는 `OutcomeUnknown`은 retry 가능한 실패로 낮추지 않는다.
-공통 fade와 배경은 Web 입력이 아니라 로컬 trusted playout 설정에서만 온다. `legacySceneFadeDuration`의 원본 기본값은 6이며, `legacySceneBackgroundKind`, scene root 아래의 상대 `legacySceneBackgroundAssetPath`, video loop 설정을 `PlayoutSceneCompositionFactory`가 검증한다. `DryRun`에서도 경로 탈출, 절대 경로, 허용되지 않은 확장자, reparse point, 누락 파일을 실제 COM 전에 fail-closed preflight한다. asset 경로는 Web 상태와 preview에 노출하지 않는다.
+공통 배경 경로는 Web 입력이 아니라 로컬 trusted playout 설정과 네이티브 파일 선택에서만 온다. `legacySceneFadeDuration`의 원본 기본 인덱스는 6(화면 표시 7)이고 화면의 `DissolveTime`은 선택 인덱스 0~19만 C#에 보낸다. 실제 K3D에는 원본 raw `SetSceneEffectType(10, FADE, index)` 및 공통 배경 `(path, 2004, 10)`을 전달하며 s5006의 `(path, 1, 1)`과 구분한다. `legacySceneBackgroundKind`, scene root 아래의 상대 `legacySceneBackgroundAssetPath`, video loop 설정을 `PlayoutSceneCompositionFactory`가 검증한다. `DryRun`에서도 경로 탈출, 절대 경로, 허용되지 않은 확장자, reparse point, 누락 파일을 실제 COM 전에 fail-closed preflight한다. asset 경로는 Web 상태와 preview에 노출하지 않는다.
이 순서는 `RegisteredLegacySceneCueProviderTests`, `LegacyPlayoutWorkflowTests`, Playout의 `DynamicK3dSessionTests`, `TornadoPlayoutEngineTests`에서 검증한다. 실제 COM 형식과 vendor 구현은 계속 `IPlayoutEngine` 뒤에 있다.
@@ -136,7 +136,7 @@ TAKE IN은 PREPARE 때의 오래된 DTO를 그대로 재생하지 않는다. 원
- `s5088`의 NXT 인덱스는 비교와 조회 모두 `i + pageIndex * 12`를 사용한다.
- 현재 항목에 다음 page가 있으면 operator Page NEXT는 playlist index를 유지하되 원본 `Next_Scene(0)`처럼 다음 page 데이터를 조회하고 새 scene을 `LoadScene` → transaction mutation → `QueryVariables` → `EndTransaction` → `Prepare(10)` → `Play(10)`한다. 이 경로는 `GetPlayingScene` in-place refresh가 아니다.
- 마지막 page면 다음 활성 playlist 항목으로 이동한다. 비활성 항목은 원본처럼 앞으로 건너뛰며 끝에서 wrap하지 않는다.
-- 원본 timer는 TAKE IN과 playlist NEXT 뒤 반복되며 첫 `m_time` 이후 3초 주기로 계속 실행된다. 새 runtime도 반복 갱신을 유지하지만, 이전 tracked `Play`의 `OnScenePlayed` callback을 drain한 뒤에야 첫 `m_time` 또는 3초의 **전체 cooldown**을 시작한다. 이는 callback 대기와 delay가 겹쳐 operator window가 사라지는 일을 막는 의도적 안전 적응이다. 모든 operator command는 현재 epoch를 먼저 중단하고 Page NEXT 뒤에는 refresh를 재시작하지 않는다.
+- 원본 timer는 TAKE IN과 playlist NEXT 뒤 반복되며 첫 `m_time` 이후 3초 주기로 계속 실행된다. 새 runtime도 반복 갱신을 유지하지만, 이전 tracked `Play`의 `OnScenePlayed` callback을 drain한 뒤에야 첫 `m_time` 또는 3초의 **전체 cooldown**을 시작한다. 이는 callback 대기와 delay가 겹쳐 operator window가 사라지는 일을 막는 의도적 안전 적응이다. 모든 operator command는 현재 epoch를 먼저 중단하고 Page NEXT 뒤에는 refresh를 재시작하지 않는다. 단, 5076/5079/5080/5081은 원본의 `m_time * 1000` Int32 overflow 뒤 Timer 설정 예외로 실제 갱신이 시작되지 않으므로 명시적으로 비활성화한다.
- 원본 `timer1_Tick`에는 mutation 전 선행 `Play(10)`과 `Prepare` 뒤 후행 `Play(10)`이 모두 있다. 마이그레이션은 불완전한 `GetPlayingScene` proxy 대신 retained on-air scene에 fresh DTO mutation을 적용하고 `BeginTransaction → mutation → QueryVariables → EndTransaction → Prepare(10) → tracked Play(10)` 한 번만 보낸다. 선행 replay를 생략한 것은 callback 추적 없이 PLAY를 중복시키지 않기 위한 명시적 안전 차이이며 Round H의 정확한 PLAY/callback 4회 예산에 반영됐다.
- refresh CTS와 공개 상태는 atomic epoch로 결합한다. `Replace`/`Stop`/조건부 update/completion이 같은 lock에서 generation을 확인하므로 취소된 이전 loop가 `active`, `NextAt`, success 또는 fault를 새 loop나 중단 상태 위에 다시 쓰지 못한다.
@@ -155,6 +155,7 @@ TAKE IN은 PREPARE 때의 오래된 DTO를 그대로 재생하지 않는다. 원
- `OnCutOut`은 해당 layout의 on-air 참조를, `OnStopAll`은 player의 모든 on-air 참조를 정리할 근거다.
- 이전 connection generation의 늦은 callback은 현재 state를 바꾸지 않는다.
- pending Play callback이 있으면 TAKE OUT을 제외한 PREPARE/TAKE IN/NEXT/timer refresh를 fail-closed 차단한다. 어떤 lifecycle callback이든 pending이면 Disconnect와 session recycle을 하지 않고 abandon/quarantine 및 `OutcomeUnknown`으로 보수적으로 전환한다.
+- pending CutOut/StopAll callback이 있으면 반복 TAKE OUT을 포함한 모든 scene 명령을 SDK 전에 차단하며, 일치하는 완료 callback 뒤에만 다음 PREPARE를 허용한다.
- `StopAll`/`CutOut`은 SDK dispatch 전에 각 pending completion counter를 올리고 동기 호출 실패 시 되돌린다. 성공 callback은 대응 counter를 정확히 하나 줄이며, 성공한 stop/cut은 중단된 Play가 뒤늦게 `OnScenePlayed`를 보내지 않을 수 있으므로 해당 pending Play accounting을 취소한다. dispatch 뒤 cancellation/timeout은 결과 불명확으로 취급한다.
- 장기 실행 시 retired scene은 callback으로 안전성이 확인된 뒤 unload/release한다.
diff --git a/docs/TRUSTED_BACKGROUND_ROOT.md b/docs/TRUSTED_BACKGROUND_ROOT.md
index 9c5e3c6..3f58701 100644
--- a/docs/TRUSTED_BACKGROUND_ROOT.md
+++ b/docs/TRUSTED_BACKGROUND_ROOT.md
@@ -6,6 +6,7 @@
## 원본 동작
- `T2S_FOLDER_PATH`는 실행 폴더의 `Cuts\`이다.
+- MainForm 시작 시에는 `Cuts\video\기본.vrv`를 기본 배경으로 지정한다.
- F2/`btnback_Click`는 먼저 `Cuts\..\배경\기본.vrv`를 기본값으로 두고,
`배경` 폴더에서 vrv/jpg/png 파일 선택 창을 연다.
- F3/`ckback_CheckedChanged`가 배경을 끄면 파일 값을 비우고, 다시 켜면
@@ -14,9 +15,10 @@
## 새 구현
-`SceneDirectory`(Cuts)는 기존 장면과 장면 내부 자산만 계속 담당한다. 운영자
-F2/F3 배경은 별도 `LegacyBackgroundDirectory`에서만 해석하며 두 allowlist
-root를 합치거나 서로 대신 사용하지 않는다.
+`SceneDirectory`(Cuts)는 기존 장면, 장면 내부 자산과 시작 시 기본 배경
+`video\기본.vrv`를 담당한다. 운영자 F2/F3 배경은 별도
+`LegacyBackgroundDirectory`에서만 해석하며 두 allowlist root를 합치거나 서로
+대신 사용하지 않는다.
```json
{
@@ -37,6 +39,11 @@ F2/F3는 IDLE에서만 변경할 수 있고 변경은 다음 PREPARE부터 적
통과하지 못하면 재생 가능한 mutation으로 만들지 않는다. F3로 다시 켤 때도
이전 사용자 선택 파일이 아니라 원본과 같은 `기본.vrv`를 새로 검증한다.
+명시적인 로컬 배경 설정이 없을 때의 시작 폴백은 오직
+`SceneDirectory\video\기본.vrv`만 검사한다. 이 파일이 없거나 안전 검증에
+실패해도 sibling `배경\기본.vrv`로 대신하지 않고, 경로를 노출하지 않는 경고와
+함께 배경 없음으로 시작한다. 반대로 F2/F3는 시작 폴백 파일을 대신 사용하지 않는다.
+
## Fail-closed 검증
배경 root와 파일은 사용할 때마다 다음 조건을 모두 만족해야 한다.
@@ -49,11 +56,11 @@ F2/F3는 IDLE에서만 변경할 수 있고 변경은 다음 PREPARE부터 적
- 검사 중 디렉터리 조상과 파일 handle은 delete/rename 공유를 허용하지 않음
root나 파일이 없거나 검증 결과가 불명확하면 경로를 Web/오류에 노출하지 않고
-F2/F3 또는 PREPARE를 거부한다. Cuts root로의 fallback은 없다.
+F2/F3 또는 PREPARE를 거부한다. 시작 경로와 F2/F3 경로 사이의 fallback은 없다.
## 현재 자산 확인
-2026-07-12에 원본 `bin\Debug\Cuts`의 형제 `bin\Debug\배경`과
-`배경\기본.vrv`를 확인했으나 둘 다 존재하지 않았다. 따라서 자산이 배치되기
-전까지 현재 환경의 기본 배경은 의도대로 fail closed 상태다. 실제 vrv 파일은
-Git/MSIX에 포함하지 않는다.
+2026-07-15에 원본 `bin\Debug\Cuts\video\기본.vrv`와 Cuts의 형제
+`bin\Debug\배경\기본.vrv`를 확인했으나 둘 다 존재하지 않았다. 따라서 자산이
+배치되기 전까지 현재 환경의 기본 배경은 의도대로 fail closed 상태다. 실제 vrv
+파일은 Git/MSIX에 포함하지 않는다.
diff --git a/docs/UC1_TREE_EVENT_PARITY.md b/docs/UC1_TREE_EVENT_PARITY.md
new file mode 100644
index 0000000..e2924bf
--- /dev/null
+++ b/docs/UC1_TREE_EVENT_PARITY.md
@@ -0,0 +1,38 @@
+# UC1 tree event parity
+
+Source evidence is the read-only original
+`C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\Control\UC1.cs`:
+
+- `UC1_Load` checks `Expand`, selects the first rendered root, and calls
+ `EnsureVisible`.
+- `treeView_MouseDown`, `treeView_BeforeExpand`, and
+ `treeView_BeforeCollapse` prevent a double-click from also changing a
+ root's expanded state.
+- `treeView_DoubleClick` activates either the selected leaf or every direct
+ child of the selected root.
+- `chE_CheckedChanged` expands or collapses every root. Collapsing does not
+ clear `SelectedNode`.
+
+The WebView tree therefore owns only presentation state (open roots, selected
+node, focused node, and scroll position). C# remains the authority for every
+leaf/root action. A root label click selects without expanding; only its
+disclosure glyph changes `open`. A root or leaf double-click emits one closed
+native intent and does not invoke the browser's whole-`summary` toggle.
+
+On each real tab activation/re-entry the presentation state is recreated like
+the original `UC1`: `Expand` is checked, all sections are open, and the first
+root is selected, focused, and scrolled into view. State-only renders within
+the same activation preserve per-section open state, selection, focus, and
+scroll position. Busy and fixed-section-batch states reject both actions and
+local expand/collapse changes.
+
+## Intentional defect exclusion
+
+The original `treeView_DoubleClick` reads `uc_3.FpSpread` in the parent-node
+path before checking which UC1 file is active. For UC1 instances constructed
+with `uc_3 == null`, that can throw `NullReferenceException`; the surrounding
+empty `catch` silently drops the operator action. This is an original defect,
+not observable intended behavior, so the migration does **not** reproduce it.
+The WebView sends only the selected root identity and C# validates/materializes
+the corresponding section without dereferencing an unrelated comparison
+control.
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs
index 8ada023..822fe23 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs
@@ -193,10 +193,14 @@ public sealed record PlayoutSetBackgroundTexture(
PlayoutMutationTiming Timing = PlayoutMutationTiming.BeforeTransaction,
PlayoutAssetRoot AssetRoot = PlayoutAssetRoot.SceneDirectory) : PlayoutMutation;
+///
+/// Raw K3D Int32 argument. MainForm uses 10 for its common background and s5006
+/// uses 1; this value must not be normalized as a Boolean.
+///
public sealed record PlayoutSetBackgroundVideo(
string AssetPath,
int LoopCount,
- bool LoopInfinite,
+ int LoopInfinite,
PlayoutMutationTiming Timing = PlayoutMutationTiming.BeforeTransaction,
PlayoutAssetRoot AssetRoot = PlayoutAssetRoot.SceneDirectory) : PlayoutMutation;
@@ -233,6 +237,12 @@ public sealed record PlayoutStatus(
///
public bool IsPlayCompletionPending { get; init; }
+ ///
+ /// True after CutOut/StopAll was accepted and before the matching Tornado
+ /// completion callback. No further scene command may be dispatched in this state.
+ ///
+ public bool IsTakeOutCompletionPending { get; init; }
+
///
/// True only after the adapter entered the KAEngine.KTAPConnect dispatch path.
/// Process discovery, registration probes, DryRun, and configuration preflight do not set it.
@@ -328,7 +338,8 @@ public interface IPlayoutEngine : IAsyncDisposable
///
/// Updates and replays the scene already on air without loading a replacement scene.
- /// Used by MainForm-compatible timer refresh through GetPlayingScene.
+ /// Used by MainForm-compatible timer refresh. The concrete adapter decides how it
+ /// safely retains or reacquires the on-air scene reference.
///
Task UpdateOnAirAsync(
PlayoutCue cue,
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/PlayoutBridgeProtocol.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/PlayoutBridgeProtocol.cs
index d530564..3c20d57 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Playout/PlayoutBridgeProtocol.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/PlayoutBridgeProtocol.cs
@@ -289,7 +289,7 @@ public static class PlayoutBridgeProtocol
{
if (fadeElement.ValueKind != JsonValueKind.Number ||
!fadeElement.TryGetInt32(out var parsedFade) ||
- parsedFade is < 0 or > 60)
+ parsedFade is < 0 or > 19)
{
return false;
}
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs
index bcf5a09..6d5b9db 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs
@@ -14,12 +14,106 @@ public sealed record LegacySceneSelection(
string Subtype,
string DataCode);
+///
+/// Current operator checkbox state used by s8010. The original scene constructor read
+/// MainForm.ck1/ck2 every time the scene was rebuilt, including TAKE IN, NEXT and the
+/// timer refresh path; it was not part of the persisted playlist identity.
+///
+public sealed record LegacySceneMovingAverageSelection(bool Ma5, bool Ma20);
+
+public interface ILegacySceneMovingAverageSelectionSource
+{
+ LegacySceneMovingAverageSelection Current { get; }
+}
+
+///
+/// Thread-safe native source shared by an operator request and its retained workflow
+/// playlist. Updating it changes only a later s8010 cue build.
+///
+public sealed class MutableLegacySceneMovingAverageSelectionSource :
+ ILegacySceneMovingAverageSelectionSource
+{
+ private LegacySceneMovingAverageSelection _current;
+
+ public MutableLegacySceneMovingAverageSelectionSource(
+ LegacySceneMovingAverageSelection initial)
+ {
+ _current = initial ?? throw new ArgumentNullException(nameof(initial));
+ }
+
+ public LegacySceneMovingAverageSelection Current => Volatile.Read(ref _current);
+
+ public void Update(LegacySceneMovingAverageSelection value)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ Interlocked.Exchange(ref _current, value);
+ }
+}
+
+///
+/// Applies the current checkbox state only to the closed s8010 alias family. This keeps
+/// the playlist selection stable while giving every cue build a fresh native snapshot.
+/// Index and industry visibility suppression remains authoritative in the s8010 builder.
+///
+public static class LegacySceneMovingAverageOverlay
+{
+ private static readonly IReadOnlySet S8010CutCodes = new HashSet(
+ ["8035", "8061", "8040", "8046", "8051", "8056"],
+ StringComparer.Ordinal);
+
+ public static bool Supports(string? cutCode) =>
+ cutCode is not null && S8010CutCodes.Contains(cutCode);
+
+ public static LegacyPlaylistEntry ApplyCurrent(LegacyPlaylistEntry entry)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+ if (!Supports(entry.CutCode) ||
+ entry.Selection is null ||
+ entry.MovingAverageSelectionSource is null)
+ {
+ return entry;
+ }
+
+ var current = entry.MovingAverageSelectionSource.Current ??
+ throw new InvalidOperationException(
+ "The moving-average selection source returned no state.");
+ var graphicType = current switch
+ {
+ { Ma5: true, Ma20: true } => "MA5,MA20",
+ { Ma5: true } => "MA5",
+ { Ma20: true } => "MA20",
+ _ => string.Empty
+ };
+ return entry with
+ {
+ Selection = entry.Selection with { GraphicType = graphicType }
+ };
+ }
+}
+
+///
+/// Explicit operator-grid PageN contract. A null
+/// keeps the provider-derived behavior used by direct Core callers. A non-null contract with
+/// null values deliberately disables same-row page navigation; otherwise the page size and
+/// page-count suffix are frozen for the complete prepare/on-air cycle.
+///
+public sealed record LegacyPlaylistPageNavigation(
+ ScenePageSize? PageSize,
+ int? FrozenPageCount)
+{
+ public static LegacyPlaylistPageNavigation Disabled { get; } = new(null, null);
+
+ public bool IsEnabled => PageSize.HasValue && FrozenPageCount.HasValue;
+}
+
public sealed record LegacyPlaylistEntry(
string EntryId,
string CutCode,
bool IsEnabled = true,
int? FadeDuration = null,
- LegacySceneSelection? Selection = null);
+ LegacySceneSelection? Selection = null,
+ LegacyPlaylistPageNavigation? PageNavigation = null,
+ ILegacySceneMovingAverageSelectionSource? MovingAverageSelectionSource = null);
public sealed record LegacySceneCuePage(
PlayoutCue Cue,
@@ -89,7 +183,8 @@ public sealed record LegacyPlayoutWorkflowState(
string? CurrentEntryId,
string? BuilderKey,
int CurrentPageItemCount,
- IReadOnlyList PreviewFields);
+ IReadOnlyList PreviewFields,
+ bool IsPageNavigationEnabled);
///
/// Reproduces legacy PREPARE/TAKE IN/NEXT/TAKE OUT state transitions above IPlayoutEngine.
@@ -294,13 +389,17 @@ public sealed class LegacyPlayoutWorkflow
}
///
- /// Reproduces the visible MainForm TAKE IN/F8 path from idle: ONAirMode builds and
- /// prepares the selected scene, then Play starts that exact prepared instance. The
- /// page DTO is therefore loaded once and the workflow gate covers both engine calls.
- /// This is intentionally separate from , which preserves the
- /// legacy explicit-PREPARE path and refreshes its page before play.
+ /// Reproduces MainForm.btnTake_Click/F8. The row selected by the operator is
+ /// independent from the row currently on air: every F8 runs ONAirMode for the
+ /// current selection (including the forward disabled-row skip), prepares that
+ /// scene, and then plays it. NEXT remains based on and is not
+ /// affected merely because the operator selected another row.
+ ///
+ /// PREPARE and PLAY remain inside one workflow gate. A completed PREPARE becomes
+ /// the new prepared state, but the previous on-air state is retained until PLAY is
+ /// known to have succeeded. An unknown PLAY outcome is never retried here.
///
- public async Task PrepareAndTakeInAsync(
+ public async Task TakeSelectedAsync(
IReadOnlyList playlist,
int cueIndexZeroBased,
CancellationToken cancellationToken = default)
@@ -308,11 +407,12 @@ public sealed class LegacyPlayoutWorkflow
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
- if (_prepared is not null || _onAir is not null)
+ if (_engine.Status.IsPlayCompletionPending)
{
return Rejected(
PlayoutOperation.TakeIn,
- "One-click TAKE IN can start only while the playout workflow is idle.");
+ "The previous SDK Play completion callback is still pending. " +
+ "Only TAKE OUT is allowed until Tornado confirms the result.");
}
var snapshot = ValidatePlaylist(playlist, cueIndexZeroBased);
@@ -359,6 +459,17 @@ public sealed class LegacyPlayoutWorkflow
}
}
+ ///
+ /// Compatibility alias for callers compiled against the original one-click API.
+ /// New operator code should use so that it is clear
+ /// this operation always follows the current row selection, even during PROGRAM.
+ ///
+ public Task PrepareAndTakeInAsync(
+ IReadOnlyList playlist,
+ int cueIndexZeroBased,
+ CancellationToken cancellationToken = default) =>
+ TakeSelectedAsync(playlist, cueIndexZeroBased, cancellationToken);
+
public async Task TakeInAsync(
CancellationToken cancellationToken = default)
{
@@ -484,8 +595,8 @@ public sealed class LegacyPlayoutWorkflow
///
/// Reproduces MainForm's timer-driven same-scene refresh. The active non-paged
/// entry and current page are queried again and updated through
- /// IPlayoutEngine.UpdateOnAirAsync, whose vendor implementation uses
- /// GetPlayingScene. Timer start/stop policy remains an application concern.
+ /// IPlayoutEngine.UpdateOnAirAsync. The vendor adapter updates its retained on-air
+ /// scene reference; timer start/stop policy remains an application concern.
///
public async Task RefreshOnAirAsync(
CancellationToken cancellationToken = default)
@@ -557,7 +668,11 @@ public sealed class LegacyPlayoutWorkflow
int pageIndexZeroBased,
CancellationToken cancellationToken)
{
- var entry = playlist[cueIndexZeroBased];
+ // s8010 sampled MainForm.ck1/ck2 inside every scene constructor call. Resolve
+ // the current native state here so PREPARE, TAKE IN, NEXT and refresh all use
+ // the same request-time rule without mutating the retained playlist identity.
+ var entry = LegacySceneMovingAverageOverlay.ApplyCurrent(
+ playlist[cueIndexZeroBased]);
var page = await _cueProvider.CreatePageAsync(
entry,
pageIndexZeroBased,
@@ -566,6 +681,8 @@ public sealed class LegacyPlayoutWorkflow
ArgumentNullException.ThrowIfNull(page.Cue);
ScenePagingPlan? plan = null;
+ var pageNavigationEnabled = false;
+ var navigation = entry.PageNavigation;
if (page.PageSize is { } pageSize)
{
var planResult = ScenePaging.CreatePlan(page.ItemCount, pageSize);
@@ -583,14 +700,66 @@ public sealed class LegacyPlayoutWorkflow
throw new LegacySceneDataException("The requested scene page is outside the available range.");
}
- plan = planResult.Plan;
+ if (navigation is null)
+ {
+ // Backwards compatibility for direct Core callers: without explicit
+ // operator metadata, the current provider result remains authoritative.
+ plan = planResult.Plan;
+ pageNavigationEnabled = true;
+ }
+ else if (!navigation.IsEnabled)
+ {
+ if (pageIndexZeroBased != 0)
+ {
+ throw new LegacySceneDataException(
+ "This playlist entry does not allow same-row page navigation.");
+ }
+
+ plan = new ScenePagingPlan(
+ page.ItemCount,
+ pageSize,
+ totalPages: 1,
+ accessibleItemCount: Math.Min(page.ItemCount, (int)pageSize));
+ }
+ else
+ {
+ if (navigation.PageSize != pageSize)
+ {
+ throw new LegacySceneDataException(
+ "The scene provider page size does not match the operator PageN contract.");
+ }
+
+ var frozenPageCount = navigation.FrozenPageCount!.Value;
+ if (pageIndexZeroBased >= frozenPageCount)
+ {
+ throw new LegacySceneDataException(
+ "The requested scene page is outside the frozen operator PageN range.");
+ }
+
+ plan = new ScenePagingPlan(
+ page.ItemCount,
+ pageSize,
+ frozenPageCount,
+ Math.Min(page.ItemCount, frozenPageCount * (int)pageSize));
+ pageNavigationEnabled = true;
+ }
}
else if (pageIndexZeroBased != 0 || page.ItemCount < 0)
{
throw new LegacySceneDataException("A non-paged scene returned invalid page data.");
}
+ else if (navigation?.IsEnabled == true)
+ {
+ throw new LegacySceneDataException(
+ "The scene provider did not return the page size required by the operator PageN contract.");
+ }
- return new ActivePage(cueIndexZeroBased, pageIndexZeroBased, page, plan);
+ return new ActivePage(
+ cueIndexZeroBased,
+ pageIndexZeroBased,
+ page,
+ plan,
+ pageNavigationEnabled);
}
private static IReadOnlyList ValidatePlaylist(
@@ -621,8 +790,9 @@ public sealed class LegacyPlayoutWorkflow
{
if (entry is null || !IsSafeToken(entry.EntryId) || !IsSafeToken(entry.CutCode) ||
!ids.Add(entry.EntryId) ||
- entry.FadeDuration is < 0 or > 60 ||
- !IsSafeSelection(entry.Selection))
+ entry.FadeDuration is < 0 or > 19 ||
+ !IsSafeSelection(entry.Selection) ||
+ !IsSafePageNavigation(entry.PageNavigation))
{
throw new ArgumentException("The playlist contains an invalid entry.", parameterName);
}
@@ -683,7 +853,8 @@ public sealed class LegacyPlayoutWorkflow
: Math.Min(
pageSize,
Math.Max(0, active.Page.ItemCount - (active.PageIndexZeroBased * pageSize))),
- active.Page.PreviewFields ?? Array.Empty());
+ active.Page.PreviewFields ?? Array.Empty(),
+ active.IsPageNavigationEnabled);
}
private static bool IsSafeToken(string? value) =>
@@ -705,6 +876,23 @@ public sealed class LegacyPlayoutWorkflow
IsSafeLookupText(selection.DataCode);
}
+ private static bool IsSafePageNavigation(LegacyPlaylistPageNavigation? navigation)
+ {
+ if (navigation is null)
+ {
+ return true;
+ }
+
+ if (!navigation.PageSize.HasValue && !navigation.FrozenPageCount.HasValue)
+ {
+ return true;
+ }
+
+ return navigation.PageSize is
+ ScenePageSize.Five or ScenePageSize.Six or ScenePageSize.Twelve &&
+ navigation.FrozenPageCount is >= 1 and <= ScenePaging.MaximumPageCount;
+ }
+
private static bool IsSafeLookupText(string? value) =>
value is not null &&
value.Length <= 256 &&
@@ -757,11 +945,13 @@ public sealed class LegacyPlayoutWorkflow
null,
null,
0,
- Array.Empty());
+ Array.Empty(),
+ false);
private sealed record ActivePage(
int CueIndexZeroBased,
int PageIndexZeroBased,
LegacySceneCuePage Page,
- ScenePagingPlan? Plan);
+ ScenePagingPlan? Plan,
+ bool IsPageNavigationEnabled);
}
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyScenePreview.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyScenePreview.cs
index 57b4138..2a44a0a 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyScenePreview.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyScenePreview.cs
@@ -62,7 +62,7 @@ public static class LegacyScenePreviewFactory
"background-texture", "scene", "configured"),
PlayoutSetBackgroundVideo video => new(
"background-video", "scene",
- $"configured;loop={video.LoopCount};infinite={video.LoopInfinite.ToString().ToLowerInvariant()}"),
+ $"configured;loop={video.LoopCount};loop-mode={video.LoopInfinite}"),
_ => throw new LegacySceneDataException(
"The scene preview encountered an unsupported typed mutation.")
};
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneRefreshPolicy.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneRefreshPolicy.cs
index d125084..d3b8582 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneRefreshPolicy.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneRefreshPolicy.cs
@@ -5,8 +5,9 @@ using System.Collections.ObjectModel;
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
///
-/// Closed refresh intervals copied from MainForm.Show_PlayList's m_time assignments.
-/// Browser input cannot select or alter these values.
+/// Closed refresh behavior reproduced from MainForm.Show_PlayList's m_time assignments
+/// and its subsequent WinForms Timer.Interval assignment. Browser input cannot select
+/// or alter these values.
///
public static class LegacySceneRefreshPolicy
{
@@ -30,12 +31,8 @@ public static class LegacySceneRefreshPolicy
["5032"] = Seconds(5),
["5037"] = Seconds(5),
["5074"] = Seconds(7),
- ["5076"] = Seconds(3_000_000),
["5077"] = Seconds(7),
["5078"] = Seconds(30),
- ["5079"] = Seconds(3_000_000),
- ["5080"] = Seconds(3_000_000),
- ["5081"] = Seconds(3_000_000),
["5082"] = Seconds(6),
["5083"] = Seconds(60),
["5084"] = Seconds(5),
@@ -63,7 +60,22 @@ public static class LegacySceneRefreshPolicy
["5072"] = Seconds(30)
});
- public static int Count => Intervals.Count;
+ // MainForm stores 3,000,000 seconds, then evaluates m_time * 1000 as an
+ // unchecked Int32. The result overflows negative and assigning it to the
+ // WinForms Timer throws inside btnTake's swallowed catch after timer.Stop().
+ // These scenes therefore never arm automatic refresh in the original runtime.
+ private static readonly IReadOnlySet DisabledByTimerOverflow =
+ new HashSet(["5076", "5079", "5080", "5081"], StringComparer.Ordinal);
+
+ public static int Count => Intervals.Count + DisabledByTimerOverflow.Count;
+
+ public static bool ContainsAlias(string? cutCode) =>
+ !string.IsNullOrWhiteSpace(cutCode) &&
+ (Intervals.ContainsKey(cutCode) || DisabledByTimerOverflow.Contains(cutCode));
+
+ public static bool IsDisabledByLegacyTimerOverflow(string? cutCode) =>
+ !string.IsNullOrWhiteSpace(cutCode) &&
+ DisabledByTimerOverflow.Contains(cutCode);
public static bool TryGetInterval(string? cutCode, out TimeSpan interval)
{
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneBuilders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneBuilders.cs
index e42a783..5603d45 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneBuilders.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneBuilders.cs
@@ -306,7 +306,7 @@ internal static class PagedQuoteMutations
bool useWideRateVariants)
{
// The original hides all row groups first. Explicit value/variant clearing is the
- // approved partial-page correction required for in-place GetPlayingScene updates.
+ // approved partial-page correction required for retained on-air scene updates.
mutations.Add(new PlayoutSetVisible("row" + slot, false));
mutations.Add(new PlayoutSetValue("title" + slot, string.Empty));
mutations.Add(new PlayoutSetValue("price" + slot, string.Empty));
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ReadOnlyPanelSceneBuilders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ReadOnlyPanelSceneBuilders.cs
index 6f384fc..db37b77 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ReadOnlyPanelSceneBuilders.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ReadOnlyPanelSceneBuilders.cs
@@ -46,7 +46,7 @@ public sealed class S5006SceneMutationBuilder : ILegacySceneMutationBuilder 60 ||
+ if (options.FadeDuration is < 0 or > 19 ||
!Enum.IsDefined(options.BackgroundKind) ||
!Enum.IsDefined(options.BackgroundAssetRoot) ||
(options.BackgroundKind == LegacySceneBackgroundKind.Video &&
@@ -307,7 +307,9 @@ public sealed class RegisteredLegacySceneCueProvider :
mutations.Add(new PlayoutSetBackgroundVideo(
options.BackgroundAssetPath!,
options.BackgroundVideoLoopCount,
- options.BackgroundVideoLoopInfinite,
+ // MainForm passes the vendor's raw Int32 flag 10 here. This is
+ // deliberately distinct from scene s5006, which passes 1.
+ options.BackgroundVideoLoopInfinite ? 10 : 0,
AssetRoot: options.BackgroundAssetRoot));
mutations.Add(new PlayoutUseBackground(true));
break;
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs
index 34497e4..b6ef082 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs
@@ -33,6 +33,7 @@ public sealed class LegacyComparisonWorkflowController
private long _draftSequence;
private int _domesticSearchVersion;
private int _worldSearchVersion;
+ private int _activationGeneration;
private bool _isBusy;
public LegacyComparisonWorkflowController(
@@ -45,12 +46,44 @@ public sealed class LegacyComparisonWorkflowController
public LegacyComparisonWorkflowSnapshot Current => CreateSnapshot();
+ ///
+ /// Recreates UC3's per-control state before its saved CP949 pairs are loaded for
+ /// a new tab activation. Search and pair-load generations invalidate completions
+ /// that belong to the discarded control instance.
+ ///
+ public LegacyComparisonWorkflowSnapshot ResetForTabActivation()
+ {
+ Interlocked.Increment(ref _activationGeneration);
+ Interlocked.Increment(ref _domesticSearchVersion);
+ Interlocked.Increment(ref _worldSearchVersion);
+ _domesticResults = [];
+ _worldResults = [];
+ _savedPairs = [];
+ _firstTarget = null;
+ _secondTarget = null;
+ _selectedDomesticResultId = null;
+ _selectedWorldResultId = null;
+ _selectedFixedTargetId = null;
+ _selectedPairId = null;
+ _domesticQuery = string.Empty;
+ _worldQuery = string.Empty;
+ _domesticError = string.Empty;
+ _worldError = string.Empty;
+ _statusMessage = string.Empty;
+ _domesticStatus = LegacyComparisonSearchStatus.Idle;
+ _worldStatus = LegacyComparisonSearchStatus.Idle;
+ _statusKind = LegacyOperatorStatusKind.Neutral;
+ _isBusy = false;
+ Touch();
+ return CreateSnapshot();
+ }
+
public async Task SearchDomesticAsync(
string query,
CancellationToken cancellationToken = default)
{
var normalizedQuery = NormalizeSearchQuery(query);
- var version = ++_domesticSearchVersion;
+ var version = Interlocked.Increment(ref _domesticSearchVersion);
_domesticQuery = normalizedQuery;
_domesticStatus = LegacyComparisonSearchStatus.Loading;
_domesticError = string.Empty;
@@ -64,7 +97,7 @@ public sealed class LegacyComparisonWorkflowController
normalizedQuery,
LegacyComparisonWorkflowCatalog.DomesticMaximumResults,
cancellationToken).ConfigureAwait(false);
- if (version != _domesticSearchVersion)
+ if (version != Volatile.Read(ref _domesticSearchVersion))
{
return CreateSnapshot();
}
@@ -81,7 +114,7 @@ public sealed class LegacyComparisonWorkflowController
}
catch (OperationCanceledException)
{
- if (version == _domesticSearchVersion)
+ if (version == Volatile.Read(ref _domesticSearchVersion))
{
_domesticStatus = LegacyComparisonSearchStatus.Idle;
_domesticError = string.Empty;
@@ -92,7 +125,7 @@ public sealed class LegacyComparisonWorkflowController
}
catch
{
- if (version == _domesticSearchVersion)
+ if (version == Volatile.Read(ref _domesticSearchVersion))
{
_domesticResults = [];
_domesticStatus = LegacyComparisonSearchStatus.Error;
@@ -110,7 +143,7 @@ public sealed class LegacyComparisonWorkflowController
{
// UC3's blank world query loaded the complete US/TW universe, so blank remains valid.
var normalizedQuery = NormalizeSearchQuery(query);
- var version = ++_worldSearchVersion;
+ var version = Interlocked.Increment(ref _worldSearchVersion);
_worldQuery = normalizedQuery;
_worldStatus = LegacyComparisonSearchStatus.Loading;
_worldError = string.Empty;
@@ -124,7 +157,7 @@ public sealed class LegacyComparisonWorkflowController
normalizedQuery,
LegacyComparisonWorkflowCatalog.WorldMaximumResults,
cancellationToken).ConfigureAwait(false);
- if (version != _worldSearchVersion)
+ if (version != Volatile.Read(ref _worldSearchVersion))
{
return CreateSnapshot();
}
@@ -141,7 +174,7 @@ public sealed class LegacyComparisonWorkflowController
}
catch (OperationCanceledException)
{
- if (version == _worldSearchVersion)
+ if (version == Volatile.Read(ref _worldSearchVersion))
{
_worldStatus = LegacyComparisonSearchStatus.Idle;
_worldError = string.Empty;
@@ -152,7 +185,7 @@ public sealed class LegacyComparisonWorkflowController
}
catch
{
- if (version == _worldSearchVersion)
+ if (version == Volatile.Read(ref _worldSearchVersion))
{
_worldResults = [];
_worldStatus = LegacyComparisonSearchStatus.Error;
@@ -220,6 +253,7 @@ public sealed class LegacyComparisonWorkflowController
public async Task LoadSavedPairsAsync(
CancellationToken cancellationToken = default)
{
+ var activationGeneration = Volatile.Read(ref _activationGeneration);
BeginBusy();
try
{
@@ -251,6 +285,11 @@ public sealed class LegacyComparisonWorkflowController
}
}
+ if (activationGeneration != Volatile.Read(ref _activationGeneration))
+ {
+ return CreateSnapshot();
+ }
+
_savedPairs = candidate;
_pairSequence = Math.Max(_pairSequence, candidate.Count);
_selectedPairId = candidate.FirstOrDefault()?.RowId;
@@ -261,13 +300,21 @@ public sealed class LegacyComparisonWorkflowController
}
catch
{
+ if (activationGeneration != Volatile.Read(ref _activationGeneration))
+ {
+ return CreateSnapshot();
+ }
+
_statusMessage = "CP949 종목비교.dat를 안전하게 불러오지 못했습니다.";
_statusKind = LegacyOperatorStatusKind.Error;
throw;
}
finally
{
- EndBusy();
+ if (activationGeneration == Volatile.Read(ref _activationGeneration))
+ {
+ EndBusy();
+ }
}
return CreateSnapshot();
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyFixedActionCatalog.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyFixedActionCatalog.cs
index 3287e96..014dd03 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyFixedActionCatalog.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyFixedActionCatalog.cs
@@ -104,7 +104,6 @@ public sealed class LegacyFixedAction
public sealed record LegacyMaterializedFixedAction(
LegacyFixedAction Action,
LegacyFixedSelection Selection,
- LegacyMovingAverageSelection MovingAverages,
LegacyFixedRuntimeAsset RuntimeAsset);
public sealed class LegacyFixedSection
@@ -262,8 +261,7 @@ public sealed class LegacyFixedActionCatalog
public LegacyMaterializedFixedAction Materialize(
string actionId,
- DateTimeOffset localNow,
- LegacyMovingAverageSelection movingAverages = default)
+ DateTimeOffset localNow)
{
var action = GetAction(actionId);
if (!action.Available)
@@ -271,14 +269,9 @@ public sealed class LegacyFixedActionCatalog
throw new InvalidOperationException(action.Prerequisite);
}
- var trustedMovingAverages = action.SupportsMovingAverages
- ? movingAverages
- : default;
var graphicType = action.DynamicNxtSession
? localNow.Hour <= 12 ? "PRE_MARKET" : "AFTER_MARKET"
- : action.SupportsMovingAverages
- ? MovingAverageText(trustedMovingAverages)
- : action.Selection.GraphicType;
+ : action.Selection.GraphicType;
var dataCode = action.DynamicDate
? localNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
: action.Selection.DataCode;
@@ -291,7 +284,6 @@ public sealed class LegacyFixedActionCatalog
return new LegacyMaterializedFixedAction(
action,
selection,
- trustedMovingAverages,
RuntimeAssets[action.Market]);
}
@@ -602,21 +594,6 @@ public sealed class LegacyFixedActionCatalog
}
}
- private static string MovingAverageText(LegacyMovingAverageSelection movingAverages)
- {
- if (movingAverages.Ma5 && movingAverages.Ma20)
- {
- return "MA5,MA20";
- }
-
- if (movingAverages.Ma5)
- {
- return "MA5";
- }
-
- return movingAverages.Ma20 ? "MA20" : string.Empty;
- }
-
private static LegacyFixedMarket ParseMarket(string value) => value switch
{
"overseas" => LegacyFixedMarket.Overseas,
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustrySelectionWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustrySelectionWorkflow.cs
index 96aa1d1..b5f9517 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustrySelectionWorkflow.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustrySelectionWorkflow.cs
@@ -82,6 +82,26 @@ public sealed class LegacyIndustrySelectionWorkflow
public LegacyIndustrySelectionSnapshot Current => CreateSnapshot();
+ ///
+ /// Recreates the observable UC2 state for one tab activation. The generation
+ /// increment prevents a request started by the discarded control from restoring
+ /// its rows after the operator has re-entered the tab.
+ ///
+ public LegacyIndustrySelectionSnapshot ResetForTabActivation(IndustryMarket market)
+ {
+ ValidateMarket(market);
+ Interlocked.Increment(ref _loadGeneration);
+ _market = market;
+ _industries = NoIndustries;
+ _actions = LegacyIndustryActionCatalog.GetActions(market);
+ _selectedIndex = null;
+ _selectedIndustry = null;
+ _firstIndustry = null;
+ _secondIndustry = null;
+ Touch();
+ return CreateSnapshot();
+ }
+
public async Task LoadMarketAsync(
IndustryMarket market,
CancellationToken cancellationToken = default)
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs
index e200281..6492c7f 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs
@@ -1,5 +1,9 @@
using S5025ManualAudience =
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.S5025ManualAudience;
+using CoreMovingAverageSelection =
+ MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneMovingAverageSelection;
+using CoreMovingAverageSource =
+ MBN_STOCK_WEBVIEW.Core.Playout.Scenes.MutableLegacySceneMovingAverageSelectionSource;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
@@ -29,6 +33,15 @@ public enum LegacyDragDropPosition
After
}
+public enum LegacyCutKeyboardKey
+{
+ ArrowUp,
+ ArrowDown,
+ Home,
+ End,
+ Space
+}
+
public enum LegacyOperatorTabId
{
Overseas,
@@ -50,7 +63,8 @@ public sealed record LegacyCutViewRow(
int? DisplayOrdinal,
string RawLabel,
bool IsSeparator,
- bool IsSelected);
+ bool IsSelected,
+ bool IsFocused = false);
public sealed record LegacyDialogState(string Message);
@@ -130,6 +144,7 @@ public sealed class LegacyOperatorController
private const long DoubleClickWindowMilliseconds = 500;
private readonly ILegacyStockLookup _stockLookup;
+ private readonly IReadOnlyList _cutRows;
private readonly LegacyFixedActionCatalog _fixedCatalog;
private readonly TimeProvider _timeProvider;
private readonly LegacyIndustrySelectionWorkflow? _industryWorkflow;
@@ -142,6 +157,8 @@ public sealed class LegacyOperatorController
private readonly LegacyOverseasSelectionWorkflow? _overseasWorkflow;
private readonly LegacyManualListsWorkflow? _manualListsWorkflow;
private readonly LegacyOperatorCatalogWorkflowController? _operatorCatalogWorkflow;
+ private readonly CoreMovingAverageSource _movingAverageSource = new(
+ new CoreMovingAverageSelection(true, true));
private readonly SemaphoreSlim _tabActivationGate = new(1, 1);
private readonly SortedSet _legacyPointerRows = [];
private readonly SortedSet _nativeSelectedCutRows = [];
@@ -165,6 +182,7 @@ public sealed class LegacyOperatorController
private int? _selectedStockIndex;
private int? _cutAnchorIndex;
private int _nativeSelectionAnchorIndex;
+ private int _nativeFocusedCutIndex = -1;
private int? _previousPointerRow;
private int _previousPointerX;
private int _previousPointerY;
@@ -182,7 +200,8 @@ public sealed class LegacyOperatorController
private LegacyMovingAverageSelection _movingAverages = new(true, true);
private LegacyThemeWorkflowSnapshot? _themeSnapshot;
private LegacyManualFinancialWorkflowSnapshot? _manualFinancialSnapshot;
- private bool _comparisonLoaded;
+ private bool _industryActivationListLoaded;
+ private bool _comparisonActivationListLoaded;
private bool _themeActivationListLoaded;
private bool _expertActivationListLoaded;
private long _tabActivationRequest;
@@ -203,9 +222,11 @@ public sealed class LegacyOperatorController
LegacyNamedPlaylistWorkflowController? namedPlaylistWorkflow = null,
LegacyOverseasSelectionWorkflow? overseasWorkflow = null,
LegacyManualListsWorkflow? manualListsWorkflow = null,
- LegacyOperatorCatalogWorkflowController? operatorCatalogWorkflow = null)
+ LegacyOperatorCatalogWorkflowController? operatorCatalogWorkflow = null,
+ IReadOnlyList? cutRows = null)
{
_stockLookup = stockLookup ?? throw new ArgumentNullException(nameof(stockLookup));
+ _cutRows = CreateValidatedCutRows(cutRows);
_fixedCatalog = fixedCatalog ?? LegacyFixedActionCatalog.Default;
_timeProvider = timeProvider ?? TimeProvider.System;
_industryWorkflow = industryWorkflow;
@@ -229,7 +250,23 @@ public sealed class LegacyOperatorController
public LegacyOperatorSnapshot Current => CreateSnapshot();
- public LegacyOperatorPlayoutRequest CreatePlayoutRequest()
+ public LegacyOperatorPlayoutRequest CreatePlayoutRequest() =>
+ CreatePlayoutRequestCore(globalFadeDuration: null);
+
+ public LegacyOperatorPlayoutRequest CreatePlayoutRequest(int globalFadeDuration)
+ {
+ if (globalFadeDuration is < 0 or > 19)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(globalFadeDuration),
+ "The original DissolveTime index must be between 0 and 19.");
+ }
+
+ return CreatePlayoutRequestCore(globalFadeDuration);
+ }
+
+ private LegacyOperatorPlayoutRequest CreatePlayoutRequestCore(
+ int? globalFadeDuration)
{
if (_playlist.Count == 0)
{
@@ -241,21 +278,82 @@ public sealed class LegacyOperatorController
: FindPlaylistIndex(_activePlaylistRowId);
if (selectedIndex < 0)
{
- selectedIndex = _playlist.FindIndex(row =>
- _selectedPlaylistRowIds.Contains(row.RowId));
+ throw new InvalidOperationException(
+ "플레이리스트의 F8 대상 행이 선택되지 않았습니다.");
}
- if (selectedIndex < 0)
+ var entries = _playlist.Select(row =>
{
- selectedIndex = Math.Clamp(_playlistCursorIndex, 0, _playlist.Count - 1);
- }
+ var entry = row.ToPlayoutEntry();
+ if (MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneMovingAverageOverlay
+ .Supports(entry.CutCode))
+ {
+ // Keep the row's seven persisted fields unchanged. The retained source
+ // is sampled again by the workflow whenever s8010 is rebuilt.
+ entry = entry with { MovingAverageSelectionSource = _movingAverageSource };
+ entry = MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneMovingAverageOverlay
+ .ApplyCurrent(entry);
+ }
- var entries = _playlist.Select(static row => row.ToPlayoutEntry()).ToArray();
+ return globalFadeDuration.HasValue
+ ? entry with { FadeDuration = globalFadeDuration.Value }
+ : entry;
+ }).ToArray();
return new LegacyOperatorPlayoutRequest(
Array.AsReadOnly(entries),
selectedIndex);
}
+ ///
+ /// Reflects a successfully prepared or played cue back into the operator grid.
+ /// MainForm stored the current PageN value in column 5 instead of keeping it only
+ /// in the Tornado workflow. TAKE/PREPARE may move the F8 candidate when a disabled
+ /// row is skipped, but never rewrites bufdel/m_delcnt or the Shift anchors. NEXT
+ /// deliberately leaves even that independent candidate alone.
+ ///
+ public LegacyOperatorSnapshot ReflectSuccessfulPlayout(
+ string entryId,
+ int pageIndexZeroBased,
+ int pageCount,
+ bool synchronizeOperatorSelection)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(entryId);
+ if (pageCount <= 0 || pageIndexZeroBased < 0 || pageIndexZeroBased >= pageCount)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(pageIndexZeroBased),
+ "The playout page position is outside the playlist page range.");
+ }
+
+ var index = FindPlaylistIndex(entryId);
+ if (index < 0)
+ {
+ return CreateSnapshot();
+ }
+
+ var changed = false;
+ var pageText = $"{pageIndexZeroBased + 1}/{pageCount}";
+ if (!string.Equals(_playlist[index].PageText, pageText, StringComparison.Ordinal))
+ {
+ _playlist[index] = _playlist[index] with { PageText = pageText };
+ changed = true;
+ }
+
+ if (synchronizeOperatorSelection)
+ {
+ var rowId = _playlist[index].RowId;
+ changed |= !string.Equals(_activePlaylistRowId, rowId, StringComparison.Ordinal);
+ _activePlaylistRowId = rowId;
+ }
+
+ if (changed)
+ {
+ Touch();
+ }
+
+ return CreateSnapshot();
+ }
+
public async Task SearchStocksAsync(
string text,
LegacySearchTrigger trigger,
@@ -333,7 +431,53 @@ public sealed class LegacyOperatorController
ApplyNativeListSelection(physicalIndex, control, shift);
_cutAnchorIndex = physicalIndex;
- _nativeSelectionAnchorIndex = physicalIndex;
+ _nativeFocusedCutIndex = physicalIndex;
+ // Win32 ListView's selection mark remains the fixed anchor across
+ // consecutive Shift/Ctrl+Shift gestures. Only a non-Shift click moves it.
+ if (!shift)
+ {
+ _nativeSelectionAnchorIndex = physicalIndex;
+ }
+ Touch();
+ return CreateSnapshot();
+ }
+
+ public LegacyOperatorSnapshot CutKeyDown(
+ LegacyCutKeyboardKey key,
+ bool control,
+ bool shift)
+ {
+ if (_selectedStockIndex is null || _cutRows.Count == 0)
+ {
+ return CreateSnapshot();
+ }
+
+ var focusedIndex = IsCutIndex(_nativeFocusedCutIndex)
+ ? _nativeFocusedCutIndex
+ : 0;
+ var targetIndex = key switch
+ {
+ LegacyCutKeyboardKey.ArrowUp => Math.Max(0, focusedIndex - 1),
+ LegacyCutKeyboardKey.ArrowDown => Math.Min(_cutRows.Count - 1, focusedIndex + 1),
+ LegacyCutKeyboardKey.Home => 0,
+ LegacyCutKeyboardKey.End => _cutRows.Count - 1,
+ LegacyCutKeyboardKey.Space => focusedIndex,
+ _ => throw new ArgumentOutOfRangeException(nameof(key), key, null)
+ };
+
+ _nativeFocusedCutIndex = targetIndex;
+ if (key == LegacyCutKeyboardKey.Space)
+ {
+ ApplyNativeSpaceSelection(targetIndex, control, shift);
+ }
+ else
+ {
+ ApplyNativeKeyboardNavigationSelection(targetIndex, control, shift);
+ }
+
+ // MainForm's selectedRowsList was populated only by listView1_MouseDown.
+ // Keyboard selection belongs exclusively to the native ListView state used
+ // by owner drawing and PlayList_DragDrop, so do not alter the legacy list.
Touch();
return CreateSnapshot();
}
@@ -352,6 +496,31 @@ public sealed class LegacyOperatorController
return CreateSnapshot();
}
+ public LegacyOperatorSnapshot ClearCutSelection()
+ {
+ if (_selectedStockIndex is null)
+ {
+ return CreateSnapshot();
+ }
+
+ var changed = _legacyPointerRows.Count != 0 ||
+ _nativeSelectedCutRows.Count != 0 ||
+ _cutAnchorIndex != -1 ||
+ _previousPointerTimestamp is not null;
+ _legacyPointerRows.Clear();
+ _nativeSelectedCutRows.Clear();
+ _cutAnchorIndex = -1;
+ // A Win32 ListView blank-area click clears Selected items but preserves
+ // its selection mark, so a following Shift gesture keeps the old anchor.
+ _previousPointerTimestamp = null;
+ _previousPointerRow = -1;
+ if (changed)
+ {
+ Touch();
+ }
+ return CreateSnapshot();
+ }
+
public LegacyOperatorSnapshot SetPlaylistRowEnabled(string rowId, bool enabled)
{
ArgumentNullException.ThrowIfNull(rowId);
@@ -422,7 +591,6 @@ public sealed class LegacyOperatorController
LegacyOperatorTabId tabId,
CancellationToken cancellationToken = default)
{
- var changed = _activeTab != tabId;
SelectTab(tabId);
var activationRequest = Volatile.Read(ref _tabActivationRequest);
await _tabActivationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
@@ -436,7 +604,7 @@ public sealed class LegacyOperatorController
var market = GetActiveIndustryMarket();
var touched = false;
if (_industryWorkflow is not null && market is not null &&
- (changed || _industryWorkflow.Current.Market != market.Value))
+ !_industryActivationListLoaded)
{
await _industryWorkflow.LoadMarketAsync(market.Value, cancellationToken)
.ConfigureAwait(false);
@@ -445,10 +613,12 @@ public sealed class LegacyOperatorController
return CreateSnapshot();
}
+ _industryActivationListLoaded = true;
touched = true;
}
- if (IsComparisonActive() && _comparisonWorkflow is not null && !_comparisonLoaded)
+ if (IsComparisonActive() && _comparisonWorkflow is not null &&
+ !_comparisonActivationListLoaded)
{
await _comparisonWorkflow.LoadSavedPairsAsync(cancellationToken)
.ConfigureAwait(false);
@@ -457,7 +627,7 @@ public sealed class LegacyOperatorController
return CreateSnapshot();
}
- _comparisonLoaded = true;
+ _comparisonActivationListLoaded = true;
touched = true;
}
@@ -586,6 +756,7 @@ public sealed class LegacyOperatorController
return CreateSnapshot();
}
+ _movingAverageSource.Update(new CoreMovingAverageSelection(ma5, ma20));
_movingAverages = next;
_tradingHaltWorkflow?.SetMovingAverages(ma5, ma20);
_overseasWorkflow?.SetMovingAverages(ma5, ma20);
@@ -1738,8 +1909,7 @@ public sealed class LegacyOperatorController
{
var materialized = _fixedCatalog.Materialize(
actionId,
- _timeProvider.GetLocalNow(),
- _movingAverages);
+ _timeProvider.GetLocalNow());
if (materialized.Action.Market != market.Value)
{
return ReportError("현재 탭에 속하지 않는 항목입니다.");
@@ -1854,8 +2024,7 @@ public sealed class LegacyOperatorController
action,
_fixedCatalog.Materialize(
action.Id,
- localNow,
- _movingAverages)));
+ localNow)));
}
var manualCount = items.Count(item => item.ManualTarget is not null);
@@ -2754,6 +2923,28 @@ public sealed class LegacyOperatorController
return CreateSnapshot();
}
+ ///
+ /// Moves only MainForm's m_rowidx equivalent. A click in the checkbox column
+ /// selected the next F8 candidate before its separate enabled-state handling,
+ /// but did not replace the rows accumulated for Ctrl/Shift deletion.
+ ///
+ public LegacyOperatorSnapshot ActivatePlaylistRow(string rowId)
+ {
+ ArgumentNullException.ThrowIfNull(rowId);
+ var index = FindPlaylistIndex(rowId);
+ if (index < 0 || string.Equals(
+ _activePlaylistRowId,
+ rowId,
+ StringComparison.Ordinal))
+ {
+ return CreateSnapshot();
+ }
+
+ _activePlaylistRowId = rowId;
+ Touch();
+ return CreateSnapshot();
+ }
+
public LegacyOperatorSnapshot ToggleActivePlaylistRowEnabled()
{
return _activePlaylistRowId is null
@@ -2770,11 +2961,7 @@ public sealed class LegacyOperatorController
var index = last ? _playlist.Count - 1 : 0;
var rowId = _playlist[index].RowId;
- _selectedPlaylistRowIds.Clear();
- _selectedPlaylistRowIds.Add(rowId);
_activePlaylistRowId = rowId;
- _playlistPriorCursorIndex = index;
- _playlistCursorIndex = index;
Touch();
return CreateSnapshot();
}
@@ -2786,8 +2973,44 @@ public sealed class LegacyOperatorController
return CreateSnapshot();
}
- _playlist.RemoveAll(row => _selectedPlaylistRowIds.Contains(row.RowId));
- ResetPlaylistSelection();
+ var originalRows = _playlist.ToArray();
+ var removedIds = new HashSet(
+ _selectedPlaylistRowIds,
+ StringComparer.Ordinal);
+ var activeIndex = FindOriginalPlaylistIndex(originalRows, _activePlaylistRowId);
+ var cursorIndex = Math.Clamp(_playlistCursorIndex, 0, originalRows.Length - 1);
+ var priorCursorIndex = Math.Clamp(
+ _playlistPriorCursorIndex,
+ 0,
+ originalRows.Length - 1);
+ var cursorRowId = originalRows[cursorIndex].RowId;
+ var priorCursorRowId = originalRows[priorCursorIndex].RowId;
+
+ _playlist.RemoveAll(row => removedIds.Contains(row.RowId));
+ _selectedPlaylistRowIds.Clear();
+ if (_playlist.Count == 0)
+ {
+ ResetPlaylistSelection();
+ }
+ else
+ {
+ _activePlaylistRowId = ResolveSurvivingPlaylistRowId(
+ originalRows,
+ removedIds,
+ _activePlaylistRowId,
+ activeIndex);
+ _playlistCursorIndex = ResolveSurvivingPlaylistIndex(
+ originalRows,
+ removedIds,
+ cursorRowId,
+ cursorIndex);
+ _playlistPriorCursorIndex = ResolveSurvivingPlaylistIndex(
+ originalRows,
+ removedIds,
+ priorCursorRowId,
+ priorCursorIndex);
+ }
+
Touch();
return CreateSnapshot();
}
@@ -2855,24 +3078,20 @@ public sealed class LegacyOperatorController
return CreateSnapshot();
}
- var sourceWasSelected = _selectedPlaylistRowIds.Contains(sourceRowId);
- var movingIds = sourceWasSelected
- ? new HashSet(_selectedPlaylistRowIds, StringComparer.Ordinal)
- : new HashSet([sourceRowId], StringComparer.Ordinal);
- if (movingIds.Contains(targetRowId))
- {
- return CreateSnapshot();
- }
-
- var movingRows = _playlist
- .Where(row => movingIds.Contains(row.RowId))
- .ToArray();
+ // FarPoint's native row move uses the row being dragged as its source.
+ // MainForm's manually painted Ctrl/Shift deletion selection was not a
+ // native multi-row drag selection, so it must never turn this into a
+ // block move.
+ var movingRow = _playlist[sourceIndex];
var remainingRows = _playlist
- .Where(row => !movingIds.Contains(row.RowId))
+ .Where(row => !string.Equals(
+ row.RowId,
+ sourceRowId,
+ StringComparison.Ordinal))
.ToList();
var remainingTargetIndex = remainingRows.FindIndex(row =>
string.Equals(row.RowId, targetRowId, StringComparison.Ordinal));
- if (movingRows.Length == 0 || remainingTargetIndex < 0)
+ if (remainingTargetIndex < 0)
{
return CreateSnapshot();
}
@@ -2883,32 +3102,22 @@ public sealed class LegacyOperatorController
LegacyDragDropPosition.After => remainingTargetIndex + 1,
_ => throw new ArgumentOutOfRangeException(nameof(position), position, null)
};
- remainingRows.InsertRange(insertionIndex, movingRows);
+ remainingRows.Insert(insertionIndex, movingRow);
var orderChanged = !_playlist
.Select(static row => row.RowId)
.SequenceEqual(
remainingRows.Select(static row => row.RowId),
StringComparer.Ordinal);
- var selectionChanged = !sourceWasSelected ||
- !string.Equals(_activePlaylistRowId, sourceRowId, StringComparison.Ordinal);
- if (!orderChanged && !selectionChanged)
+ if (!orderChanged)
{
return CreateSnapshot();
}
- if (orderChanged)
- {
- _playlist.Clear();
- _playlist.AddRange(remainingRows);
- }
-
- if (!sourceWasSelected)
- {
- _selectedPlaylistRowIds.Clear();
- _selectedPlaylistRowIds.Add(sourceRowId);
- }
-
+ _playlist.Clear();
+ _playlist.AddRange(remainingRows);
+ _selectedPlaylistRowIds.Clear();
+ _selectedPlaylistRowIds.Add(sourceRowId);
_activePlaylistRowId = sourceRowId;
_playlistCursorIndex = FindPlaylistIndex(sourceRowId);
_playlistPriorCursorIndex = _playlistCursorIndex;
@@ -2991,6 +3200,7 @@ public sealed class LegacyOperatorController
_playlist.Clear();
_playlist.AddRange(loadedRows);
ResetPlaylistSelection();
+ SelectInitialPlaylistCandidate();
ApplyNamedPlaylistStatus(namedPlaylist);
}
catch (OperationCanceledException)
@@ -3239,6 +3449,14 @@ public sealed class LegacyOperatorController
return CreateSnapshot();
}
+ public LegacyOperatorSnapshot ReportWarning(string message)
+ {
+ _statusMessage = message ?? string.Empty;
+ _statusKind = LegacyOperatorStatusKind.Warning;
+ Touch();
+ return CreateSnapshot();
+ }
+
private void ContinuePendingDrop()
{
while (_pendingDropCutRows is { Count: > 0 })
@@ -3262,7 +3480,7 @@ public sealed class LegacyOperatorController
return true;
}
- var cut = LegacyStockCutCatalog.Rows[physicalIndex];
+ var cut = _cutRows[physicalIndex];
var stockName = _searchData.DisplayNames[stockIndex];
var isNxt = stockName.Contains("(NXT)", StringComparison.Ordinal);
if (isNxt && !string.Equals(
@@ -3281,7 +3499,7 @@ public sealed class LegacyOperatorController
var identity = _searchData.ResolveLastByDisplayName(stockName);
var rowId = $"legacy-stock-{++_playlistSequence:D8}";
- _playlist.Add(LegacyStockCutCatalog.CreatePlaylistRow(
+ AppendPlaylistRow(LegacyStockCutCatalog.CreatePlaylistRow(
rowId,
stockName,
identity,
@@ -3316,11 +3534,14 @@ public sealed class LegacyOperatorController
private void ApplyNativeListSelection(int physicalIndex, bool control, bool shift)
{
+ var anchor = _nativeSelectionAnchorIndex >= 0
+ ? _nativeSelectionAnchorIndex
+ : physicalIndex;
if (control && shift)
{
AddInclusiveRange(
_nativeSelectedCutRows,
- _nativeSelectionAnchorIndex,
+ anchor,
physicalIndex);
}
else if (control)
@@ -3335,7 +3556,7 @@ public sealed class LegacyOperatorController
_nativeSelectedCutRows.Clear();
AddInclusiveRange(
_nativeSelectedCutRows,
- _nativeSelectionAnchorIndex,
+ anchor,
physicalIndex);
}
else
@@ -3345,6 +3566,71 @@ public sealed class LegacyOperatorController
}
}
+ private void ApplyNativeKeyboardNavigationSelection(
+ int physicalIndex,
+ bool control,
+ bool shift)
+ {
+ var anchor = IsCutIndex(_nativeSelectionAnchorIndex)
+ ? _nativeSelectionAnchorIndex
+ : physicalIndex;
+ if (control && !shift)
+ {
+ // Ctrl+navigation moves the focus rectangle without changing either
+ // selection or the Win32 ListView selection mark.
+ return;
+ }
+
+ if (control && shift)
+ {
+ AddInclusiveRange(_nativeSelectedCutRows, anchor, physicalIndex);
+ return;
+ }
+
+ _nativeSelectedCutRows.Clear();
+ if (shift)
+ {
+ AddInclusiveRange(_nativeSelectedCutRows, anchor, physicalIndex);
+ return;
+ }
+
+ _nativeSelectedCutRows.Add(physicalIndex);
+ _nativeSelectionAnchorIndex = physicalIndex;
+ }
+
+ private void ApplyNativeSpaceSelection(int physicalIndex, bool control, bool shift)
+ {
+ var anchor = IsCutIndex(_nativeSelectionAnchorIndex)
+ ? _nativeSelectionAnchorIndex
+ : physicalIndex;
+ if (control && shift)
+ {
+ AddInclusiveRange(_nativeSelectedCutRows, anchor, physicalIndex);
+ return;
+ }
+
+ if (control)
+ {
+ if (!_nativeSelectedCutRows.Add(physicalIndex))
+ {
+ _nativeSelectedCutRows.Remove(physicalIndex);
+ }
+
+ _nativeSelectionAnchorIndex = physicalIndex;
+ return;
+ }
+
+ _nativeSelectedCutRows.Clear();
+ if (shift)
+ {
+ AddInclusiveRange(_nativeSelectedCutRows, anchor, physicalIndex);
+ return;
+ }
+
+ _nativeSelectedCutRows.Add(physicalIndex);
+ _nativeSelectionAnchorIndex = physicalIndex;
+ }
+
private void AddLegacyRangeFromAnchor(int physicalIndex)
{
// MainForm used beforeRow = -1 and skipped the range when the previous
@@ -3434,7 +3720,7 @@ public sealed class LegacyOperatorController
var rawCutLabel = selection.Subtype.Length == 0
? selection.GraphicType
: $"{selection.GraphicType}_{selection.Subtype}";
- return new LegacyOperatorPlaylistRow(
+ return NormalizeMovingAverageIdentity(new LegacyOperatorPlaylistRow(
$"legacy-named-{++_playlistSequence:D8}",
row.IsEnabled,
selection.GroupCode,
@@ -3446,7 +3732,7 @@ public sealed class LegacyOperatorController
rawCutLabel,
6,
SceneAlias: sceneAlias,
- PlayoutSelection: playoutSelection);
+ PlayoutSelection: playoutSelection));
}
private string ResolveNamedPlaylistSceneAlias(
@@ -3581,19 +3867,24 @@ public sealed class LegacyOperatorController
var searchRows = _searchData.DisplayNames
.Select((name, index) => new LegacyStockResultRow(index, name))
.ToArray();
- var cutRows = LegacyStockCutCatalog.Rows
+ var cutRows = _cutRows
.Select(row => new LegacyCutViewRow(
row.PhysicalIndex,
row.DisplayOrdinal,
row.RawLabel,
row.IsSeparator,
- _nativeSelectedCutRows.Contains(row.PhysicalIndex)))
+ _nativeSelectedCutRows.Contains(row.PhysicalIndex),
+ row.PhysicalIndex == _nativeFocusedCutIndex))
.ToArray();
var playlistRows = _playlist
.Select(row => row with
{
- IsSelected = _selectedPlaylistRowIds.Contains(row.RowId)
+ IsSelected = _selectedPlaylistRowIds.Contains(row.RowId),
+ IsActive = string.Equals(
+ _activePlaylistRowId,
+ row.RowId,
+ StringComparison.Ordinal)
})
.ToArray();
var tabs = _tabOrder
@@ -3683,14 +3974,39 @@ public sealed class LegacyOperatorController
private void BeginTabActivation(LegacyOperatorTabId tabId)
{
- if (tabId == LegacyOperatorTabId.Theme)
+ if (tabId == LegacyOperatorTabId.KospiIndustry)
+ {
+ _industryActivationListLoaded = false;
+ _industryWorkflow?.ResetForTabActivation(
+ MMoneyCoderSharp.Data.IndustryMarket.Kospi);
+ }
+ else if (tabId == LegacyOperatorTabId.KosdaqIndustry)
+ {
+ _industryActivationListLoaded = false;
+ _industryWorkflow?.ResetForTabActivation(
+ MMoneyCoderSharp.Data.IndustryMarket.Kosdaq);
+ }
+ else if (tabId == LegacyOperatorTabId.Comparison)
+ {
+ _comparisonActivationListLoaded = false;
+ _comparisonWorkflow?.ResetForTabActivation();
+ }
+ else if (tabId == LegacyOperatorTabId.Theme)
{
_themeActivationListLoaded = false;
}
+ else if (tabId == LegacyOperatorTabId.OverseasStocks)
+ {
+ _overseasWorkflow?.ResetForTabActivation();
+ }
else if (tabId == LegacyOperatorTabId.Expert)
{
_expertActivationListLoaded = false;
}
+ else if (tabId == LegacyOperatorTabId.TradingHalt)
+ {
+ _tradingHaltWorkflow?.ResetForTabActivation();
+ }
}
private bool IsCurrentTabActivation(
@@ -3822,6 +4138,10 @@ public sealed class LegacyOperatorController
{
var originalCount = _playlist.Count;
var originalSequence = _playlistSequence;
+ var originalActiveRowId = _activePlaylistRowId;
+ var originalSelectedRowIds = _selectedPlaylistRowIds.ToArray();
+ var originalCursorIndex = _playlistCursorIndex;
+ var originalPriorCursorIndex = _playlistPriorCursorIndex;
try
{
foreach (var item in items)
@@ -3849,6 +4169,11 @@ public sealed class LegacyOperatorController
}
_playlistSequence = originalSequence;
+ _activePlaylistRowId = originalActiveRowId;
+ _selectedPlaylistRowIds.Clear();
+ _selectedPlaylistRowIds.UnionWith(originalSelectedRowIds);
+ _playlistCursorIndex = originalCursorIndex;
+ _playlistPriorCursorIndex = originalPriorCursorIndex;
throw;
}
}
@@ -3890,7 +4215,7 @@ public sealed class LegacyOperatorController
private void AppendManualListRow(LegacyManualListPlaylistDraft draft)
{
- _playlist.Add(new LegacyOperatorPlaylistRow(
+ AppendPlaylistRow(new LegacyOperatorPlaylistRow(
$"legacy-manual-list-{++_playlistSequence:D8}",
true,
draft.MarketText,
@@ -3916,7 +4241,7 @@ public sealed class LegacyOperatorController
LegacyFixedMarket.Index => "주요지수",
_ => throw new ArgumentOutOfRangeException()
};
- _playlist.Add(new LegacyOperatorPlaylistRow(
+ AppendPlaylistRow(new LegacyOperatorPlaylistRow(
$"legacy-fixed-{++_playlistSequence:D8}",
true,
marketText,
@@ -3952,7 +4277,7 @@ public sealed class LegacyOperatorController
var marketText = draft.Market == MMoneyCoderSharp.Data.IndustryMarket.Kospi
? "업종_코스피"
: "업종_코스닥";
- _playlist.Add(new LegacyOperatorPlaylistRow(
+ AppendPlaylistRow(new LegacyOperatorPlaylistRow(
$"legacy-industry-{++_playlistSequence:D8}",
draft.IsEnabled,
marketText,
@@ -3974,12 +4299,13 @@ public sealed class LegacyOperatorController
private void AppendOverseasRow(LegacyOverseasPlaylistDraft draft)
{
- _playlist.Add(new LegacyOperatorPlaylistRow(
+ var isCandle = string.Equals(draft.BuilderKey, "s8010", StringComparison.Ordinal);
+ AppendPlaylistRow(new LegacyOperatorPlaylistRow(
$"legacy-overseas-{++_playlistSequence:D8}",
draft.IsEnabled,
draft.Selection.GroupCode,
draft.Title,
- draft.Selection.GraphicType,
+ isCandle ? "캔들그래프" : draft.Selection.GraphicType,
draft.Selection.Subtype,
draft.InitialPageText,
draft.Selection.DataCode,
@@ -3989,7 +4315,7 @@ public sealed class LegacyOperatorController
PlayoutSelection: new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
draft.Selection.GroupCode,
draft.Selection.Subject,
- draft.Selection.GraphicType,
+ isCandle ? string.Empty : draft.Selection.GraphicType,
draft.Selection.Subtype,
draft.Selection.DataCode)));
}
@@ -3999,7 +4325,7 @@ public sealed class LegacyOperatorController
var separator = draft.Detail.IndexOf(" · ", StringComparison.Ordinal);
var graphicType = separator > 0 ? draft.Detail[..separator] : draft.Detail;
var subtype = separator > 0 ? draft.Detail[(separator + 3)..] : string.Empty;
- _playlist.Add(new LegacyOperatorPlaylistRow(
+ AppendPlaylistRow(new LegacyOperatorPlaylistRow(
$"legacy-theme-{++_playlistSequence:D8}",
true,
draft.Selection.GroupCode == "PAGED_THEME" ? "테마" : "테마_NXT",
@@ -4021,7 +4347,7 @@ public sealed class LegacyOperatorController
private void AppendComparisonRow(LegacyGenericPlaylistDraft draft)
{
- _playlist.Add(new LegacyOperatorPlaylistRow(
+ AppendPlaylistRow(new LegacyOperatorPlaylistRow(
$"legacy-comparison-{++_playlistSequence:D8}",
draft.IsEnabled,
"비교",
@@ -4038,7 +4364,7 @@ public sealed class LegacyOperatorController
private void AppendExpertRow(LegacyExpertPlaylistDraft draft)
{
- _playlist.Add(new LegacyOperatorPlaylistRow(
+ AppendPlaylistRow(new LegacyOperatorPlaylistRow(
$"legacy-expert-{++_playlistSequence:D8}",
draft.IsEnabled,
"전문가",
@@ -4056,7 +4382,7 @@ public sealed class LegacyOperatorController
private void AppendTradingHaltRow(LegacyTradingHaltPlaylistDraft draft)
{
var isCandle = draft.CandlePeriodDays.HasValue;
- _playlist.Add(new LegacyOperatorPlaylistRow(
+ AppendPlaylistRow(new LegacyOperatorPlaylistRow(
$"legacy-halt-{++_playlistSequence:D8}",
draft.IsEnabled,
draft.Market == "kospi" ? "코스피" : "코스닥",
@@ -4073,7 +4399,7 @@ public sealed class LegacyOperatorController
private void AppendManualFinancialRow(LegacyManualFinancialPlaylistDraft draft)
{
- _playlist.Add(new LegacyOperatorPlaylistRow(
+ AppendPlaylistRow(new LegacyOperatorPlaylistRow(
$"legacy-manual-financial-{++_playlistSequence:D8}",
true,
draft.Market,
@@ -4103,12 +4429,236 @@ public sealed class LegacyOperatorController
_ => throw new ArgumentOutOfRangeException(nameof(tabId), tabId, null)
};
- private static bool IsCutIndex(int physicalIndex) =>
- physicalIndex >= 0 && physicalIndex < LegacyStockCutCatalog.Rows.Count;
+ private bool IsCutIndex(int physicalIndex) =>
+ physicalIndex >= 0 && physicalIndex < _cutRows.Count;
+
+ private static IReadOnlyList CreateValidatedCutRows(
+ IReadOnlyList? cutRows)
+ {
+ var source = cutRows ?? LegacyStockCutCatalog.Rows;
+ if (source.Count is < 1 or > LegacyRuntimeStockCutMenuLoader.MaximumRows)
+ {
+ throw new ArgumentException(
+ "The stock cut menu must contain from 1 through 50 physical rows.",
+ nameof(cutRows));
+ }
+
+ var copiedRows = new LegacyCutCatalogRow[source.Count];
+ var displayOrdinal = 0;
+ for (var index = 0; index < source.Count; index++)
+ {
+ var row = source[index] ?? throw new ArgumentException(
+ "The stock cut menu cannot contain a null row.",
+ nameof(cutRows));
+ if (row.PhysicalIndex != index)
+ {
+ throw new ArgumentException(
+ "Stock cut physical indices must be contiguous and zero based.",
+ nameof(cutRows));
+ }
+
+ if (row.IsSeparator)
+ {
+ if (row.DisplayOrdinal is not null ||
+ !string.IsNullOrEmpty(row.RawLabel))
+ {
+ throw new ArgumentException(
+ "A stock cut separator must have no display ordinal or label.",
+ nameof(cutRows));
+ }
+ }
+ else
+ {
+ displayOrdinal++;
+ if (row.DisplayOrdinal != displayOrdinal)
+ {
+ throw new ArgumentException(
+ "Stock cut display ordinals must be contiguous across active rows.",
+ nameof(cutRows));
+ }
+
+ var isKnown = LegacyStockCutCatalog.Rows.Any(candidate =>
+ !candidate.IsSeparator && string.Equals(
+ candidate.RawLabel,
+ row.RawLabel,
+ StringComparison.Ordinal));
+ if (!isKnown)
+ {
+ throw new ArgumentException(
+ "The stock cut menu contains an unknown cut label.",
+ nameof(cutRows));
+ }
+ }
+
+ copiedRows[index] = row;
+ }
+
+ if (displayOrdinal == 0)
+ {
+ throw new ArgumentException(
+ "The stock cut menu must contain at least one active cut.",
+ nameof(cutRows));
+ }
+
+ return Array.AsReadOnly(copiedRows);
+ }
private int FindPlaylistIndex(string rowId) => _playlist.FindIndex(row =>
string.Equals(row.RowId, rowId, StringComparison.Ordinal));
+ private static int FindOriginalPlaylistIndex(
+ IReadOnlyList rows,
+ string? rowId)
+ {
+ if (rowId is null)
+ {
+ return -1;
+ }
+
+ for (var index = 0; index < rows.Count; index++)
+ {
+ if (string.Equals(rows[index].RowId, rowId, StringComparison.Ordinal))
+ {
+ return index;
+ }
+ }
+
+ return -1;
+ }
+
+ private void AppendPlaylistRow(LegacyOperatorPlaylistRow row)
+ {
+ ArgumentNullException.ThrowIfNull(row);
+ row = NormalizeMovingAverageIdentity(row);
+ var wasEmpty = _playlist.Count == 0;
+ _playlist.Add(row);
+ if (wasEmpty)
+ {
+ SelectInitialPlaylistCandidate();
+ }
+ }
+
+ private static LegacyOperatorPlaylistRow NormalizeMovingAverageIdentity(
+ LegacyOperatorPlaylistRow row)
+ {
+ var entry = row.ToPlayoutEntry();
+ if (!MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneMovingAverageOverlay
+ .Supports(entry.CutCode))
+ {
+ return row;
+ }
+
+ var graphicType = RemoveCapturedMovingAverageFlags(row.GraphicType);
+ var selection = row.PlayoutSelection;
+ var normalizedSelection = selection is null
+ ? null
+ : selection with
+ {
+ GraphicType = RemoveCapturedMovingAverageFlags(selection.GraphicType)
+ };
+ return string.Equals(graphicType, row.GraphicType, StringComparison.Ordinal) &&
+ Equals(normalizedSelection, selection)
+ ? row
+ : row with
+ {
+ GraphicType = graphicType,
+ PlayoutSelection = normalizedSelection
+ };
+ }
+
+ private static string RemoveCapturedMovingAverageFlags(string graphicType) =>
+ graphicType is "MA5" or "MA20" or "MA5,MA20"
+ ? string.Empty
+ : graphicType;
+
+ private void SelectInitialPlaylistCandidate()
+ {
+ if (_playlist.Count == 0)
+ {
+ return;
+ }
+
+ _activePlaylistRowId = _playlist[0].RowId;
+ // MainForm corrected m_rowidx to zero for the first F8 candidate, but it
+ // did not populate bufdel until an actual playlist row click occurred.
+ _selectedPlaylistRowIds.Clear();
+ _playlistCursorIndex = 0;
+ _playlistPriorCursorIndex = 0;
+ }
+
+ private string ResolveSurvivingPlaylistRowId(
+ IReadOnlyList originalRows,
+ ISet removedIds,
+ string? preferredRowId,
+ int preferredIndex)
+ {
+ if (preferredRowId is not null && FindPlaylistIndex(preferredRowId) >= 0)
+ {
+ return preferredRowId;
+ }
+
+ var resolvedOriginalIndex = FindNextThenPreviousSurvivor(
+ originalRows,
+ removedIds,
+ preferredIndex);
+ return originalRows[resolvedOriginalIndex].RowId;
+ }
+
+ private int ResolveSurvivingPlaylistIndex(
+ IReadOnlyList originalRows,
+ ISet removedIds,
+ string preferredRowId,
+ int preferredIndex)
+ {
+ var survivingIndex = FindPlaylistIndex(preferredRowId);
+ if (survivingIndex >= 0)
+ {
+ return survivingIndex;
+ }
+
+ var resolvedOriginalIndex = FindNextThenPreviousSurvivor(
+ originalRows,
+ removedIds,
+ preferredIndex);
+ return FindPlaylistIndex(originalRows[resolvedOriginalIndex].RowId);
+ }
+
+ private static int FindNextThenPreviousSurvivor(
+ IReadOnlyList originalRows,
+ ISet removedIds,
+ int preferredIndex)
+ {
+ if (preferredIndex < 0)
+ {
+ for (var index = 0; index < originalRows.Count; index++)
+ {
+ if (!removedIds.Contains(originalRows[index].RowId))
+ {
+ return index;
+ }
+ }
+ }
+
+ var start = Math.Clamp(preferredIndex, 0, originalRows.Count - 1);
+ for (var index = start + 1; index < originalRows.Count; index++)
+ {
+ if (!removedIds.Contains(originalRows[index].RowId))
+ {
+ return index;
+ }
+ }
+
+ for (var index = start - 1; index >= 0; index--)
+ {
+ if (!removedIds.Contains(originalRows[index].RowId))
+ {
+ return index;
+ }
+ }
+
+ throw new InvalidOperationException("삭제 후 남은 플레이리스트 행을 찾지 못했습니다.");
+ }
+
private void AddPlaylistRange(int firstIndex, int lastIndex)
{
var first = Math.Max(0, Math.Min(firstIndex, lastIndex));
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorPlayoutModels.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorPlayoutModels.cs
index 0ce0109..7bfc397 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorPlayoutModels.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorPlayoutModels.cs
@@ -53,4 +53,7 @@ public sealed record LegacyOperatorPlayoutSnapshot(
bool BackgroundEnabled,
string BackgroundFileName,
bool CanChangeBackground,
- string Message);
+ string Message)
+{
+ public bool IsTakeOutCompletionPending { get; init; }
+}
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs
index 6d8a20f..99c3e7e 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs
@@ -273,6 +273,41 @@ public sealed class LegacyOverseasSelectionWorkflow
}
}
+ ///
+ /// Recreates UC5's per-control search and selection state while retaining the
+ /// MainForm-owned moving-average checkboxes. Incrementing both generations makes
+ /// results from the discarded control stale before they can commit.
+ ///
+ public LegacyOverseasWorkflowSnapshot ResetForTabActivation()
+ {
+ lock (_sync)
+ {
+ checked
+ {
+ _industryGeneration++;
+ _stockGeneration++;
+ }
+
+ _selectedFixedIndex = null;
+ _industryStatus = LegacyOverseasSearchStatus.Idle;
+ _industryQuery = string.Empty;
+ _industryRetrievedAt = null;
+ _industryResults = NoIndustryResults;
+ _industryIsTruncated = false;
+ _selectedIndustry = null;
+ _industryError = string.Empty;
+ _stockStatus = LegacyOverseasSearchStatus.Idle;
+ _stockQuery = string.Empty;
+ _stockRetrievedAt = null;
+ _stockResults = NoStockResults;
+ _stockIsTruncated = false;
+ _selectedStock = null;
+ _stockError = string.Empty;
+ Touch();
+ return CreateSnapshot();
+ }
+ }
+
public LegacyOverseasWorkflowSnapshot SelectFixedIndex(string targetId)
{
var target = LegacyOverseasActionCatalog.GetFixedIndexTarget(targetId);
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyRuntimeStockCutMenuLoader.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyRuntimeStockCutMenuLoader.cs
new file mode 100644
index 0000000..ee49cbe
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyRuntimeStockCutMenuLoader.cs
@@ -0,0 +1,355 @@
+using System.Text;
+
+namespace MBN_STOCK_WEBVIEW.LegacyApplication;
+
+public enum LegacyRuntimeStockCutMenuFailure
+{
+ None,
+ InvalidPath,
+ FileNotFound,
+ FileUnavailable,
+ FileTooLarge,
+ InvalidEncoding,
+ InvalidFormat,
+ TooManyRows,
+ UnknownCut,
+ EmptyMenu
+}
+
+public enum LegacyRuntimeStockCutMenuSource
+{
+ ExternalOverride,
+ Packaged,
+ BuiltIn
+}
+
+public sealed record LegacyRuntimeStockCutMenuComposition(
+ IReadOnlyList Rows,
+ LegacyRuntimeStockCutMenuSource Source,
+ LegacyRuntimeStockCutMenuFailure Failure,
+ string? WarningMessage)
+{
+ public bool UsesBuiltInCatalog => Source == LegacyRuntimeStockCutMenuSource.BuiltIn;
+}
+
+public static class LegacyRuntimeStockCutMenuLoader
+{
+ public const int MaximumRows = 50;
+ public const int MaximumFileBytes = 64 * 1024;
+ public const string StockSectionHeader = "[종목]";
+ public const string FallbackWarningMessage =
+ "런타임 컷 메뉴를 읽지 못해 내장 컷 목록을 사용합니다.";
+
+ private static readonly Encoding Cp949 = CreateCp949Encoding();
+ private static readonly HashSet KnownCutLabels = LegacyStockCutCatalog.Rows
+ .Where(row => !row.IsSeparator)
+ .Select(row => row.RawLabel)
+ .ToHashSet(StringComparer.Ordinal);
+
+ public static LegacyRuntimeStockCutMenuComposition ResolveFromBaseDirectory(
+ string? baseDirectory)
+ {
+ if (!TryCreatePackagedCandidatePath(baseDirectory, out var path))
+ {
+ return CreateFallbackComposition(
+ LegacyRuntimeStockCutMenuFailure.InvalidPath);
+ }
+
+ if (TryLoad(path, out var rows, out var failure))
+ {
+ return CreateRuntimeComposition(
+ rows,
+ LegacyRuntimeStockCutMenuSource.Packaged);
+ }
+
+ return CreateFallbackComposition(failure);
+ }
+
+ public static LegacyRuntimeStockCutMenuComposition ResolveFromCandidates(
+ string? externalOverridePath,
+ string? packagedBaseDirectory)
+ {
+ if (externalOverridePath is not null)
+ {
+ if (!TryNormalizeExternalOverridePath(
+ externalOverridePath,
+ out var normalizedExternalPath))
+ {
+ return CreateFallbackComposition(
+ LegacyRuntimeStockCutMenuFailure.InvalidPath);
+ }
+
+ if (TryLoad(
+ normalizedExternalPath,
+ out var externalRows,
+ out var externalFailure))
+ {
+ return CreateRuntimeComposition(
+ externalRows,
+ LegacyRuntimeStockCutMenuSource.ExternalOverride);
+ }
+
+ if (externalFailure != LegacyRuntimeStockCutMenuFailure.FileNotFound)
+ {
+ return CreateFallbackComposition(externalFailure);
+ }
+ }
+
+ return ResolveFromBaseDirectory(packagedBaseDirectory);
+ }
+
+ public static bool TryLoad(
+ string? path,
+ out IReadOnlyList rows,
+ out LegacyRuntimeStockCutMenuFailure failure)
+ {
+ rows = Array.Empty();
+ if (string.IsNullOrWhiteSpace(path) ||
+ path.Any(char.IsControl) ||
+ !Path.IsPathFullyQualified(path))
+ {
+ failure = LegacyRuntimeStockCutMenuFailure.InvalidPath;
+ return false;
+ }
+
+ try
+ {
+ using var stream = new FileStream(
+ path,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.Read,
+ bufferSize: 4096,
+ FileOptions.SequentialScan);
+ if (stream.Length > MaximumFileBytes)
+ {
+ failure = LegacyRuntimeStockCutMenuFailure.FileTooLarge;
+ return false;
+ }
+
+ var bytes = new byte[(int)stream.Length];
+ stream.ReadExactly(bytes);
+ return TryParse(bytes, out rows, out failure);
+ }
+ catch (Exception exception) when (
+ exception is FileNotFoundException or
+ DirectoryNotFoundException)
+ {
+ failure = LegacyRuntimeStockCutMenuFailure.FileNotFound;
+ return false;
+ }
+ catch (Exception exception) when (
+ exception is IOException or
+ UnauthorizedAccessException or
+ NotSupportedException or
+ ArgumentException or
+ System.Security.SecurityException)
+ {
+ failure = LegacyRuntimeStockCutMenuFailure.FileUnavailable;
+ return false;
+ }
+ }
+
+ public static bool TryParse(
+ ReadOnlySpan cp949Bytes,
+ out IReadOnlyList rows,
+ out LegacyRuntimeStockCutMenuFailure failure)
+ {
+ rows = Array.Empty();
+ if (cp949Bytes.Length > MaximumFileBytes)
+ {
+ failure = LegacyRuntimeStockCutMenuFailure.FileTooLarge;
+ return false;
+ }
+
+ string text;
+ try
+ {
+ text = Cp949.GetString(cp949Bytes);
+ }
+ catch (DecoderFallbackException)
+ {
+ failure = LegacyRuntimeStockCutMenuFailure.InvalidEncoding;
+ return false;
+ }
+
+ return TryParseDecodedText(text, out rows, out failure);
+ }
+
+ private static bool TryParseDecodedText(
+ string text,
+ out IReadOnlyList rows,
+ out LegacyRuntimeStockCutMenuFailure failure)
+ {
+ rows = Array.Empty();
+ var parsedRows = new List();
+ var sawSection = false;
+ var displayOrdinal = 0;
+
+ using var reader = new StringReader(text);
+ while (reader.ReadLine() is { } sourceLine)
+ {
+ var line = sourceLine.Trim();
+ if (line.Length == 0)
+ {
+ continue;
+ }
+
+ if (!sawSection)
+ {
+ if (!string.Equals(line, StockSectionHeader, StringComparison.Ordinal))
+ {
+ failure = LegacyRuntimeStockCutMenuFailure.InvalidFormat;
+ return false;
+ }
+
+ sawSection = true;
+ continue;
+ }
+
+ if (line.StartsWith("[", StringComparison.Ordinal))
+ {
+ failure = LegacyRuntimeStockCutMenuFailure.InvalidFormat;
+ return false;
+ }
+
+ if (parsedRows.Count >= MaximumRows)
+ {
+ failure = LegacyRuntimeStockCutMenuFailure.TooManyRows;
+ return false;
+ }
+
+ var isSeparator = string.Equals(line, "-", StringComparison.Ordinal);
+ if (!isSeparator && !KnownCutLabels.Contains(line))
+ {
+ failure = LegacyRuntimeStockCutMenuFailure.UnknownCut;
+ return false;
+ }
+
+ parsedRows.Add(new LegacyCutCatalogRow(
+ parsedRows.Count,
+ isSeparator ? null : ++displayOrdinal,
+ isSeparator ? string.Empty : line,
+ isSeparator));
+ }
+
+ if (!sawSection)
+ {
+ failure = LegacyRuntimeStockCutMenuFailure.InvalidFormat;
+ return false;
+ }
+
+ if (displayOrdinal == 0)
+ {
+ failure = LegacyRuntimeStockCutMenuFailure.EmptyMenu;
+ return false;
+ }
+
+ rows = parsedRows.AsReadOnly();
+ failure = LegacyRuntimeStockCutMenuFailure.None;
+ return true;
+ }
+
+ private static Encoding CreateCp949Encoding()
+ {
+ Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
+ return Encoding.GetEncoding(
+ 949,
+ EncoderFallback.ExceptionFallback,
+ DecoderFallback.ExceptionFallback);
+ }
+
+ private static LegacyRuntimeStockCutMenuComposition CreateFallbackComposition(
+ LegacyRuntimeStockCutMenuFailure failure) =>
+ new(
+ LegacyStockCutCatalog.Rows,
+ LegacyRuntimeStockCutMenuSource.BuiltIn,
+ failure,
+ FallbackWarningMessage);
+
+ private static LegacyRuntimeStockCutMenuComposition CreateRuntimeComposition(
+ IReadOnlyList rows,
+ LegacyRuntimeStockCutMenuSource source) =>
+ new(
+ rows,
+ source,
+ LegacyRuntimeStockCutMenuFailure.None,
+ WarningMessage: null);
+
+ private static bool TryCreatePackagedCandidatePath(
+ string? baseDirectory,
+ out string path)
+ {
+ path = string.Empty;
+ if (string.IsNullOrWhiteSpace(baseDirectory) ||
+ baseDirectory.Any(char.IsControl) ||
+ !Path.IsPathFullyQualified(baseDirectory))
+ {
+ return false;
+ }
+
+ try
+ {
+ path = Path.Combine(
+ Path.GetFullPath(baseDirectory),
+ "Res",
+ "종목.ini");
+ return true;
+ }
+ catch (Exception exception) when (
+ exception is ArgumentException or
+ IOException or
+ NotSupportedException or
+ System.Security.SecurityException)
+ {
+ return false;
+ }
+ }
+
+ private static bool TryNormalizeExternalOverridePath(
+ string path,
+ out string normalizedPath)
+ {
+ normalizedPath = string.Empty;
+ if (string.IsNullOrWhiteSpace(path) ||
+ path.Any(char.IsControl) ||
+ !Path.IsPathFullyQualified(path))
+ {
+ return false;
+ }
+
+ try
+ {
+ normalizedPath = Path.GetFullPath(path);
+ var resourceDirectory = Path.GetDirectoryName(normalizedPath);
+ var applicationDirectory = resourceDirectory is null
+ ? null
+ : Path.GetDirectoryName(resourceDirectory);
+ return string.Equals(
+ Path.GetFileName(normalizedPath),
+ "종목.ini",
+ StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(
+ resourceDirectory is null
+ ? null
+ : Path.GetFileName(resourceDirectory),
+ "Res",
+ StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(
+ applicationDirectory is null
+ ? null
+ : Path.GetFileName(applicationDirectory),
+ "MBN_STOCK_WEBVIEW",
+ StringComparison.OrdinalIgnoreCase);
+ }
+ catch (Exception exception) when (
+ exception is ArgumentException or
+ IOException or
+ NotSupportedException or
+ System.Security.SecurityException)
+ {
+ normalizedPath = string.Empty;
+ return false;
+ }
+ }
+}
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs
index 8734f8e..6cff600 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs
@@ -1,3 +1,4 @@
+using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
@@ -21,7 +22,8 @@ public sealed record LegacyOperatorPlaylistRow(
int FadeDuration,
bool IsSelected = false,
string SceneAlias = "",
- LegacySceneSelection? PlayoutSelection = null)
+ LegacySceneSelection? PlayoutSelection = null,
+ bool IsActive = false)
{
public LegacyPlaylistEntry ToPlayoutEntry()
{
@@ -40,7 +42,47 @@ public sealed record LegacyOperatorPlaylistRow(
StockName,
GraphicType,
Subtype,
- StockCode));
+ StockCode),
+ CreatePageNavigation());
+ }
+
+ private LegacyPlaylistPageNavigation CreatePageNavigation()
+ {
+ var pageSize = GraphicType switch
+ {
+ "5단 표그래프" => ScenePageSize.Five,
+ "6종목 현재가" => ScenePageSize.Six,
+ "12종목 현재가" => ScenePageSize.Twelve,
+ _ => (ScenePageSize?)null
+ };
+ if (!pageSize.HasValue)
+ {
+ // MainForm's PageN branches compared the visible FarPoint column text
+ // exactly. A scene alias that happens to resolve to a paged builder is still
+ // an ordinary playlist row when that column uses any other spelling.
+ return LegacyPlaylistPageNavigation.Disabled;
+ }
+
+ var separator = PageText.IndexOf('/');
+ if (separator <= 0 || separator != PageText.LastIndexOf('/') ||
+ !int.TryParse(
+ PageText.AsSpan(0, separator),
+ System.Globalization.NumberStyles.None,
+ System.Globalization.CultureInfo.InvariantCulture,
+ out var currentPage) ||
+ !int.TryParse(
+ PageText.AsSpan(separator + 1),
+ System.Globalization.NumberStyles.None,
+ System.Globalization.CultureInfo.InvariantCulture,
+ out var totalPages) ||
+ totalPages is < 1 or > ScenePaging.MaximumPageCount ||
+ currentPage < 1 || currentPage > totalPages)
+ {
+ throw new InvalidOperationException(
+ "A PageN playlist row must use current/total with a total from 1 through 20.");
+ }
+
+ return new LegacyPlaylistPageNavigation(pageSize, totalPages);
}
}
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/TradingHalt/LegacyTradingHaltWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/TradingHalt/LegacyTradingHaltWorkflow.cs
index bfac150..4ef8ebd 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/TradingHalt/LegacyTradingHaltWorkflow.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/TradingHalt/LegacyTradingHaltWorkflow.cs
@@ -119,6 +119,33 @@ public sealed class LegacyTradingHaltWorkflowController
}
}
+ ///
+ /// Recreates UC7's per-control query, result, selection and column-sort state.
+ /// MainForm-owned moving-average values remain in force across tab activation.
+ ///
+ public LegacyTradingHaltWorkflowSnapshot ResetForTabActivation()
+ {
+ lock (_gate)
+ {
+ checked
+ {
+ _searchGeneration++;
+ }
+
+ _results = [];
+ _selected = null;
+ _query = string.Empty;
+ _errorMessage = string.Empty;
+ _statusMessage = string.Empty;
+ _status = LegacyTradingHaltRequestStatus.Idle;
+ _nameSort = LegacyTradingHaltNameSort.None;
+ _statusKind = LegacyOperatorStatusKind.Neutral;
+ _isTruncated = false;
+ Touch();
+ return CreateSnapshot();
+ }
+ }
+
public async Task SearchAsync(
string query,
CancellationToken cancellationToken = default)
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs
index 833f26f..bb99092 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs
@@ -24,6 +24,13 @@ public sealed record LegacyCutPointerDownIntent(
int Y,
long TimestampMilliseconds) : LegacyUiIntent;
+public sealed record LegacyCutKeyDownIntent(
+ LegacyCutKeyboardKey Key,
+ bool Control,
+ bool Shift) : LegacyUiIntent;
+
+public sealed record LegacyClearCutSelectionIntent : LegacyUiIntent;
+
public sealed record LegacyDropSelectedCutsIntent : LegacyUiIntent;
public sealed record LegacySetPlaylistEnabledIntent(string RowId, bool Enabled)
@@ -37,6 +44,8 @@ public sealed record LegacySelectPlaylistRowIntent(
bool Control,
bool Shift) : LegacyUiIntent;
+public sealed record LegacyActivatePlaylistRowIntent(string RowId) : LegacyUiIntent;
+
public sealed record LegacyMoveSelectedPlaylistRowsIntent(
LegacyPlaylistMoveDirection Direction) : LegacyUiIntent;
@@ -349,10 +358,13 @@ public static class LegacyBridgeProtocol
"search-stocks" => ParseSearch(payload),
"select-stock" => ParseStockSelection(payload),
"cut-pointer-down" => ParseCutPointer(payload),
+ "cut-key-down" => ParseCutKeyDown(payload),
+ "clear-cut-selection" => ParseClearCutSelection(payload),
"drop-selected-cuts" => ParseDrop(payload),
"set-playlist-enabled" => ParseEnabled(payload),
"set-all-playlist-enabled" => ParseAllEnabled(payload),
"select-playlist-row" => ParsePlaylistSelection(payload),
+ "activate-playlist-row" => ParsePlaylistActivation(payload),
"move-selected-playlist-rows" => ParsePlaylistMove(payload),
"reorder-playlist-rows" => ParsePlaylistReorder(payload),
"delete-selected-playlist-rows" => ParseDeleteSelected(payload),
@@ -565,6 +577,7 @@ public static class LegacyBridgeProtocol
row.RowId,
row.IsEnabled,
row.IsSelected,
+ row.IsActive,
row.MarketText,
row.StockName,
row.GraphicType,
@@ -1020,10 +1033,12 @@ public static class LegacyBridgeProtocol
playout.IsCommandAvailable,
playout.LiveTakeInAllowed,
playout.IsPlayCompletionPending,
+ playout.IsTakeOutCompletionPending,
playout.OutcomeUnknown,
playout.PreparedCode,
playout.OnAirCode,
playout.CurrentCueIndexZeroBased,
+ playout.CurrentEntryId,
playout.PageIndexZeroBased,
playout.PageCount,
playout.PageSize,
@@ -1089,7 +1104,12 @@ public static class LegacyBridgeProtocol
"x",
"y",
"timestampMilliseconds") ||
- !TryInt32(payload, "physicalIndex", 0, 32, out var physicalIndex) ||
+ !TryInt32(
+ payload,
+ "physicalIndex",
+ 0,
+ LegacyRuntimeStockCutMenuLoader.MaximumRows - 1,
+ out var physicalIndex) ||
!TryBoolean(payload, "control", out var control) ||
!TryBoolean(payload, "shift", out var shift) ||
!TryInt32(payload, "x", 0, 16_384, out var x) ||
@@ -1111,6 +1131,33 @@ public static class LegacyBridgeProtocol
private static LegacyUiIntent? ParseDrop(JsonElement payload) =>
HasOnlyProperties(payload) ? new LegacyDropSelectedCutsIntent() : null;
+ private static LegacyUiIntent? ParseCutKeyDown(JsonElement payload)
+ {
+ if (!HasOnlyProperties(payload, "key", "control", "shift") ||
+ !TryString(payload, "key", 16, out var keyText) ||
+ !TryBoolean(payload, "control", out var control) ||
+ !TryBoolean(payload, "shift", out var shift))
+ {
+ return null;
+ }
+
+ var key = keyText switch
+ {
+ "arrow-up" => LegacyCutKeyboardKey.ArrowUp,
+ "arrow-down" => LegacyCutKeyboardKey.ArrowDown,
+ "home" => LegacyCutKeyboardKey.Home,
+ "end" => LegacyCutKeyboardKey.End,
+ "space" => LegacyCutKeyboardKey.Space,
+ _ => (LegacyCutKeyboardKey?)null
+ };
+ return key.HasValue
+ ? new LegacyCutKeyDownIntent(key.Value, control, shift)
+ : null;
+ }
+
+ private static LegacyUiIntent? ParseClearCutSelection(JsonElement payload) =>
+ HasOnlyProperties(payload) ? new LegacyClearCutSelectionIntent() : null;
+
private static LegacyUiIntent? ParseEnabled(JsonElement payload)
{
if (!HasOnlyProperties(payload, "rowId", "enabled") ||
@@ -1144,6 +1191,13 @@ public static class LegacyBridgeProtocol
return new LegacySelectPlaylistRowIntent(rowId, control, shift);
}
+ private static LegacyUiIntent? ParsePlaylistActivation(JsonElement payload) =>
+ HasOnlyProperties(payload, "rowId") &&
+ TryString(payload, "rowId", 128, out var rowId) &&
+ IsSafeIdentifier(rowId)
+ ? new LegacyActivatePlaylistRowIntent(rowId)
+ : null;
+
private static LegacyUiIntent? ParsePlaylistMove(JsonElement payload)
{
if (!HasOnlyProperties(payload, "direction") ||
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyPlayoutUiIntents.cs b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyPlayoutUiIntents.cs
index d7f463d..b989609 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyPlayoutUiIntents.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyPlayoutUiIntents.cs
@@ -51,7 +51,7 @@ internal static class LegacyPlayoutUiIntentParser
!payload.TryGetProperty("duration", out var durationElement) ||
durationElement.ValueKind != JsonValueKind.Number ||
!durationElement.TryGetInt32(out var duration) ||
- duration is < 0 or > 60)
+ duration is < 0 or > 19)
{
return null;
}
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.CloseConfirmation.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.CloseConfirmation.cs
index 0262a84..e2478df 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.CloseConfirmation.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.CloseConfirmation.cs
@@ -59,10 +59,10 @@ public sealed partial class MainWindow
var dialog = new ContentDialog
{
XamlRoot = Root.XamlRoot,
- Title = "프로그램 종료",
+ Title = "매일경제TV",
Content = BuildCloseConfirmationMessage(),
- PrimaryButtonText = "종료",
- CloseButtonText = "취소",
+ PrimaryButtonText = "예",
+ CloseButtonText = "아니요",
DefaultButton = ContentDialogButton.Close
};
@@ -94,7 +94,8 @@ public sealed partial class MainWindow
var hasOnAirScene = !string.IsNullOrWhiteSpace(status?.OnAirSceneName) ||
!string.IsNullOrWhiteSpace(workflow?.OnAirCutCode);
var commandPending = Volatile.Read(ref _playoutBusy) != 0 ||
- status?.IsPlayCompletionPending == true;
+ status?.IsPlayCompletionPending == true ||
+ status?.IsTakeOutCompletionPending == true;
var outcomeUnknown = Volatile.Read(ref _playoutQuarantined) != 0 ||
status?.State == PlayoutConnectionState.OutcomeUnknown;
@@ -116,7 +117,7 @@ public sealed partial class MainWindow
"PGM을 확인하세요. 종료 과정에서는 TAKE OUT을 자동 실행하지 않습니다. 그래도 종료하시겠습니까?";
}
- return "프로그램을 종료하시겠습니까?";
+ return "프로그램을 종료하겠습니까?";
}
private void DetachCloseConfirmation()
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs
index 0aff3ef..e207c70 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs
@@ -136,42 +136,44 @@ public sealed partial class MainWindow
try
{
Interlocked.Exchange(ref _playoutBusy, 1);
- StopRefreshLoop();
var nextKindBefore = workflow.State.NextKind;
- if (command == LegacyOperatorPlayoutCommand.Next &&
+ var pageNavigationEnabledBefore = workflow.State.IsPageNavigationEnabled;
+ if (engine.Status.IsTakeOutCompletionPending)
+ {
+ return _controller.ReportError(
+ "Tornado TAKE OUT 완료 이벤트를 기다리는 동안에는 송출 명령을 실행할 수 없습니다.");
+ }
+
+ if (command != LegacyOperatorPlayoutCommand.TakeOut &&
engine.Status.IsPlayCompletionPending)
{
return _controller.ReportError(
- "Tornado 재생 완료 이벤트를 기다리는 중입니다. NEXT를 반복하지 마세요.");
+ "Tornado 재생 완료 이벤트를 기다리는 중입니다. 결과가 확인될 때까지 TAKE OUT만 사용할 수 있습니다.");
}
+ StopRefreshLoop();
PlayoutResult result;
switch (command)
{
case LegacyOperatorPlayoutCommand.Prepare:
- var request = _controller.CreatePlayoutRequest();
+ var request = _controller.CreatePlayoutRequest(
+ _compositionOptions!.Current.FadeDuration);
result = await workflow.PrepareAsync(
request.Playlist,
request.SelectedIndexZeroBased,
cancellationToken).ConfigureAwait(false);
break;
case LegacyOperatorPlayoutCommand.TakeIn:
- // MainForm.btnTake_Click performed ONAirMode (load/prepare) and
- // Play in one visible F8 action. Keep the explicit PREPARE control
- // for engineering checks, but preserve the original one-click path
- // whenever no scene is already prepared.
- if (string.IsNullOrWhiteSpace(workflow.State.PreparedCutCode))
- {
- var takeInRequest = _controller.CreatePlayoutRequest();
- result = await workflow.PrepareAndTakeInAsync(
- takeInRequest.Playlist,
- takeInRequest.SelectedIndexZeroBased,
- cancellationToken).ConfigureAwait(false);
- }
- else
- {
- result = await workflow.TakeInAsync(cancellationToken).ConfigureAwait(false);
- }
+ // MainForm.btnTake_Click always copied m_rowidx (the operator's
+ // candidate) to m_crow and ran ONAirMode + Play, even while another
+ // row was already on air. Re-read the current selection on every F8;
+ // a stale explicit PREPARE must never override a later row click.
+ var takeInRequest = _controller.CreatePlayoutRequest(
+ _compositionOptions!.Current.FadeDuration);
+ result = await workflow.TakeSelectedAsync(
+ takeInRequest.Playlist,
+ takeInRequest.SelectedIndexZeroBased,
+ cancellationToken).ConfigureAwait(false);
break;
case LegacyOperatorPlayoutCommand.Next:
result = await workflow.NextAsync(cancellationToken).ConfigureAwait(false);
@@ -185,6 +187,23 @@ public sealed partial class MainWindow
throw new ArgumentOutOfRangeException(nameof(command));
}
+ if (result.IsSuccess &&
+ command != LegacyOperatorPlayoutCommand.TakeOut &&
+ workflow.State.CurrentEntryId is { Length: > 0 } currentEntryId &&
+ workflow.State.PageCount > 0)
+ {
+ // FarPoint column 5 was mutable operator state: F8/PREPARE reset a
+ // paged cut to 1/N, while Page NEXT left 2/N, 3/N, ... in the row.
+ // NEXT must not replace the independently selected F8 candidate.
+ _controller.ReflectSuccessfulPlayout(
+ currentEntryId,
+ workflow.State.PageIndexZeroBased,
+ workflow.State.PageCount,
+ synchronizeOperatorSelection:
+ command is LegacyOperatorPlayoutCommand.Prepare or
+ LegacyOperatorPlayoutCommand.TakeIn);
+ }
+
if (result.Code is PlayoutResultCode.OutcomeUnknown or PlayoutResultCode.TimedOut)
{
await QuarantinePlayoutAsync().ConfigureAwait(false);
@@ -197,9 +216,15 @@ public sealed partial class MainWindow
return _controller.ReportError(result.Message);
}
+ if (command == LegacyOperatorPlayoutCommand.TakeOut)
+ {
+ ResetRefreshState();
+ }
+
if (command == LegacyOperatorPlayoutCommand.TakeIn ||
command == LegacyOperatorPlayoutCommand.Next &&
- nextKindBefore == LegacyWorkflowNextKind.PlaylistNext)
+ nextKindBefore == LegacyWorkflowNextKind.PlaylistNext &&
+ !pageNavigationEnabledBefore)
{
StartRefreshLoop(workflow);
}
@@ -237,9 +262,9 @@ public sealed partial class MainWindow
int fadeDuration,
CancellationToken cancellationToken)
{
- if (fadeDuration is < 0 or > 60)
+ if (fadeDuration is < 0 or > 19)
{
- return _controller.ReportError("Fade 값이 올바르지 않습니다.");
+ return _controller.ReportError("DissolveTime 값이 올바르지 않습니다.");
}
if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
@@ -251,13 +276,13 @@ public sealed partial class MainWindow
{
if (!CanChangeComposition())
{
- return _controller.ReportError("Fade는 IDLE 상태에서만 변경할 수 있습니다.");
+ return _controller.ReportError("DissolveTime은 IDLE 상태에서만 변경할 수 있습니다.");
}
var source = _compositionOptions!;
source.Update(source.Current with { FadeDuration = fadeDuration });
return _controller.ReportInformation(
- $"Fade {fadeDuration}을 다음 PREPARE부터 적용합니다.");
+ $"DissolveTime {fadeDuration + 1}을 다음 PREPARE부터 적용합니다.");
}
finally
{
@@ -265,6 +290,14 @@ public sealed partial class MainWindow
}
}
+ private LegacyOperatorSnapshot SelectPlaylistBoundaryAndStopRefresh(bool last)
+ {
+ // MainForm.ProcessCmdKey stopped timer1 before applying either Home or End.
+ // Ordinary row selection deliberately does not use this path.
+ StopRefreshLoop();
+ return _controller.SelectPlaylistBoundary(last);
+ }
+
private async Task ToggleBackgroundAsync(
CancellationToken cancellationToken)
{
@@ -390,6 +423,7 @@ public sealed partial class MainWindow
Volatile.Read(ref _playoutBusy) == 0 &&
Volatile.Read(ref _playoutQuarantined) == 0 &&
!status.IsPlayCompletionPending &&
+ !status.IsTakeOutCompletionPending &&
string.IsNullOrWhiteSpace(status.PreparedSceneName) &&
string.IsNullOrWhiteSpace(status.OnAirSceneName) &&
string.IsNullOrWhiteSpace(workflow.PreparedCutCode) &&
@@ -413,6 +447,7 @@ public sealed partial class MainWindow
Volatile.Read(ref _playoutShutdownStarted) == 0 &&
status.State != PlayoutConnectionState.OutcomeUnknown &&
!status.IsPlayCompletionPending &&
+ !status.IsTakeOutCompletionPending &&
string.IsNullOrWhiteSpace(status.PreparedSceneName) &&
string.IsNullOrWhiteSpace(status.OnAirSceneName) &&
string.IsNullOrWhiteSpace(workflow.PreparedCutCode) &&
@@ -421,6 +456,9 @@ public sealed partial class MainWindow
}
private static bool IsOperatorMutationIntent(LegacyUiIntent intent) => intent is
+ LegacyCutPointerDownIntent or
+ LegacyCutKeyDownIntent or
+ LegacyClearCutSelectionIntent or
LegacyDropSelectedCutsIntent or
LegacySetPlaylistEnabledIntent or
LegacySetAllPlaylistEnabledIntent or
@@ -521,7 +559,7 @@ public sealed partial class MainWindow
: LegacyOperatorPlayoutPhase.Idle;
var backgroundEnabled =
composition.BackgroundKind != LegacySceneBackgroundKind.None;
- return new LegacyOperatorPlayoutSnapshot(
+ var snapshot = new LegacyOperatorPlayoutSnapshot(
status?.Mode ?? PlayoutMode.Disabled,
quarantined
? PlayoutConnectionState.OutcomeUnknown
@@ -563,6 +601,10 @@ public sealed partial class MainWindow
: _playoutInitializationWarning ?? status?.Message ??
_playoutInitializationError ??
"송출 어댑터를 사용할 수 없습니다.");
+ return snapshot with
+ {
+ IsTakeOutCompletionPending = status?.IsTakeOutCompletionPending ?? false
+ };
}
private void StartRefreshLoop(LegacyPlayoutWorkflow workflow)
@@ -599,6 +641,14 @@ public sealed partial class MainWindow
}
}
+ private void ResetRefreshState()
+ {
+ StopRefreshLoop();
+ Interlocked.Exchange(ref _refreshCompletedCount, 0);
+ _refreshLimitReached = false;
+ _refreshMessage = string.Empty;
+ }
+
private async Task RunRefreshLoopAsync(
LegacyPlayoutWorkflow workflow,
TimeSpan firstInterval,
@@ -809,7 +859,8 @@ public sealed partial class MainWindow
if (Volatile.Read(ref _playoutQuarantined) == 0 &&
engine.Status.State != PlayoutConnectionState.OutcomeUnknown &&
string.IsNullOrWhiteSpace(engine.Status.OnAirSceneName) &&
- !engine.Status.IsPlayCompletionPending)
+ !engine.Status.IsPlayCompletionPending &&
+ !engine.Status.IsTakeOutCompletionPending)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await engine.DisconnectAsync(timeout.Token).ConfigureAwait(false);
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs
index 0a422b7..abd5a6c 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs
@@ -24,6 +24,18 @@ public sealed partial class MainWindow : Window
{
InitializeComponent();
EnsureCloseConfirmationAttached();
+ var localApplicationData = Environment.GetFolderPath(
+ Environment.SpecialFolder.LocalApplicationData);
+ var runtimeCutMenuOverridePath = string.IsNullOrWhiteSpace(localApplicationData)
+ ? null
+ : Path.Combine(
+ localApplicationData,
+ "MBN_STOCK_WEBVIEW",
+ "Res",
+ "종목.ini");
+ var cutMenuComposition = LegacyRuntimeStockCutMenuLoader.ResolveFromCandidates(
+ runtimeCutMenuOverridePath,
+ AppContext.BaseDirectory);
ILegacyStockLookup stockLookup;
IIndustrySelectionService industrySelectionService;
@@ -153,11 +165,16 @@ public sealed partial class MainWindow : Window
expertCatalogPersistenceService,
manualFinancialStockSearchService,
stockMasterIdentityValidationService,
- operatorCatalogSchemaValidationService));
+ operatorCatalogSchemaValidationService),
+ cutRows: cutMenuComposition.Rows);
if (initializationError is not null)
{
_controller.ReportError(initializationError);
}
+ else if (cutMenuComposition.WarningMessage is { } cutMenuWarning)
+ {
+ _controller.ReportWarning(cutMenuWarning);
+ }
InitializePlayoutRuntime();
@@ -470,6 +487,11 @@ public sealed partial class MainWindow : Window
pointer.X,
pointer.Y,
pointer.TimestampMilliseconds),
+ LegacyCutKeyDownIntent key => _controller.CutKeyDown(
+ key.Key,
+ key.Control,
+ key.Shift),
+ LegacyClearCutSelectionIntent => _controller.ClearCutSelection(),
LegacyDropSelectedCutsIntent => _controller.DropSelectedCutsOnPlaylist(),
LegacySetPlaylistEnabledIntent enabled =>
_controller.SetPlaylistRowEnabled(enabled.RowId, enabled.Enabled),
@@ -480,6 +502,8 @@ public sealed partial class MainWindow : Window
selection.RowId,
selection.Control,
selection.Shift),
+ LegacyActivatePlaylistRowIntent activation =>
+ _controller.ActivatePlaylistRow(activation.RowId),
LegacyMoveSelectedPlaylistRowsIntent move =>
_controller.MoveSelectedPlaylistRows(move.Direction),
LegacyReorderPlaylistRowsIntent reorder =>
@@ -494,7 +518,7 @@ public sealed partial class MainWindow : Window
LegacyToggleActivePlaylistEnabledIntent =>
_controller.ToggleActivePlaylistRowEnabled(),
LegacySelectPlaylistBoundaryIntent boundary =>
- _controller.SelectPlaylistBoundary(boundary.Last),
+ SelectPlaylistBoundaryAndStopRefresh(boundary.Last),
LegacySelectTabIntent tab =>
await _controller.SelectTabAsync(tab.TabId, cancellationToken),
LegacySwapTabsIntent tabs =>
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js
index 07f87be..5cb5bb4 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js
+++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js
@@ -96,12 +96,194 @@
let deferredPlaylistPlainSelectionRowId = null;
let playlistDragBlockedRowId = null;
let playlistMutationLocked = false;
+ let cutSelectionEnabled = false;
+ let lastPresentedPlayoutRowKey = "";
let draggedTabId = null;
let tabDragLastTargetId = null;
let pendingTabHoverSwap = null;
let draggedOperatorCatalogRowId = null;
let operatorCatalogDragBlockedRowId = null;
let operatorCatalogMutationLocked = false;
+ let renderedTreeTabId = null;
+ let treeInteractionLocked = false;
+ const treeUiStateByTab = new Map();
+ const pendingTreeResetTabs = new Set();
+
+ function activeTreeTabId(state) {
+ if (!state || !Array.isArray(state.tabs)) return null;
+ const active = state.tabs.find(function (tab) { return tab.isActive === true; });
+ return active ? active.id : null;
+ }
+
+ function treeSectionIdentity(summary, index) {
+ if (summary.dataset.sectionIndex != null) {
+ return "fixed:" + summary.dataset.sectionIndex;
+ }
+ if (summary.dataset.industrySectionIndex != null) {
+ return "industry:" + summary.dataset.industrySectionIndex;
+ }
+ if (summary.dataset.comparisonSectionIndex != null) {
+ return "comparison:" + summary.dataset.comparisonSectionIndex;
+ }
+ return "section:" + index + ":" + (summary.textContent || "").trim();
+ }
+
+ function treeLeafIdentity(button, index) {
+ return button.dataset.actionId ||
+ button.dataset.industryActionId ||
+ button.dataset.comparisonActionId ||
+ button.dataset.overseasActionId ||
+ ("leaf:" + index + ":" + (button.textContent || "").trim());
+ }
+
+ function syncTreeSectionExpanded(details) {
+ const summary = details.querySelector(":scope > summary[data-tree-root]");
+ if (summary) summary.setAttribute("aria-expanded", details.open ? "true" : "false");
+ }
+
+ function decorateTreeNodes(tabId) {
+ if (!tabId) return;
+ categoryTree.querySelectorAll("details").forEach(function (details, sectionIndex) {
+ const summary = details.querySelector(":scope > summary");
+ if (!summary) return;
+ const sectionKey = tabId + "|" + treeSectionIdentity(summary, sectionIndex);
+ details.dataset.treeSectionKey = sectionKey;
+ summary.dataset.treeRoot = "true";
+ summary.dataset.treeNodeKey = "root|" + sectionKey;
+ summary.setAttribute("role", "treeitem");
+ summary.setAttribute("aria-selected", "false");
+ summary.setAttribute("aria-disabled", treeInteractionLocked ? "true" : "false");
+
+ if (!summary.querySelector("[data-tree-disclosure]")) {
+ const labelText = summary.textContent || "";
+ const disclosure = document.createElement("span");
+ disclosure.className = "tree-disclosure";
+ disclosure.dataset.treeDisclosure = "true";
+ disclosure.setAttribute("aria-hidden", "true");
+ const label = document.createElement("span");
+ label.className = "tree-root-label";
+ label.dataset.treeRootLabel = "true";
+ label.textContent = labelText;
+ summary.replaceChildren(disclosure, label);
+ }
+
+ details.querySelectorAll(".tree-item").forEach(function (button, leafIndex) {
+ button.dataset.treeNodeKey = "leaf|" + sectionKey + "|" +
+ treeLeafIdentity(button, leafIndex);
+ button.setAttribute("role", "treeitem");
+ if (!button.classList.contains("tree-node-selected")) {
+ button.setAttribute("aria-selected", "false");
+ }
+ });
+ syncTreeSectionExpanded(details);
+ });
+ }
+
+ function findTreeNodeByKey(key) {
+ if (!key) return null;
+ return Array.from(categoryTree.querySelectorAll("[data-tree-node-key]")).find(
+ function (node) { return node.dataset.treeNodeKey === key; }) || null;
+ }
+
+ function selectTreeNode(node, focus) {
+ if (!node || !renderedTreeTabId) return;
+ categoryTree.querySelectorAll("[data-tree-node-key].tree-node-selected")
+ .forEach(function (selected) {
+ selected.classList.remove("tree-node-selected");
+ selected.setAttribute("aria-selected", "false");
+ });
+ node.classList.add("tree-node-selected");
+ node.setAttribute("aria-selected", "true");
+ const previous = treeUiStateByTab.get(renderedTreeTabId) || {};
+ treeUiStateByTab.set(renderedTreeTabId, Object.assign({}, previous, {
+ selectedKey: node.dataset.treeNodeKey
+ }));
+ if (focus !== false && typeof node.focus === "function") {
+ node.focus({ preventScroll: true });
+ }
+ }
+
+ function captureTreeUiState(tabId) {
+ if (!tabId) return;
+ decorateTreeNodes(tabId);
+ const previous = treeUiStateByTab.get(tabId) || {};
+ const openSections = new Map();
+ categoryTree.querySelectorAll("details[data-tree-section-key]")
+ .forEach(function (details) {
+ openSections.set(details.dataset.treeSectionKey, details.open === true);
+ });
+ const selected = categoryTree.querySelector(
+ "[data-tree-node-key].tree-node-selected");
+ const active = categoryTree.contains(document.activeElement)
+ ? document.activeElement.closest("[data-tree-node-key]")
+ : null;
+ treeUiStateByTab.set(tabId, {
+ openSections: openSections,
+ selectedKey: selected ? selected.dataset.treeNodeKey : previous.selectedKey || null,
+ focusKey: active ? active.dataset.treeNodeKey : null,
+ scrollTop: categoryTree.scrollTop
+ });
+ }
+
+ function beginTreeRender(state) {
+ const nextTabId = activeTreeTabId(state);
+ if (renderedTreeTabId) captureTreeUiState(renderedTreeTabId);
+ if (nextTabId !== renderedTreeTabId) {
+ renderedTreeTabId = nextTabId;
+ pendingTreeResetTabs.clear();
+ if (nextTabId) pendingTreeResetTabs.add(nextTabId);
+ if (nextTabId) treeUiStateByTab.delete(nextTabId);
+ catalogExpandAll.checked = true;
+ }
+ treeInteractionLocked = state.isBusy === true || state.fixedSectionBatch != null;
+ return nextTabId;
+ }
+
+ function finishTreeRender(tabId) {
+ if (!tabId) return;
+ decorateTreeNodes(tabId);
+ const details = Array.from(categoryTree.querySelectorAll("details[data-tree-section-key]"));
+ const roots = Array.from(categoryTree.querySelectorAll("summary[data-tree-root]"));
+ if (pendingTreeResetTabs.has(tabId) && roots.length > 0) {
+ details.forEach(function (section) {
+ section.open = true;
+ syncTreeSectionExpanded(section);
+ });
+ const firstRoot = roots[0];
+ selectTreeNode(firstRoot, true);
+ if (typeof firstRoot.scrollIntoView === "function") {
+ firstRoot.scrollIntoView({ block: "nearest", inline: "nearest" });
+ }
+ treeUiStateByTab.set(tabId, {
+ openSections: new Map(details.map(function (section) {
+ return [section.dataset.treeSectionKey, true];
+ })),
+ selectedKey: firstRoot.dataset.treeNodeKey,
+ focusKey: firstRoot.dataset.treeNodeKey,
+ scrollTop: categoryTree.scrollTop
+ });
+ pendingTreeResetTabs.delete(tabId);
+ return;
+ }
+
+ const saved = treeUiStateByTab.get(tabId);
+ if (!saved) return;
+ if (saved.openSections instanceof Map) {
+ details.forEach(function (section) {
+ if (saved.openSections.has(section.dataset.treeSectionKey)) {
+ section.open = saved.openSections.get(section.dataset.treeSectionKey) === true;
+ }
+ syncTreeSectionExpanded(section);
+ });
+ }
+ const selected = findTreeNodeByKey(saved.selectedKey);
+ if (selected) selectTreeNode(selected, false);
+ const focused = findTreeNodeByKey(saved.focusKey);
+ if (focused && typeof focused.focus === "function") {
+ focused.focus({ preventScroll: true });
+ }
+ if (Number.isFinite(saved.scrollTop)) categoryTree.scrollTop = saved.scrollTop;
+ }
function send(type, payload) {
if (webview && !uiBusy) webview.postMessage({ type: type, payload: payload || {} });
@@ -111,7 +293,8 @@
const playout = state && state.playout;
return !state || state.isBusy === true || state.fixedSectionBatch != null ||
(playout && (playout.isBusy === true || playout.outcomeUnknown === true ||
- playout.isPlayCompletionPending === true || playout.phase !== "idle"));
+ playout.isPlayCompletionPending === true ||
+ playout.isTakeOutCompletionPending === true || playout.phase !== "idle"));
}
function dragDropPosition(event, element) {
@@ -119,22 +302,162 @@
return event.clientY < bounds.top + (bounds.height / 2) ? "before" : "after";
}
+ function playlistPlayoutPresentation(state, row, position) {
+ const playout = state && state.playout;
+ const phase = playout && typeof playout.phase === "string"
+ ? playout.phase : "idle";
+ const hasActiveScene = phase === "prepared" || phase === "program";
+ const currentEntryId = playout && typeof playout.currentEntryId === "string" &&
+ playout.currentEntryId.length > 0 ? playout.currentEntryId : null;
+ const currentIndex = playout && Number.isInteger(playout.currentCueIndexZeroBased)
+ ? playout.currentCueIndexZeroBased : -1;
+ const isCurrent = hasActiveScene && (currentEntryId !== null
+ ? currentEntryId === row.rowId
+ : currentIndex === position);
+ const pageCount = isCurrent && Number.isInteger(playout.pageCount) &&
+ playout.pageCount > 0 ? playout.pageCount : 0;
+ const pageIndex = isCurrent && Number.isInteger(playout.pageIndexZeroBased) &&
+ playout.pageIndexZeroBased >= 0 && playout.pageIndexZeroBased < pageCount
+ ? playout.pageIndexZeroBased : -1;
+
+ return {
+ isSelected: row.isSelected === true,
+ isCurrent: isCurrent,
+ isOnAir: isCurrent && phase === "program",
+ pageText: pageIndex >= 0
+ ? String(pageIndex + 1) + "/" + String(pageCount)
+ : row.pageText,
+ focusKey: isCurrent
+ ? [phase, currentEntryId || row.rowId || currentIndex, pageIndex, pageCount].join(":")
+ : ""
+ };
+ }
+
+ function presentCurrentPlaylistRow(element, focusKey) {
+ if (!element || !focusKey) {
+ lastPresentedPlayoutRowKey = "";
+ return;
+ }
+ if (lastPresentedPlayoutRowKey === focusKey) return;
+
+ lastPresentedPlayoutRowKey = focusKey;
+ element.focus({ preventScroll: true });
+ if (typeof element.scrollIntoView === "function") {
+ element.scrollIntoView({ block: "nearest", inline: "nearest" });
+ }
+ }
+
+ function selectPlaylistBoundary(boundary) {
+ const rows = playlistRows.querySelectorAll(".playlist-data-row");
+ const element = boundary === "last"
+ ? rows.item(rows.length - 1)
+ : rows.item(0);
+ if (!element) return;
+
+ // MainForm's KeyPreview changed the playlist selection but returned the
+ // base key result. Keep the textbox/ListBox caret and focus where the
+ // operator pressed Home/End; only mirror the playlist scroll/selection.
+ if (typeof element.scrollIntoView === "function") {
+ element.scrollIntoView({ block: "nearest", inline: "nearest" });
+ }
+ send("select-playlist-boundary", { boundary: boundary });
+ }
+
function clearDelayedClick(timer) {
if (timer !== null) window.clearTimeout(timer);
}
+ function stockResultPageOffset(button) {
+ const itemHeight = button && Number.isFinite(button.offsetHeight)
+ ? button.offsetHeight
+ : 0;
+ const viewportHeight = Number.isFinite(stockResults.clientHeight)
+ ? stockResults.clientHeight
+ : 0;
+ if (itemHeight <= 0 || viewportHeight <= 0) return 9;
+
+ // The native 174 px ListBox exposes ten 17 px rows and PageUp/PageDown
+ // retain one boundary row, so its selection moves by visibleRows - 1.
+ const visibleRows = Math.max(1, Math.floor(viewportHeight / itemHeight));
+ return Math.max(1, visibleRows - 1);
+ }
+
+ function stockResultKeyTarget(results, key) {
+ if (!results || results.length === 0) return null;
+
+ let selectedPosition = -1;
+ for (let position = 0; position < results.length; position += 1) {
+ if (results.item(position).getAttribute("aria-selected") === "true") {
+ selectedPosition = position;
+ break;
+ }
+ }
+
+ const pageOffset = stockResultPageOffset(results.item(0));
+ let targetPosition;
+ if (key === "End") {
+ targetPosition = results.length - 1;
+ } else if (key === "Home") {
+ targetPosition = 0;
+ } else if (key === "ArrowUp" || key === "PageUp") {
+ const offset = key === "ArrowUp" ? 1 : pageOffset;
+ targetPosition = selectedPosition < 0 ? 0 : selectedPosition - offset;
+ } else if (key === "ArrowDown" || key === "PageDown") {
+ const offset = key === "ArrowDown" ? 1 : pageOffset;
+ targetPosition = selectedPosition < 0
+ ? (key === "PageDown" ? offset : 0)
+ : selectedPosition + offset;
+ } else {
+ return null;
+ }
+
+ return results.item(Math.max(0, Math.min(results.length - 1, targetPosition)));
+ }
+
+ function selectStockResult(button, focus) {
+ if (!button) return;
+ const results = stockResults.querySelectorAll("button[data-result-index]");
+ const selectionChanged = button.getAttribute("aria-selected") !== "true";
+ for (let position = 0; position < results.length; position += 1) {
+ const result = results.item(position);
+ const isSelected = result === button;
+ result.tabIndex = isSelected ? 0 : -1;
+ result.classList.toggle("selected", isSelected);
+ result.setAttribute("aria-selected", isSelected ? "true" : "false");
+ }
+ if (focus === true) {
+ button.focus({ preventScroll: true });
+ if (typeof button.scrollIntoView === "function") {
+ button.scrollIntoView({ block: "nearest", inline: "nearest" });
+ }
+ }
+ if (selectionChanged) {
+ send("select-stock", { resultIndex: Number(button.dataset.resultIndex) });
+ }
+ }
+
function createStockResultElement(row) {
const button = document.createElement("button");
button.type = "button";
button.dataset.resultIndex = String(row.index);
button.setAttribute("role", "option");
button.addEventListener("click", function () {
- send("select-stock", { resultIndex: Number(button.dataset.resultIndex) });
+ selectStockResult(button, false);
+ });
+ button.addEventListener("keydown", function (event) {
+ const results = stockResults.querySelectorAll("button[data-result-index]");
+ const target = stockResultKeyTarget(results, event.key);
+ if (!target) return;
+ // Consume only the native ListBox navigation behavior. Home/End continue
+ // bubbling to MainForm's KeyPreview-equivalent playlist boundary handler.
+ event.preventDefault();
+ selectStockResult(target, true);
});
return button;
}
function renderStockResults(state) {
+ let hasSelectedResult = false;
state.searchResults.forEach(function (row, position) {
let button = stockResults.children.item(position);
if (!button || button.dataset.resultIndex !== String(row.index)) {
@@ -146,16 +469,23 @@
}
}
- button.className = "stock-result" +
- (state.selectedStockIndex === row.index ? " selected" : "");
+ const isSelected = state.selectedStockIndex === row.index;
+ if (isSelected) hasSelectedResult = true;
+ button.className = "stock-result" + (isSelected ? " selected" : "");
button.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false");
button.textContent = row.displayName;
- button.setAttribute("aria-selected", state.selectedStockIndex === row.index ? "true" : "false");
+ button.setAttribute("aria-selected", isSelected ? "true" : "false");
+ button.tabIndex = isSelected ? 0 : -1;
});
while (stockResults.children.length > state.searchResults.length) {
stockResults.lastElementChild.remove();
}
+
+ // A WinForms ListBox is one tab stop even before it has a selection.
+ if (!hasSelectedResult && stockResults.children.length > 0) {
+ stockResults.children.item(0).tabIndex = 0;
+ }
}
function pointerPosition(event) {
@@ -166,10 +496,43 @@
};
}
+ function legacyCutKey(key) {
+ return {
+ ArrowUp: "arrow-up",
+ ArrowDown: "arrow-down",
+ Home: "home",
+ End: "end",
+ " ": "space"
+ }[key] || null;
+ }
+
+ function focusCutElement(element, scroll) {
+ if (!element) return;
+ Array.from(cutList.children).forEach(function (row) {
+ row.tabIndex = row === element ? 0 : -1;
+ });
+ element.focus({ preventScroll: true });
+ if (scroll && typeof element.scrollIntoView === "function") {
+ element.scrollIntoView({ block: "nearest", inline: "nearest" });
+ }
+ }
+
+ function cutKeyboardTarget(element, key) {
+ const rows = Array.from(cutList.children);
+ const index = rows.indexOf(element);
+ if (index < 0 || rows.length === 0) return null;
+ if (key === "ArrowUp") return rows[Math.max(0, index - 1)];
+ if (key === "ArrowDown") return rows[Math.min(rows.length - 1, index + 1)];
+ if (key === "Home") return rows[0];
+ if (key === "End") return rows[rows.length - 1];
+ return element;
+ }
+
function createCutElement(row) {
const element = document.createElement("div");
element.setAttribute("role", "option");
element.dataset.physicalIndex = String(row.physicalIndex);
+ element.tabIndex = -1;
element.draggable = true;
const number = document.createElement("span");
@@ -180,6 +543,9 @@
element.append(number, label);
element.addEventListener("pointerdown", function (event) {
+ if (playlistMutationLocked) return event.preventDefault();
+ if (!cutSelectionEnabled) return event.preventDefault();
+ focusCutElement(element, false);
const position = pointerPosition(event);
send("cut-pointer-down", {
physicalIndex: row.physicalIndex,
@@ -190,16 +556,42 @@
timestampMilliseconds: Math.max(0, Math.round(event.timeStamp))
});
});
+ element.addEventListener("keydown", function (event) {
+ const key = legacyCutKey(event.key);
+ if (!key) return;
+ // The native ListView consumes these keys. Home/End still bubble to the
+ // document-level MainForm KeyPreview equivalent for its playlist side effect.
+ event.preventDefault();
+ if (playlistMutationLocked) return;
+ if (!cutSelectionEnabled) return;
+ const target = cutKeyboardTarget(element, event.key);
+ focusCutElement(target, true);
+ send("cut-key-down", {
+ key: key,
+ control: event.ctrlKey,
+ shift: event.shiftKey
+ });
+ });
element.addEventListener("dragstart", function (event) {
- if (event.dataTransfer) {
- event.dataTransfer.effectAllowed = "move";
- event.dataTransfer.setData("text/plain", selectedCutsDragToken);
+ if (playlistMutationLocked || !event.dataTransfer) {
+ event.preventDefault();
+ return;
}
+ event.dataTransfer.effectAllowed = "move";
+ event.dataTransfer.setData("text/plain", selectedCutsDragToken);
});
return element;
}
function renderCuts(state) {
+ const cutDragLocked = isOperatorMutationLocked(state);
+ cutSelectionEnabled = Number.isInteger(state.selectedStockIndex) && !cutDragLocked;
+ const focusedCutIndex = state.cutRows.findIndex(function (row) {
+ return row.isFocused === true;
+ });
+ // renderCuts precedes renderPlaylist, so publish the same lock before a
+ // cut row can initiate an append drag for this state revision.
+ playlistMutationLocked = cutDragLocked;
state.cutRows.forEach(function (row, index) {
let element = cutList.children.item(index);
if (!element || element.dataset.physicalIndex !== String(row.physicalIndex)) {
@@ -216,7 +608,10 @@
(row.isSeparator ? " separator" : "");
element.setAttribute("aria-selected", row.isSelected ? "true" : "false");
element.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false");
- element.draggable = state.isBusy !== true;
+ element.tabIndex = row.isFocused === true || (focusedCutIndex < 0 && index === 0)
+ ? 0
+ : -1;
+ element.draggable = !cutDragLocked;
});
while (cutList.children.length > state.cutRows.length) {
@@ -224,6 +619,11 @@
}
}
+ cutList.addEventListener("pointerdown", function (event) {
+ if (event.target !== cutList || playlistMutationLocked) return;
+ send("clear-cut-selection", {});
+ });
+
function clearPlaylistDragVisuals() {
playlistRows.querySelectorAll(".dragging, .drag-before, .drag-after")
.forEach(function (row) {
@@ -312,7 +712,12 @@
enabledCell.className = "enabled-cell";
const enabled = document.createElement("input");
enabled.type = "checkbox";
+ enabled.tabIndex = -1;
+ enabled.addEventListener("click", function (event) {
+ if (playlistMutationLocked) event.preventDefault();
+ });
enabled.addEventListener("change", function () {
+ if (playlistMutationLocked) return;
send("set-playlist-enabled", {
rowId: element.dataset.rowId,
enabled: enabled.checked
@@ -332,15 +737,17 @@
// not necessarily the checkbox originally pressed. Remember the
// pointer origin so a checkbox gesture cannot reorder the row.
playlistDragBlockedRowId = element.dataset.rowId;
+ element.focus({ preventScroll: true });
+ send("activate-playlist-row", { rowId: element.dataset.rowId });
return;
}
playlistDragBlockedRowId = null;
element.focus({ preventScroll: true });
const plainSelection = !event.ctrlKey && !event.shiftKey;
- const preserveSelectedBlock = plainSelection &&
+ const deferSelectedRowPlainClickForDrag = plainSelection &&
element.getAttribute("aria-selected") === "true" &&
playlistRows.querySelectorAll('.playlist-data-row[aria-selected="true"]').length > 1;
- if (preserveSelectedBlock) {
+ if (deferSelectedRowPlainClickForDrag) {
deferredPlaylistPlainSelectionRowId = element.dataset.rowId;
return;
}
@@ -388,11 +795,14 @@
clearPlaylistDragVisuals();
}
const existingRows = new Map();
+ let currentPlayoutRow = null;
+ let currentPlayoutFocusKey = "";
Array.from(playlistRows.children).forEach(function (element) {
existingRows.set(element.dataset.rowId, element);
});
state.playlist.forEach(function (row, position) {
+ const presentation = playlistPlayoutPresentation(state, row, position);
let element = existingRows.get(row.rowId);
if (!element) {
element = createPlaylistElement(row);
@@ -405,27 +815,41 @@
const enabled = element.querySelector("input");
enabled.checked = row.isEnabled;
- enabled.disabled = state.isBusy === true;
- enabled.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false");
+ enabled.disabled = false;
+ enabled.setAttribute("aria-disabled", playlistMutationLocked ? "true" : "false");
+ const isCandidate = row.isActive === true;
element.className = "playlist-row playlist-data-row" +
- (row.isSelected ? " selected" : "");
- element.setAttribute("aria-selected", row.isSelected ? "true" : "false");
+ (presentation.isOnAir ? " on-air" : "") +
+ (presentation.isCurrent ? " playout-current" : "") +
+ (presentation.isSelected || isCandidate ? " selected" : "");
+ element.dataset.active = isCandidate ? "true" : "false";
+ element.setAttribute(
+ "aria-selected",
+ presentation.isSelected ? "true" : "false");
+ element.setAttribute(
+ "aria-current",
+ presentation.isCurrent ? "true" : "false");
element.draggable = !playlistMutationLocked;
element.setAttribute("aria-disabled", playlistMutationLocked ? "true" : "false");
- [row.marketText, row.stockName, row.graphicType, row.subtype, row.pageText]
+ [row.marketText, row.stockName, row.graphicType, row.subtype, presentation.pageText]
.forEach(function (text, cellIndex) {
const cell = element.children.item(cellIndex + 1);
cell.textContent = text;
cell.title = text;
});
+ if (presentation.isCurrent) {
+ currentPlayoutRow = element;
+ currentPlayoutFocusKey = presentation.focusKey;
+ }
});
const retainedRowIds = new Set(state.playlist.map(function (row) { return row.rowId; }));
Array.from(playlistRows.children).forEach(function (element) {
if (!retainedRowIds.has(element.dataset.rowId)) element.remove();
});
+ presentCurrentPlaylistRow(currentPlayoutRow, currentPlayoutFocusKey);
- const interactionDisabled = state.isBusy === true;
+ const interactionDisabled = playlistMutationLocked;
const enabledCount = state.playlist.filter(function (row) { return row.isEnabled; }).length;
playlistEnableAll.checked = state.playlist.length === 0 || enabledCount === state.playlist.length;
playlistEnableAll.indeterminate = enabledCount > 0 && enabledCount < state.playlist.length;
@@ -457,14 +881,69 @@
}
}
+ function hasCrossedLegacyTabSwapThreshold(
+ source,
+ target,
+ clientX,
+ sourceIndex,
+ targetIndex
+ ) {
+ if (!source || !target || !Number.isFinite(clientX) ||
+ sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return false;
+
+ const sourceRect = source.getBoundingClientRect();
+ const targetRect = target.getBoundingClientRect();
+ if (sourceRect.width < targetRect.width) {
+ // MainForm compensates for the narrow source width so merely entering a
+ // wider target cannot immediately swap it. Its comparisons are strict.
+ return sourceIndex < targetIndex
+ ? clientX > targetRect.left + targetRect.width - sourceRect.width
+ : clientX < targetRect.left + sourceRect.width;
+ }
+
+ // An equal-width or wider source swaps as soon as it enters another tab.
+ return true;
+ }
+
+ function onMarketTabDragOver(event) {
+ const target = event.target.closest("button[data-tab-id]");
+ if (!target || !draggedTabId) return;
+ event.preventDefault();
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
+ const targetId = target.dataset.tabId;
+ if (targetId === draggedTabId) {
+ tabDragLastTargetId = null;
+ return;
+ }
+ if (tabDragLastTargetId === targetId || pendingTabHoverSwap) return;
+
+ const buttons = Array.from(
+ marketTabs.querySelectorAll("button[data-tab-id]"));
+ const source = buttons.find(function (button) {
+ return button.dataset.tabId === draggedTabId;
+ });
+ const sourceIndex = buttons.indexOf(source);
+ const targetIndex = buttons.indexOf(target);
+ if (!hasCrossedLegacyTabSwapThreshold(
+ source,
+ target,
+ event.clientX,
+ sourceIndex,
+ targetIndex)) return;
+
+ // Record the crossing only when the busy/pending gates actually accepted
+ // the opaque native intent. Repeated dragover events then cannot toggle it.
+ if (requestTabHoverSwap(targetId)) tabDragLastTargetId = targetId;
+ }
+
function requestTabHoverSwap(targetId) {
if (uiBusy || !draggedTabId || !targetId || draggedTabId === targetId ||
- pendingTabHoverSwap) return;
+ pendingTabHoverSwap) return false;
const order = tabOrderFromDom();
const sourceIndex = order.indexOf(draggedTabId);
const targetIndex = order.indexOf(targetId);
- if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return;
+ if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return false;
const expectedOrder = order.slice();
expectedOrder[sourceIndex] = targetId;
@@ -487,6 +966,7 @@
expectedSourceIndex: sourceIndex,
expectedTargetIndex: targetIndex
});
+ return true;
}
function renderTabs(state) {
@@ -878,6 +1358,7 @@
}
const button = document.createElement("button");
button.type = "button";
+ button.className = "tree-item" + (action.isAvailable ? "" : " unavailable");
button.dataset.comparisonActionId = action.actionId;
button.textContent = action.label;
button.disabled = !action.isAvailable;
@@ -2040,6 +2521,7 @@
function render(state) {
const isBusy = state.isBusy === true;
+ const treeTabId = beginTreeRender(state);
uiBusy = isBusy;
document.body.classList.toggle("busy", isBusy);
document.body.setAttribute("aria-busy", isBusy ? "true" : "false");
@@ -2053,7 +2535,7 @@
movingAverage20.checked = state.movingAverages ? state.movingAverages.ma20 : true;
movingAverage5.disabled = isBusy;
movingAverage20.disabled = isBusy;
- catalogExpandAll.disabled = isBusy;
+ catalogExpandAll.disabled = treeInteractionLocked;
renderFixedCatalog(state);
renderIndustry(state);
renderOverseas(state);
@@ -2061,6 +2543,7 @@
renderTheme(state);
renderExpert(state);
renderTradingHalt(state);
+ finishTreeRender(treeTabId);
renderManualFinancial(state);
renderManualLists(state);
renderOperatorCatalog(state);
@@ -2493,12 +2976,13 @@
// A playlist row also drops inside this ancestor. Only advertise the
// drop zone for the cut-list payload; otherwise the bubbled row drop
// would append the still-selected cut as an extra playlist entry.
- if (!hasDragType(event.dataTransfer, "text/plain")) return;
+ if (playlistMutationLocked ||
+ !hasDragType(event.dataTransfer, "text/plain")) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
});
playlistDropZone.addEventListener("drop", function (event) {
- if (!isSelectedCutsDrop(event.dataTransfer)) return;
+ if (playlistMutationLocked || !isSelectedCutsDrop(event.dataTransfer)) return;
event.preventDefault();
event.stopPropagation();
send("drop-selected-cuts", {});
@@ -2538,21 +3022,7 @@
event.dataTransfer.setData("text/x-mbn-tab", draggedTabId);
button.classList.add("dragging");
});
- marketTabs.addEventListener("dragover", function (event) {
- const target = event.target.closest("button[data-tab-id]");
- if (!target || !draggedTabId) return;
- event.preventDefault();
- if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
- const targetId = target.dataset.tabId;
- if (targetId === draggedTabId) {
- tabDragLastTargetId = null;
- return;
- }
- if (tabDragLastTargetId === targetId) return;
- if (pendingTabHoverSwap) return;
- tabDragLastTargetId = targetId;
- requestTabHoverSwap(targetId);
- });
+ marketTabs.addEventListener("dragover", onMarketTabDragOver);
marketTabs.addEventListener("drop", function (event) {
const target = event.target.closest("button[data-tab-id]");
if (!target || !event.dataTransfer || !draggedTabId) return;
@@ -2578,11 +3048,28 @@
movingAverage5.addEventListener("change", sendMovingAverages);
movingAverage20.addEventListener("change", sendMovingAverages);
catalogExpandAll.addEventListener("change", function () {
+ if (treeInteractionLocked) {
+ const sections = Array.from(categoryTree.querySelectorAll("details"));
+ catalogExpandAll.checked = sections.length > 0 && sections.every(function (details) {
+ return details.open === true;
+ });
+ return;
+ }
categoryTree.querySelectorAll("details").forEach(function (details) {
details.open = catalogExpandAll.checked;
+ syncTreeSectionExpanded(details);
});
});
categoryTree.addEventListener("dblclick", function (event) {
+ const treeNode = event.target.closest("[data-tree-node-key]");
+ if (treeNode) {
+ event.preventDefault();
+ if (treeInteractionLocked || treeNode.disabled === true ||
+ treeNode.getAttribute("aria-disabled") === "true") return;
+ selectTreeNode(treeNode, true);
+ // The disclosure is a separate expand/collapse command, not a node action.
+ if (event.target.closest("[data-tree-disclosure]")) return;
+ }
const themeResult = event.target.closest("button[data-theme-result-id]");
if (themeResult && !themeResult.disabled) {
event.preventDefault();
@@ -2663,6 +3150,28 @@
}
});
categoryTree.addEventListener("click", function (event) {
+ const treeRoot = event.target.closest("summary[data-tree-root]");
+ if (treeRoot) {
+ // A native TreeView selects on its label and expands only from the glyph.
+ // Cancel 's whole-row default toggle to preserve that split.
+ event.preventDefault();
+ if (treeInteractionLocked || treeRoot.getAttribute("aria-disabled") === "true") return;
+ selectTreeNode(treeRoot, true);
+ if (event.target.closest("[data-tree-disclosure]") && (event.detail || 1) < 2) {
+ const details = treeRoot.parentElement;
+ details.open = !details.open;
+ syncTreeSectionExpanded(details);
+ }
+ return;
+ }
+ const treeLeaf = event.target.closest(".tree-item[data-tree-node-key]");
+ if (treeLeaf) {
+ if (treeInteractionLocked || treeLeaf.disabled === true) {
+ event.preventDefault();
+ return;
+ }
+ selectTreeNode(treeLeaf, true);
+ }
const uc4Catalog = event.target.closest("button[data-uc4-theme-catalog]");
if (uc4Catalog && !uc4Catalog.disabled) {
const action = uc4Catalog.dataset.uc4ThemeCatalog;
@@ -2867,7 +3376,16 @@
}
});
categoryTree.addEventListener("keydown", function (event) {
- if (event.key === "Enter" &&
+ const treeRoot = event.target.closest("summary[data-tree-root]");
+ if (treeRoot && ["Enter", " ", "ArrowLeft", "ArrowRight"].includes(event.key)) {
+ event.preventDefault();
+ if (treeInteractionLocked || treeRoot.getAttribute("aria-disabled") === "true") return;
+ selectTreeNode(treeRoot, true);
+ const details = treeRoot.parentElement;
+ if (event.key === "ArrowLeft") details.open = false;
+ else if (event.key === "ArrowRight") details.open = true;
+ syncTreeSectionExpanded(details);
+ } else if (event.key === "Enter" &&
(event.target.id === "overseas-industry-search" ||
event.target.id === "overseas-stock-search")) {
event.preventDefault();
@@ -2904,20 +3422,27 @@
document.addEventListener("keydown", function (event) {
if (event.key === "Delete" && !event.repeat && dialog.hidden && manualModal.hidden &&
manualListModal.hidden && namedPlaylistModal.hidden && operatorCatalogModal.hidden) {
- event.preventDefault();
- send("delete-selected-playlist-rows", {});
+ // MainForm.KeyPreview invoked playlist deletion but did not suppress the key,
+ // so an editor that owns focus must still perform its normal text deletion.
+ if (!playlistMutationLocked) send("delete-selected-playlist-rows", {});
} else if (event.key === " " && !event.repeat && dialog.hidden && manualModal.hidden &&
manualListModal.hidden && namedPlaylistModal.hidden && operatorCatalogModal.hidden &&
event.target.closest(".playlist-data-row")) {
event.preventDefault();
- send("toggle-active-playlist-enabled", {});
+ if (!playlistMutationLocked) {
+ const row = event.target.closest(".playlist-data-row");
+ const enabled = row.querySelector('input[type="checkbox"]');
+ send("set-playlist-enabled", {
+ rowId: row.dataset.rowId,
+ enabled: enabled.checked !== true
+ });
+ }
} else if ((event.key === "Home" || event.key === "End") &&
!event.repeat && dialog.hidden && manualModal.hidden && manualListModal.hidden &&
namedPlaylistModal.hidden && operatorCatalogModal.hidden) {
- event.preventDefault();
- send("select-playlist-boundary", {
- boundary: event.key === "Home" ? "first" : "last"
- });
+ // MainForm handled its playlist/timer side effect and then returned the
+ // base key result, so a focused textbox/list control still receives Home/End.
+ selectPlaylistBoundary(event.key === "Home" ? "first" : "last");
} else if (event.key === "Escape" && !event.repeat && !namedPlaylistModal.hidden) {
event.preventDefault();
closeNamedPlaylist();
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html
index 9079185..f33dcd9 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html
+++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html
@@ -69,8 +69,8 @@
-
-