From d591e0fda194ab8d3c0ac4d1db3835b55f35ce24 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Sat, 4 Jul 2026 22:35:07 +0200 Subject: [PATCH 01/31] Add Subsplit Editing to Run Editor This makes the run editor show segment groups as editable headers with indented split rows, allowing users to create, remove, and rename groups directly from the splits table instead of editing a flat list only. This also wires the subsplit display mode into the settings UI as a localized combobox and updates the staged livesplit-core submodule with backend-controlled group action availability. --- livesplit-core | 2 +- src-tauri/Cargo.lock | 103 ++++++-- src/css/RunEditor.module.css | 26 +++ src/localization/chinese-simplified.ts | 6 + src/localization/dutch.ts | 6 + src/localization/english.ts | 6 + src/localization/french.ts | 6 + src/localization/german.ts | 6 + src/localization/index.ts | 3 + src/localization/italian.ts | 6 + src/localization/japanese.ts | 6 + src/localization/korean.ts | 6 + src/localization/polish.ts | 6 + src/localization/portuguese-brazil.ts | 6 + src/localization/portuguese.ts | 6 + src/localization/russian.ts | 6 + src/localization/spanish.ts | 6 + .../Settings/SubsplitDisplayMode.tsx | 49 ++++ src/ui/components/Settings/index.tsx | 18 ++ src/ui/views/RunEditor.tsx | 221 +++++++++++++++++- 20 files changed, 468 insertions(+), 32 deletions(-) create mode 100644 src/ui/components/Settings/SubsplitDisplayMode.tsx diff --git a/livesplit-core b/livesplit-core index cb5f1368..a11119da 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit cb5f1368eb6cabe1e5c85c27a152112329fc5879 +Subproject commit a11119da2160fe319dc46b1ec989d202f91e70c3 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 35330307..5f91a9c5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -173,6 +173,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -507,6 +516,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -547,6 +565,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "cssparser" version = "0.29.6" @@ -687,8 +714,18 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", ] [[package]] @@ -1473,6 +1510,12 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", @@ -1564,6 +1607,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -2013,9 +2065,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.184" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libloading" @@ -2064,7 +2116,7 @@ dependencies = [ "cfg-if", "cosmic-text", "foldhash 0.2.0", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "image", "itoa", "libc", @@ -2075,7 +2127,7 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "sha2", + "sha2 0.11.0", "simdutf8", "slab", "smallstr", @@ -2087,14 +2139,14 @@ dependencies = [ [[package]] name = "livesplit-hotkey" -version = "0.8.0" +version = "0.8.1" dependencies = [ "bitflags 2.11.0", "cfg-if", "crossbeam-channel", "evdev", "mio", - "nix 0.30.1", + "nix 0.31.3", "promising-future", "serde", "windows-sys 0.61.2", @@ -2320,9 +2372,9 @@ dependencies = [ [[package]] name = "nix" -version = "0.30.1" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ "bitflags 2.11.0", "cfg-if", @@ -3768,8 +3820,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -3850,18 +3913,18 @@ checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" [[package]] name = "snafu" -version = "0.8.9" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +checksum = "d1a012328be2e3f5d5f6f3218147ca02588cea4cb865e876849ab6debcf36522" dependencies = [ "snafu-derive", ] [[package]] name = "snafu-derive" -version = "0.8.9" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +checksum = "5f103c50866b8743da9429b8a581d81a27c2d3a9c4ac7df8f8571c1dd7896eda" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -4258,7 +4321,7 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "syn 2.0.117", "tauri-utils", "thiserror 2.0.18", @@ -4899,9 +4962,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unic-char-property" @@ -5972,7 +6035,7 @@ dependencies = [ "once_cell", "percent-encoding", "raw-window-handle", - "sha2", + "sha2 0.10.9", "soup3", "tao-macros", "thiserror 2.0.18", diff --git a/src/css/RunEditor.module.css b/src/css/RunEditor.module.css index 33d72289..383120a8 100644 --- a/src/css/RunEditor.module.css +++ b/src/css/RunEditor.module.css @@ -179,6 +179,32 @@ } } +.segmentGroupHeader { + td { + border-top: 1px solid var(--border-color); + font-weight: bold; + padding-left: var(--ui-margin) !important; + padding-right: var(--ui-margin) !important; + } +} + +.segmentGroupHeaderInput { + font-weight: bold; + width: 100%; +} + +.segmentGroupHeaderIcon { + width: 1%; +} + +.segmentGroupName { + padding-left: calc(var(--ui-large-margin) + var(--ui-margin)) !important; +} + +.segmentGroupMajor { + font-weight: bold; +} + .bottomSection { display: flex; flex-wrap: wrap; diff --git a/src/localization/chinese-simplified.ts b/src/localization/chinese-simplified.ts index 30d6f22e..9916e830 100644 --- a/src/localization/chinese-simplified.ts +++ b/src/localization/chinese-simplified.ts @@ -568,6 +568,12 @@ export function resolveChineseSimplified(text: Label): string { return "对比区间时间"; case Label.ColumnStartWithPossibleTimeSave: return "可节省时间"; + case Label.SubsplitDisplayModeFlat: + return "平铺"; + case Label.SubsplitDisplayModeAllGroupsExpanded: + return "全部分组展开"; + case Label.SubsplitDisplayModeCurrentGroupExpanded: + return "当前分组展开"; case Label.ColumnUpdateWithDontUpdate: return "不更新"; case Label.ColumnUpdateWithSplitTime: diff --git a/src/localization/dutch.ts b/src/localization/dutch.ts index e01fd0c0..ffb4f82a 100644 --- a/src/localization/dutch.ts +++ b/src/localization/dutch.ts @@ -568,6 +568,12 @@ export function resolveDutch(text: Label): string { return "Vergelijking segmenttijd"; case Label.ColumnStartWithPossibleTimeSave: return "Mogelijke tijdswinst"; + case Label.SubsplitDisplayModeFlat: + return "Plat"; + case Label.SubsplitDisplayModeAllGroupsExpanded: + return "Alle groepen uitgeklapt"; + case Label.SubsplitDisplayModeCurrentGroupExpanded: + return "Huidige groep uitgeklapt"; case Label.ColumnUpdateWithDontUpdate: return "Niet bijwerken"; case Label.ColumnUpdateWithSplitTime: diff --git a/src/localization/english.ts b/src/localization/english.ts index 9c7fe671..fc6bbd45 100644 --- a/src/localization/english.ts +++ b/src/localization/english.ts @@ -568,6 +568,12 @@ export function resolveEnglish(text: Label): string { return "Comparison Segment Time"; case Label.ColumnStartWithPossibleTimeSave: return "Possible Time Save"; + case Label.SubsplitDisplayModeFlat: + return "Flat"; + case Label.SubsplitDisplayModeAllGroupsExpanded: + return "All Groups Expanded"; + case Label.SubsplitDisplayModeCurrentGroupExpanded: + return "Current Group Expanded"; case Label.ColumnUpdateWithDontUpdate: return "Don't Update"; case Label.ColumnUpdateWithSplitTime: diff --git a/src/localization/french.ts b/src/localization/french.ts index 5d5d9474..0938b67e 100644 --- a/src/localization/french.ts +++ b/src/localization/french.ts @@ -568,6 +568,12 @@ export function resolveFrench(text: Label): string { return "Temps du segment de comparaison"; case Label.ColumnStartWithPossibleTimeSave: return "Gain de temps possible"; + case Label.SubsplitDisplayModeFlat: + return "À plat"; + case Label.SubsplitDisplayModeAllGroupsExpanded: + return "Tous les groupes dépliés"; + case Label.SubsplitDisplayModeCurrentGroupExpanded: + return "Groupe actuel déplié"; case Label.ColumnUpdateWithDontUpdate: return "Ne pas mettre à jour"; case Label.ColumnUpdateWithSplitTime: diff --git a/src/localization/german.ts b/src/localization/german.ts index 13be887f..58f52c6e 100644 --- a/src/localization/german.ts +++ b/src/localization/german.ts @@ -568,6 +568,12 @@ export function resolveGerman(text: Label): string { return "Vergleichs‑Segmentzeit"; case Label.ColumnStartWithPossibleTimeSave: return "Mögliche Zeitersparnis"; + case Label.SubsplitDisplayModeFlat: + return "Flach"; + case Label.SubsplitDisplayModeAllGroupsExpanded: + return "Alle Gruppen erweitert"; + case Label.SubsplitDisplayModeCurrentGroupExpanded: + return "Aktuelle Gruppe erweitert"; case Label.ColumnUpdateWithDontUpdate: return "Nicht aktualisieren"; case Label.ColumnUpdateWithSplitTime: diff --git a/src/localization/index.ts b/src/localization/index.ts index 4b6bb7bf..fc5a2d95 100644 --- a/src/localization/index.ts +++ b/src/localization/index.ts @@ -298,6 +298,9 @@ export enum Label { ColumnStartWithComparisonTime, ColumnStartWithComparisonSegmentTime, ColumnStartWithPossibleTimeSave, + SubsplitDisplayModeFlat, + SubsplitDisplayModeAllGroupsExpanded, + SubsplitDisplayModeCurrentGroupExpanded, ColumnUpdateWithDontUpdate, ColumnUpdateWithSplitTime, ColumnUpdateWithDelta, diff --git a/src/localization/italian.ts b/src/localization/italian.ts index 4a52ecb6..4a4ddc0f 100644 --- a/src/localization/italian.ts +++ b/src/localization/italian.ts @@ -568,6 +568,12 @@ export function resolveItalian(text: Label): string { return "Tempo segmento di confronto"; case Label.ColumnStartWithPossibleTimeSave: return "Tempo risparmiabile"; + case Label.SubsplitDisplayModeFlat: + return "Elenco piatto"; + case Label.SubsplitDisplayModeAllGroupsExpanded: + return "Tutti i gruppi espansi"; + case Label.SubsplitDisplayModeCurrentGroupExpanded: + return "Gruppo attuale espanso"; case Label.ColumnUpdateWithDontUpdate: return "Non aggiornare"; case Label.ColumnUpdateWithSplitTime: diff --git a/src/localization/japanese.ts b/src/localization/japanese.ts index e018fa23..a3b267c5 100644 --- a/src/localization/japanese.ts +++ b/src/localization/japanese.ts @@ -568,6 +568,12 @@ export function resolveJapanese(text: Label): string { return "比較対象区間タイム"; case Label.ColumnStartWithPossibleTimeSave: return "更新余地"; + case Label.SubsplitDisplayModeFlat: + return "フラット"; + case Label.SubsplitDisplayModeAllGroupsExpanded: + return "すべてのグループを展開"; + case Label.SubsplitDisplayModeCurrentGroupExpanded: + return "現在のグループを展開"; case Label.ColumnUpdateWithDontUpdate: return "更新しない"; case Label.ColumnUpdateWithSplitTime: diff --git a/src/localization/korean.ts b/src/localization/korean.ts index c38966c5..a4ceeb9d 100644 --- a/src/localization/korean.ts +++ b/src/localization/korean.ts @@ -568,6 +568,12 @@ export function resolveKorean(text: Label): string { return "비교 세그먼트 시간"; case Label.ColumnStartWithPossibleTimeSave: return "절약 가능한 시간"; + case Label.SubsplitDisplayModeFlat: + return "플랫"; + case Label.SubsplitDisplayModeAllGroupsExpanded: + return "모든 그룹 펼침"; + case Label.SubsplitDisplayModeCurrentGroupExpanded: + return "현재 그룹 펼침"; case Label.ColumnUpdateWithDontUpdate: return "업데이트 안 함"; case Label.ColumnUpdateWithSplitTime: diff --git a/src/localization/polish.ts b/src/localization/polish.ts index d3f7285b..46edff78 100644 --- a/src/localization/polish.ts +++ b/src/localization/polish.ts @@ -568,6 +568,12 @@ export function resolvePolish(text: Label): string { return "Czas segmentu porównania"; case Label.ColumnStartWithPossibleTimeSave: return "Możliwa oszczędność czasu"; + case Label.SubsplitDisplayModeFlat: + return "Płasko"; + case Label.SubsplitDisplayModeAllGroupsExpanded: + return "Wszystkie grupy rozwinięte"; + case Label.SubsplitDisplayModeCurrentGroupExpanded: + return "Bieżąca grupa rozwinięta"; case Label.ColumnUpdateWithDontUpdate: return "Nie aktualizuj"; case Label.ColumnUpdateWithSplitTime: diff --git a/src/localization/portuguese-brazil.ts b/src/localization/portuguese-brazil.ts index 97b0ed3e..af96da67 100644 --- a/src/localization/portuguese-brazil.ts +++ b/src/localization/portuguese-brazil.ts @@ -568,6 +568,12 @@ export function resolveBrazilianPortuguese(text: Label): string { return "Tempo de segmento de comparação"; case Label.ColumnStartWithPossibleTimeSave: return "Tempo possível de economizar"; + case Label.SubsplitDisplayModeFlat: + return "Plano"; + case Label.SubsplitDisplayModeAllGroupsExpanded: + return "Todos os grupos expandidos"; + case Label.SubsplitDisplayModeCurrentGroupExpanded: + return "Grupo atual expandido"; case Label.ColumnUpdateWithDontUpdate: return "Não atualizar"; case Label.ColumnUpdateWithSplitTime: diff --git a/src/localization/portuguese.ts b/src/localization/portuguese.ts index 89662eab..f7fbe2ef 100644 --- a/src/localization/portuguese.ts +++ b/src/localization/portuguese.ts @@ -568,6 +568,12 @@ export function resolvePortuguese(text: Label): string { return "Tempo de segmento de comparação"; case Label.ColumnStartWithPossibleTimeSave: return "Tempo possível de economizar"; + case Label.SubsplitDisplayModeFlat: + return "Plano"; + case Label.SubsplitDisplayModeAllGroupsExpanded: + return "Todos os grupos expandidos"; + case Label.SubsplitDisplayModeCurrentGroupExpanded: + return "Grupo atual expandido"; case Label.ColumnUpdateWithDontUpdate: return "Não atualizar"; case Label.ColumnUpdateWithSplitTime: diff --git a/src/localization/russian.ts b/src/localization/russian.ts index 8985b563..a7b70281 100644 --- a/src/localization/russian.ts +++ b/src/localization/russian.ts @@ -568,6 +568,12 @@ export function resolveRussian(text: Label): string { return "Время сегмента сравнения"; case Label.ColumnStartWithPossibleTimeSave: return "Возможная экономия времени"; + case Label.SubsplitDisplayModeFlat: + return "Плоский список"; + case Label.SubsplitDisplayModeAllGroupsExpanded: + return "Все группы раскрыты"; + case Label.SubsplitDisplayModeCurrentGroupExpanded: + return "Текущая группа раскрыта"; case Label.ColumnUpdateWithDontUpdate: return "Не обновлять"; case Label.ColumnUpdateWithSplitTime: diff --git a/src/localization/spanish.ts b/src/localization/spanish.ts index 80aafce1..5410f5b7 100644 --- a/src/localization/spanish.ts +++ b/src/localization/spanish.ts @@ -568,6 +568,12 @@ export function resolveSpanish(text: Label): string { return "Tiempo de segmento de comparación"; case Label.ColumnStartWithPossibleTimeSave: return "Tiempo posible de ahorrar"; + case Label.SubsplitDisplayModeFlat: + return "Plano"; + case Label.SubsplitDisplayModeAllGroupsExpanded: + return "Todos los grupos expandidos"; + case Label.SubsplitDisplayModeCurrentGroupExpanded: + return "Grupo actual expandido"; case Label.ColumnUpdateWithDontUpdate: return "No actualizar"; case Label.ColumnUpdateWithSplitTime: diff --git a/src/ui/components/Settings/SubsplitDisplayMode.tsx b/src/ui/components/Settings/SubsplitDisplayMode.tsx new file mode 100644 index 00000000..bdbfced5 --- /dev/null +++ b/src/ui/components/Settings/SubsplitDisplayMode.tsx @@ -0,0 +1,49 @@ +import * as React from "react"; +import { expect } from "../../../util/OptionUtil"; +import { SubsplitDisplayModeJson, Language } from "../../../livesplit-core"; +import { SettingValueFactory } from "."; +import { Label, resolve } from "../../../localization"; + +import tableClasses from "../../../css/Table.module.css"; + +export function SubsplitDisplayMode({ + value, + setValue, + factory, + lang, +}: { + value: SubsplitDisplayModeJson; + setValue: (value: T) => void; + factory: SettingValueFactory; + lang: Language | undefined; +}) { + return ( +
+ +
+ ); +} diff --git a/src/ui/components/Settings/index.tsx b/src/ui/components/Settings/index.tsx index 97b77d90..37ed0b03 100644 --- a/src/ui/components/Settings/index.tsx +++ b/src/ui/components/Settings/index.tsx @@ -20,6 +20,7 @@ import { ColumnUpdateTrigger, } from "./Column"; import { Alignment } from "./Alignment"; +import { SubsplitDisplayMode } from "./SubsplitDisplayMode"; import { OptionalTimingMethod } from "./TimingMethod"; import { DeltaGradient, Gradient, ListGradient } from "./Gradient"; import { Color, OptionalColor } from "./Color"; @@ -125,6 +126,7 @@ export interface SettingValueFactory { fromColumnUpdateWith(value: string): T | null; fromColumnUpdateTrigger(value: string): T | null; fromLayoutDirection(value: string): T | null; + fromSubsplitDisplayMode(value: string): T | null; fromFont( name: string, style: string, @@ -243,6 +245,11 @@ export class JsonSettingValueFactory implements SettingValueFactory extends React.Component> { lang={this.props.lang} /> ); + } else if ("SubsplitDisplayMode" in value) { + component = ( + + this.props.setValue(valueIndex, value) + } + factory={this.props.factory} + lang={this.props.lang} + /> + ); } else if ("ServerConnection" in value) { component = ( {resolve(Label.MoveDown, lang)} + + addComparison(editor, update, lang)} importComparison={() => importComparison(editor, update, lang)} @@ -832,6 +858,81 @@ function TabContent({ } } +function getSegmentGroupBounds( + editorState: LiveSplit.RunEditorStateJson, + groupIndex: number, +): SegmentGroupBounds | undefined { + let startIndex: number | undefined; + let endIndex: number | undefined; + let explicitName: string | null = null; + + editorState.segments.forEach((segment, segmentIndex) => { + const segmentGroup = segment.segment_group; + if (segmentGroup.group_index !== groupIndex) { + return; + } + + startIndex ??= segmentIndex; + + if (segmentGroup.name !== null) { + explicitName = segmentGroup.name; + } + + if (segmentGroup.is_major_split) { + endIndex = segmentIndex; + } + }); + + if (startIndex === undefined || endIndex === undefined) { + return undefined; + } + + return { + startIndex, + endIndex, + name: explicitName ?? editorState.segments[endIndex].name, + }; +} + +function isFirstSegmentInGroup( + editorState: LiveSplit.RunEditorStateJson, + segmentIndex: number, +) { + const groupIndex = + editorState.segments[segmentIndex].segment_group.group_index; + if (groupIndex === null) { + return false; + } + + const previousSegment = editorState.segments[segmentIndex - 1]; + return previousSegment?.segment_group.group_index !== groupIndex; +} + +function selectSegmentGroup( + editor: LiveSplit.RunEditorRefMut, + editorState: LiveSplit.RunEditorStateJson, + groupIndex: number, + rowState: RowState, + setRowState: React.Dispatch>, + update: () => LiveSplit.RunEditorStateJson, +) { + const bounds = getSegmentGroupBounds(editorState, groupIndex); + if (bounds === undefined) { + return editorState; + } + + editor.selectOnly(bounds.startIndex); + editor.selectRange(bounds.endIndex); + const state = update(); + setFocusedSegmentRowState( + state, + getActiveSegmentIndex(state, bounds.endIndex), + rowState, + setRowState, + ); + return state; +} + function SegmentsTable({ editor, editorState, @@ -938,6 +1039,8 @@ function SegmentsTable({ } }; + const columnCount = 5 + editorState.comparison_names.length; + return ( @@ -999,22 +1102,111 @@ function SegmentsTable({ - {editorState.segments.map((s, segmentIndex) => { + {editorState.segments.flatMap((s, segmentIndex) => { const segmentIcon = getSegmentIconUrl( segmentIndex, runEditorUrlCache, editorState, ); + const segmentGroupIndex = s.segment_group.group_index; + const segmentGroupBounds = + segmentGroupIndex !== null + ? getSegmentGroupBounds( + editorState, + segmentGroupIndex, + ) + : undefined; + const isGroupedSegment = segmentGroupIndex !== null; + const isSelected = + s.selected === "Selected" || s.selected === "Active"; + const rowClassName = [ + isSelected ? tableClasses.selected : "", + s.segment_group.is_major_split + ? classes.segmentGroupMajor + : "", + ] + .filter(Boolean) + .join(" "); + const rows: React.JSX.Element[] = []; + + if ( + segmentGroupIndex !== null && + segmentGroupBounds !== undefined && + isFirstSegmentInGroup(editorState, segmentIndex) + ) { + const groupSelected = editorState.segments + .slice( + segmentGroupBounds.startIndex, + segmentGroupBounds.endIndex + 1, + ) + .every( + (segment) => + segment.selected === "Selected" || + segment.selected === "Active", + ); + + rows.push( + { + e.preventDefault(); + selectSegmentGroup( + editor, + editorState, + segmentGroupIndex, + rowState, + setRowState, + update, + ); + }} + > + + , + ); + } - return ( + rows.push( handleSegmentRowMouseDown( e, @@ -1042,7 +1234,13 @@ function SegmentsTable({ } lang={lang} /> -
+ + e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + onFocus={(_) => + selectSegmentGroup( + editor, + editorState, + segmentGroupIndex, + rowState, + setRowState, + update, + ) + } + onChange={(e) => { + editor.selectOnly( + segmentGroupBounds.endIndex, + ); + editor.renameActiveSegmentGroup( + e.target.value === "" + ? null + : e.target.value, + ); + update(); + }} + /> +
+ ( - + ); + return rows; })}
From 423ca4b2e062068c833a26ee35558639b3f37478 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Sun, 5 Jul 2026 12:59:34 +0200 Subject: [PATCH 02/31] Update Subsplit Group Editing This updates the staged livesplit-core submodule to include the fix for moving whole subsplit groups as coherent sections in the run editor. --- livesplit-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/livesplit-core b/livesplit-core index a11119da..fb8a5d1e 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit a11119da2160fe319dc46b1ec989d202f91e70c3 +Subproject commit fb8a5d1ed937862695a577560857e53bff71d0c5 From d2e8ede46ac986bc3619fc2febb663c1b1ba98fb Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Sun, 5 Jul 2026 14:51:04 +0200 Subject: [PATCH 03/31] Update Native Subsplit Support This updates the staged livesplit-core submodule to include single-segment subsplit groups and hierarchical scrolling before the timer has a current split. --- livesplit-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/livesplit-core b/livesplit-core index fb8a5d1e..faf201b2 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit fb8a5d1ed937862695a577560857e53bff71d0c5 +Subproject commit faf201b27b081713bb7dd03d039e9e4d80836561 From 0e029cd5456f834e295b64d4553903c8fe72f549 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Sun, 5 Jul 2026 15:42:34 +0200 Subject: [PATCH 04/31] Show Subsplit Editor Section Separators This makes the run editor draw the same section boundary before an ungrouped segment that follows a segment group as it already draws before group headers. This also updates the staged livesplit-core submodule with renderer state for hierarchical section separators. --- src/css/RunEditor.module.css | 6 ++++++ src/ui/views/RunEditor.tsx | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/css/RunEditor.module.css b/src/css/RunEditor.module.css index 383120a8..86862a18 100644 --- a/src/css/RunEditor.module.css +++ b/src/css/RunEditor.module.css @@ -188,6 +188,12 @@ } } +.segmentGroupBoundary { + td { + border-top: 1px solid var(--border-color); + } +} + .segmentGroupHeaderInput { font-weight: bold; width: 100%; diff --git a/src/ui/views/RunEditor.tsx b/src/ui/views/RunEditor.tsx index 08a8d6e8..2dc6451c 100644 --- a/src/ui/views/RunEditor.tsx +++ b/src/ui/views/RunEditor.tsx @@ -908,6 +908,18 @@ function isFirstSegmentInGroup( return previousSegment?.segment_group.group_index !== groupIndex; } +function startsUngroupedSectionAfterGroup( + editorState: LiveSplit.RunEditorStateJson, + segmentIndex: number, +) { + if (editorState.segments[segmentIndex].segment_group.group_index !== null) { + return false; + } + + const previousSegment = editorState.segments[segmentIndex - 1]; + return previousSegment?.segment_group.group_index !== null; +} + function selectSegmentGroup( editor: LiveSplit.RunEditorRefMut, editorState: LiveSplit.RunEditorStateJson, @@ -1121,6 +1133,12 @@ function SegmentsTable({ s.selected === "Selected" || s.selected === "Active"; const rowClassName = [ isSelected ? tableClasses.selected : "", + startsUngroupedSectionAfterGroup( + editorState, + segmentIndex, + ) + ? classes.segmentGroupBoundary + : "", s.segment_group.is_major_split ? classes.segmentGroupMajor : "", From aafc02c6504e6bced4b08d8cca0882aed33bddc6 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Sun, 5 Jul 2026 20:36:57 +0200 Subject: [PATCH 05/31] Refine Subsplit Editor Group Names This makes the run editor show automatic segment group names as placeholder text while keeping manually entered group names as explicit input values. This also wires the subsplit display mode setting labels through the frontend localization table and updates the staged livesplit-core submodule with simpler group name bindings. --- livesplit-core | 2 +- src/css/RunEditor.module.css | 5 ++++ src/localization/chinese-simplified.ts | 4 +++ src/localization/chinese-traditional.ts | 1 + src/localization/dutch.ts | 4 +++ src/localization/english.ts | 4 +++ src/localization/french.ts | 4 +++ src/localization/german.ts | 4 +++ src/localization/index.ts | 2 ++ src/localization/italian.ts | 4 +++ src/localization/japanese.ts | 4 +++ src/localization/korean.ts | 4 +++ src/localization/polish.ts | 4 +++ src/localization/portuguese-brazil.ts | 4 +++ src/localization/portuguese.ts | 4 +++ src/localization/russian.ts | 4 +++ src/localization/spanish.ts | 4 +++ .../Settings/SubsplitDisplayMode.tsx | 4 +-- src/ui/views/RunEditor.tsx | 26 ++++++++++++------- 19 files changed, 80 insertions(+), 12 deletions(-) diff --git a/livesplit-core b/livesplit-core index faf201b2..fec2a023 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit faf201b27b081713bb7dd03d039e9e4d80836561 +Subproject commit fec2a0238fb8580a42c6a13cdeb4fe7cae5735d7 diff --git a/src/css/RunEditor.module.css b/src/css/RunEditor.module.css index 86862a18..035b37c2 100644 --- a/src/css/RunEditor.module.css +++ b/src/css/RunEditor.module.css @@ -197,6 +197,11 @@ .segmentGroupHeaderInput { font-weight: bold; width: 100%; + + &::placeholder { + color: var(--input-text-color); + opacity: 0.65; + } } .segmentGroupHeaderIcon { diff --git a/src/localization/chinese-simplified.ts b/src/localization/chinese-simplified.ts index 9916e830..b961906f 100644 --- a/src/localization/chinese-simplified.ts +++ b/src/localization/chinese-simplified.ts @@ -218,6 +218,10 @@ export function resolveChineseSimplified(text: Label): string { return "上移"; case Label.MoveDown: return "下移"; + case Label.CreateGroup: + return "建立群组"; + case Label.RemoveGroup: + return "移除群组"; case Label.AddVariable: return "添加变量"; case Label.OpenPbPage: diff --git a/src/localization/chinese-traditional.ts b/src/localization/chinese-traditional.ts index 50dc151d..e1647c83 100644 --- a/src/localization/chinese-traditional.ts +++ b/src/localization/chinese-traditional.ts @@ -29,6 +29,7 @@ const TRADITIONAL_REPLACEMENTS: [string, string][] = [ ["区域", "區域"], ["地区", "地區"], ["网络", "網路"], + ["群组", "群組"], ["选项", "選項"], ["比较", "比較"], ["对比", "對比"], diff --git a/src/localization/dutch.ts b/src/localization/dutch.ts index ffb4f82a..2c965649 100644 --- a/src/localization/dutch.ts +++ b/src/localization/dutch.ts @@ -218,6 +218,10 @@ export function resolveDutch(text: Label): string { return "Omhoog"; case Label.MoveDown: return "Omlaag"; + case Label.CreateGroup: + return "Groep aanmaken"; + case Label.RemoveGroup: + return "Groep verwijderen"; case Label.AddVariable: return "Variabele toevoegen"; case Label.OpenPbPage: diff --git a/src/localization/english.ts b/src/localization/english.ts index fc6bbd45..4d3ce6d1 100644 --- a/src/localization/english.ts +++ b/src/localization/english.ts @@ -218,6 +218,10 @@ export function resolveEnglish(text: Label): string { return "Move Up"; case Label.MoveDown: return "Move Down"; + case Label.CreateGroup: + return "Create Group"; + case Label.RemoveGroup: + return "Remove Group"; case Label.AddVariable: return "Add Variable"; case Label.OpenPbPage: diff --git a/src/localization/french.ts b/src/localization/french.ts index 0938b67e..2cb2eb19 100644 --- a/src/localization/french.ts +++ b/src/localization/french.ts @@ -218,6 +218,10 @@ export function resolveFrench(text: Label): string { return "Monter"; case Label.MoveDown: return "Descendre"; + case Label.CreateGroup: + return "Créer un groupe"; + case Label.RemoveGroup: + return "Supprimer le groupe"; case Label.AddVariable: return "Ajouter une variable"; case Label.OpenPbPage: diff --git a/src/localization/german.ts b/src/localization/german.ts index 58f52c6e..316b7fc3 100644 --- a/src/localization/german.ts +++ b/src/localization/german.ts @@ -218,6 +218,10 @@ export function resolveGerman(text: Label): string { return "Nach oben"; case Label.MoveDown: return "Nach unten"; + case Label.CreateGroup: + return "Gruppe erstellen"; + case Label.RemoveGroup: + return "Gruppe entfernen"; case Label.AddVariable: return "Variable hinzufügen"; case Label.OpenPbPage: diff --git a/src/localization/index.ts b/src/localization/index.ts index fc5a2d95..d28475c4 100644 --- a/src/localization/index.ts +++ b/src/localization/index.ts @@ -123,6 +123,8 @@ export enum Label { RemoveSegment, MoveUp, MoveDown, + CreateGroup, + RemoveGroup, AddVariable, OpenPbPage, AssociateRun, diff --git a/src/localization/italian.ts b/src/localization/italian.ts index 4a4ddc0f..91a74b32 100644 --- a/src/localization/italian.ts +++ b/src/localization/italian.ts @@ -218,6 +218,10 @@ export function resolveItalian(text: Label): string { return "Sposta su"; case Label.MoveDown: return "Sposta giù"; + case Label.CreateGroup: + return "Crea gruppo"; + case Label.RemoveGroup: + return "Rimuovi gruppo"; case Label.AddVariable: return "Aggiungi variabile"; case Label.OpenPbPage: diff --git a/src/localization/japanese.ts b/src/localization/japanese.ts index a3b267c5..a20d6123 100644 --- a/src/localization/japanese.ts +++ b/src/localization/japanese.ts @@ -218,6 +218,10 @@ export function resolveJapanese(text: Label): string { return "上へ移動"; case Label.MoveDown: return "下へ移動"; + case Label.CreateGroup: + return "グループを作成"; + case Label.RemoveGroup: + return "グループを解除"; case Label.AddVariable: return "変数を追加"; case Label.OpenPbPage: diff --git a/src/localization/korean.ts b/src/localization/korean.ts index a4ceeb9d..d07ae915 100644 --- a/src/localization/korean.ts +++ b/src/localization/korean.ts @@ -218,6 +218,10 @@ export function resolveKorean(text: Label): string { return "위로"; case Label.MoveDown: return "아래로"; + case Label.CreateGroup: + return "그룹 만들기"; + case Label.RemoveGroup: + return "그룹 해제"; case Label.AddVariable: return "변수 추가"; case Label.OpenPbPage: diff --git a/src/localization/polish.ts b/src/localization/polish.ts index 46edff78..8269490e 100644 --- a/src/localization/polish.ts +++ b/src/localization/polish.ts @@ -218,6 +218,10 @@ export function resolvePolish(text: Label): string { return "Przenieś w górę"; case Label.MoveDown: return "Przenieś w dół"; + case Label.CreateGroup: + return "Utwórz grupę"; + case Label.RemoveGroup: + return "Usuń grupę"; case Label.AddVariable: return "Dodaj zmienną"; case Label.OpenPbPage: diff --git a/src/localization/portuguese-brazil.ts b/src/localization/portuguese-brazil.ts index af96da67..fb85e5db 100644 --- a/src/localization/portuguese-brazil.ts +++ b/src/localization/portuguese-brazil.ts @@ -218,6 +218,10 @@ export function resolveBrazilianPortuguese(text: Label): string { return "Mover para cima"; case Label.MoveDown: return "Mover para baixo"; + case Label.CreateGroup: + return "Criar grupo"; + case Label.RemoveGroup: + return "Remover grupo"; case Label.AddVariable: return "Adicionar variável"; case Label.OpenPbPage: diff --git a/src/localization/portuguese.ts b/src/localization/portuguese.ts index f7fbe2ef..d3d1d8fc 100644 --- a/src/localization/portuguese.ts +++ b/src/localization/portuguese.ts @@ -218,6 +218,10 @@ export function resolvePortuguese(text: Label): string { return "Mover para cima"; case Label.MoveDown: return "Mover para baixo"; + case Label.CreateGroup: + return "Criar grupo"; + case Label.RemoveGroup: + return "Remover grupo"; case Label.AddVariable: return "Adicionar variável"; case Label.OpenPbPage: diff --git a/src/localization/russian.ts b/src/localization/russian.ts index a7b70281..4d1861de 100644 --- a/src/localization/russian.ts +++ b/src/localization/russian.ts @@ -218,6 +218,10 @@ export function resolveRussian(text: Label): string { return "Вверх"; case Label.MoveDown: return "Вниз"; + case Label.CreateGroup: + return "Создать группу"; + case Label.RemoveGroup: + return "Удалить группу"; case Label.AddVariable: return "Добавить переменную"; case Label.OpenPbPage: diff --git a/src/localization/spanish.ts b/src/localization/spanish.ts index 5410f5b7..9d507208 100644 --- a/src/localization/spanish.ts +++ b/src/localization/spanish.ts @@ -218,6 +218,10 @@ export function resolveSpanish(text: Label): string { return "Subir"; case Label.MoveDown: return "Bajar"; + case Label.CreateGroup: + return "Crear grupo"; + case Label.RemoveGroup: + return "Eliminar grupo"; case Label.AddVariable: return "Agregar variable"; case Label.OpenPbPage: diff --git a/src/ui/components/Settings/SubsplitDisplayMode.tsx b/src/ui/components/Settings/SubsplitDisplayMode.tsx index bdbfced5..ed7b33f2 100644 --- a/src/ui/components/Settings/SubsplitDisplayMode.tsx +++ b/src/ui/components/Settings/SubsplitDisplayMode.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { expect } from "../../../util/OptionUtil"; -import { SubsplitDisplayModeJson, Language } from "../../../livesplit-core"; +import { SubsplitDisplayMode as SubsplitDisplayModeType, Language } from "../../../livesplit-core"; import { SettingValueFactory } from "."; import { Label, resolve } from "../../../localization"; @@ -12,7 +12,7 @@ export function SubsplitDisplayMode({ factory, lang, }: { - value: SubsplitDisplayModeJson; + value: SubsplitDisplayModeType; setValue: (value: T) => void; factory: SettingValueFactory; lang: Language | undefined; diff --git a/src/ui/views/RunEditor.tsx b/src/ui/views/RunEditor.tsx index 2dc6451c..dc995043 100644 --- a/src/ui/views/RunEditor.tsx +++ b/src/ui/views/RunEditor.tsx @@ -90,7 +90,8 @@ type SegmentSelectionState = interface SegmentGroupBounds { startIndex: number; endIndex: number; - name: string; + explicitName: string | null; + defaultName: string; } enum Tab { @@ -593,13 +594,13 @@ function SegmentListButtons({ addComparison(editor, update, lang)} @@ -887,10 +888,13 @@ function getSegmentGroupBounds( return undefined; } + const defaultName = editorState.segments[endIndex].name; + return { startIndex, endIndex, - name: explicitName ?? editorState.segments[endIndex].name, + explicitName: explicitName === defaultName ? null : explicitName, + defaultName, }; } @@ -1191,7 +1195,13 @@ function SegmentsTable({ e.stopPropagation()} onClick={(e) => e.stopPropagation()} onFocus={(_) => @@ -1209,9 +1219,7 @@ function SegmentsTable({ segmentGroupBounds.endIndex, ); editor.renameActiveSegmentGroup( - e.target.value === "" - ? null - : e.target.value, + e.target.value, ); update(); }} From b0b6e6dc00477679b6847cf86750cbbaa40990e2 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Sun, 5 Jul 2026 21:19:53 +0200 Subject: [PATCH 06/31] Preserve Subsplit Group Selection While Renaming This keeps the full segment group selected when editing a group header in the run editor instead of collapsing the selection to the major split. This also updates the staged livesplit-core submodule with regression coverage for the same selection behavior. --- livesplit-core | 2 +- src/ui/views/RunEditor.tsx | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/livesplit-core b/livesplit-core index fec2a023..b4c24f16 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit fec2a0238fb8580a42c6a13cdeb4fe7cae5735d7 +Subproject commit b4c24f164754e033017c2a9f33a8119b46ea8e26 diff --git a/src/ui/views/RunEditor.tsx b/src/ui/views/RunEditor.tsx index dc995043..c5ce6eb6 100644 --- a/src/ui/views/RunEditor.tsx +++ b/src/ui/views/RunEditor.tsx @@ -1216,6 +1216,9 @@ function SegmentsTable({ } onChange={(e) => { editor.selectOnly( + segmentGroupBounds.startIndex, + ); + editor.selectRange( segmentGroupBounds.endIndex, ); editor.renameActiveSegmentGroup( From 1469d0b7bea2e25e458102c4379f51e3ed3c704a Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Mon, 6 Jul 2026 19:47:57 +0200 Subject: [PATCH 07/31] Add Subsplit Group Icons To Editor This lets the run editor display and edit explicit segment group icons while showing inherited major-split icons as faint placeholders. This also updates the staged livesplit-core submodule with group icon support and refined current-group-expanded scrolling behavior. --- livesplit-core | 2 +- src/css/RunEditor.module.css | 32 ++++++++-- src/ui/views/RunEditor.tsx | 117 ++++++++++++++++++++++++++++++----- 3 files changed, 130 insertions(+), 21 deletions(-) diff --git a/livesplit-core b/livesplit-core index b4c24f16..96bac4f7 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit b4c24f164754e033017c2a9f33a8119b46ea8e26 +Subproject commit 96bac4f71a7ac6af24c7437dc085cffe19a87389 diff --git a/src/css/RunEditor.module.css b/src/css/RunEditor.module.css index 035b37c2..d129b0db 100644 --- a/src/css/RunEditor.module.css +++ b/src/css/RunEditor.module.css @@ -7,12 +7,17 @@ > th { font-weight: bold; - text-align: center; + text-align: right; padding: calc(var(--ui-margin) / 2) calc(var(--ui-margin) / 2); display: table-cell; &:first-child { padding-left: var(--ui-margin); + text-align: center; + } + + &:nth-child(2) { + text-align: left; } &:last-child { @@ -162,6 +167,14 @@ cursor: pointer; display: flex; justify-content: center; + + img { + display: block; + height: 19px; + margin: 0 auto; + object-fit: contain; + width: 19px; + } } > input { @@ -183,8 +196,13 @@ td { border-top: 1px solid var(--border-color); font-weight: bold; - padding-left: var(--ui-margin) !important; - padding-right: var(--ui-margin) !important; + } + + td.segmentIconContainer { + display: table-cell; + height: auto; + text-align: center; + vertical-align: middle; } } @@ -195,17 +213,19 @@ } .segmentGroupHeaderInput { + display: block; font-weight: bold; width: 100%; &::placeholder { - color: var(--input-text-color); + color: currentColor; opacity: 0.65; } } -.segmentGroupHeaderIcon { - width: 1%; + +.placeholderSegmentIcon { + opacity: 0.5; } .segmentGroupName { diff --git a/src/ui/views/RunEditor.tsx b/src/ui/views/RunEditor.tsx index c5ce6eb6..b3632c9b 100644 --- a/src/ui/views/RunEditor.tsx +++ b/src/ui/views/RunEditor.tsx @@ -92,6 +92,8 @@ interface SegmentGroupBounds { endIndex: number; explicitName: string | null; defaultName: string; + icon: LiveSplit.ImageId; + hasExplicitIcon: boolean; } enum Tab { @@ -866,6 +868,8 @@ function getSegmentGroupBounds( let startIndex: number | undefined; let endIndex: number | undefined; let explicitName: string | null = null; + let icon: LiveSplit.ImageId | undefined; + let hasExplicitIcon = false; editorState.segments.forEach((segment, segmentIndex) => { const segmentGroup = segment.segment_group; @@ -874,6 +878,8 @@ function getSegmentGroupBounds( } startIndex ??= segmentIndex; + icon = segmentGroup.icon; + hasExplicitIcon = segmentGroup.has_explicit_icon; if (segmentGroup.name !== null) { explicitName = segmentGroup.name; @@ -895,6 +901,8 @@ function getSegmentGroupBounds( endIndex, explicitName: explicitName === defaultName ? null : explicitName, defaultName, + icon: icon ?? editorState.segments[endIndex].icon, + hasExplicitIcon, }; } @@ -921,7 +929,10 @@ function startsUngroupedSectionAfterGroup( } const previousSegment = editorState.segments[segmentIndex - 1]; - return previousSegment?.segment_group.group_index !== null; + return ( + previousSegment !== undefined && + previousSegment.segment_group.group_index !== null + ); } function selectSegmentGroup( @@ -1188,8 +1199,33 @@ function SegmentsTable({ ); }} > - + changeSegmentGroupIcon( + segmentGroupBounds, + editor, + maybeUpdate, + lang, + ) + } + removeSegmentIcon={() => + removeSegmentGroupIcon( + segmentGroupBounds, + editor, + update, + ) + } className={classes.segmentGroupHeaderIcon} + isPlaceholder={ + !segmentGroupBounds.hasExplicitIcon + } + canRemoveIcon={ + segmentGroupBounds.hasExplicitIcon + } + lang={lang} /> void; removeSegmentIcon: () => void; + className?: string; + isPlaceholder?: boolean; + canRemoveIcon?: boolean; lang: LiveSplit.Language | undefined; }) { const [position, setPosition] = React.useState(null); return ( { if (position !== null) { return; @@ -2182,7 +2226,14 @@ function SegmentIcon({ } }} > - {segmentIcon !== undefined && } + {segmentIcon !== undefined && ( + + )} {position && ( - - {resolve(Label.RemoveSegmentIcon, lang)} - - {resolve(Label.RemoveSegmentIconDescription, lang)} - - + {canRemoveIcon && ( + + {resolve(Label.RemoveSegmentIcon, lang)} + + {resolve(Label.RemoveSegmentIconDescription, lang)} + + + )} )} @@ -3018,6 +3071,42 @@ function removeSegmentIcon( update(); } +async function changeSegmentGroupIcon( + bounds: SegmentGroupBounds, + editor: LiveSplit.RunEditorRefMut, + maybeUpdate: () => void, + lang: LiveSplit.Language | undefined, +) { + editor.selectOnly(bounds.startIndex); + editor.selectRange(bounds.endIndex); + const maybeFile = await openFileAsArrayBuffer(FILE_EXT_IMAGES); + if (maybeFile === undefined) { + return; + } + if (maybeFile instanceof Error) { + toast.error( + `${resolve(Label.FailedToReadFile, lang)} ${maybeFile.message}`, + ); + return; + } + const [file] = maybeFile; + if (editor.activeSetSegmentGroupIconFromArray(new Uint8Array(file))) { + maybeUpdate(); + } +} + +function removeSegmentGroupIcon( + bounds: SegmentGroupBounds, + editor: LiveSplit.RunEditorRefMut, + update: () => void, +) { + editor.selectOnly(bounds.startIndex); + editor.selectRange(bounds.endIndex); + if (editor.activeRemoveSegmentGroupIcon()) { + update(); + } +} + function focusSegment( index: number, editor: LiveSplit.RunEditorRefMut, From 37da56c82c31b135a91e268574c1c042ae5814fd Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Thu, 9 Jul 2026 22:20:33 +0200 Subject: [PATCH 08/31] Update Native Subsplit Editor State This makes the Run Editor read each native segment group from its dedicated state list instead of reconstructing group bounds and properties from per-segment metadata. It also keeps explicit names visible when they happen to match their major split's current name. This updates the Cargo lock for the new core dependency and advances the staged livesplit-core submodule with invariant-preserving structural edits, a multi-group movement guard, and correct group-header icon layout. --- livesplit-core | 2 +- src-tauri/Cargo.lock | 1 + .../Settings/SubsplitDisplayMode.tsx | 5 +- src/ui/views/RunEditor.tsx | 61 +++++++------------ 4 files changed, 28 insertions(+), 41 deletions(-) diff --git a/livesplit-core b/livesplit-core index 96bac4f7..62eb704b 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit 96bac4f71a7ac6af24c7437dc085cffe19a87389 +Subproject commit 62eb704bcd369c1af26ff009b58296452a86772f diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5f91a9c5..7235c54c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2131,6 +2131,7 @@ dependencies = [ "simdutf8", "slab", "smallstr", + "smallvec", "snafu", "time", "tiny-skia", diff --git a/src/ui/components/Settings/SubsplitDisplayMode.tsx b/src/ui/components/Settings/SubsplitDisplayMode.tsx index ed7b33f2..d60f4802 100644 --- a/src/ui/components/Settings/SubsplitDisplayMode.tsx +++ b/src/ui/components/Settings/SubsplitDisplayMode.tsx @@ -1,6 +1,9 @@ import * as React from "react"; import { expect } from "../../../util/OptionUtil"; -import { SubsplitDisplayMode as SubsplitDisplayModeType, Language } from "../../../livesplit-core"; +import { + SubsplitDisplayMode as SubsplitDisplayModeType, + Language, +} from "../../../livesplit-core"; import { SettingValueFactory } from "."; import { Label, resolve } from "../../../localization"; diff --git a/src/ui/views/RunEditor.tsx b/src/ui/views/RunEditor.tsx index b3632c9b..62942132 100644 --- a/src/ui/views/RunEditor.tsx +++ b/src/ui/views/RunEditor.tsx @@ -865,44 +865,26 @@ function getSegmentGroupBounds( editorState: LiveSplit.RunEditorStateJson, groupIndex: number, ): SegmentGroupBounds | undefined { - let startIndex: number | undefined; - let endIndex: number | undefined; - let explicitName: string | null = null; - let icon: LiveSplit.ImageId | undefined; - let hasExplicitIcon = false; - - editorState.segments.forEach((segment, segmentIndex) => { - const segmentGroup = segment.segment_group; - if (segmentGroup.group_index !== groupIndex) { - return; - } - - startIndex ??= segmentIndex; - icon = segmentGroup.icon; - hasExplicitIcon = segmentGroup.has_explicit_icon; - - if (segmentGroup.name !== null) { - explicitName = segmentGroup.name; - } - - if (segmentGroup.is_major_split) { - endIndex = segmentIndex; - } - }); - - if (startIndex === undefined || endIndex === undefined) { + const group = editorState.segment_groups[groupIndex]; + if (group === undefined || group.start >= group.end) { return undefined; } + const startIndex = group.start; + const endIndex = group.end - 1; const defaultName = editorState.segments[endIndex].name; return { startIndex, endIndex, - explicitName: explicitName === defaultName ? null : explicitName, + // An explicit name may intentionally match the major split's current + // name so it remains stable if that segment is renamed later. Keep the + // distinction visible instead of presenting persisted explicit data as + // an inherited placeholder value. + explicitName: group.name, defaultName, - icon: icon ?? editorState.segments[endIndex].icon, - hasExplicitIcon, + icon: group.icon, + hasExplicitIcon: group.has_explicit_icon, }; } @@ -910,28 +892,26 @@ function isFirstSegmentInGroup( editorState: LiveSplit.RunEditorStateJson, segmentIndex: number, ) { - const groupIndex = - editorState.segments[segmentIndex].segment_group.group_index; + const groupIndex = editorState.segments[segmentIndex].segment_group_index; if (groupIndex === null) { return false; } - const previousSegment = editorState.segments[segmentIndex - 1]; - return previousSegment?.segment_group.group_index !== groupIndex; + return editorState.segment_groups[groupIndex]?.start === segmentIndex; } function startsUngroupedSectionAfterGroup( editorState: LiveSplit.RunEditorStateJson, segmentIndex: number, ) { - if (editorState.segments[segmentIndex].segment_group.group_index !== null) { + if (editorState.segments[segmentIndex].segment_group_index !== null) { return false; } const previousSegment = editorState.segments[segmentIndex - 1]; return ( previousSegment !== undefined && - previousSegment.segment_group.group_index !== null + previousSegment.segment_group_index !== null ); } @@ -1135,7 +1115,7 @@ function SegmentsTable({ runEditorUrlCache, editorState, ); - const segmentGroupIndex = s.segment_group.group_index; + const segmentGroupIndex = s.segment_group_index; const segmentGroupBounds = segmentGroupIndex !== null ? getSegmentGroupBounds( @@ -1154,7 +1134,7 @@ function SegmentsTable({ ) ? classes.segmentGroupBoundary : "", - s.segment_group.is_major_split + segmentGroupBounds?.endIndex === segmentIndex ? classes.segmentGroupMajor : "", ] @@ -1555,7 +1535,7 @@ function SegmentsTable({ ), )} - + , ); return rows; })} @@ -2257,7 +2237,10 @@ function SegmentIcon({ > {resolve(Label.RemoveSegmentIcon, lang)} - {resolve(Label.RemoveSegmentIconDescription, lang)} + {resolve( + Label.RemoveSegmentIconDescription, + lang, + )} )} From 156a8d587d33afe5afefa6db1e4ccfdf7b56b257 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Sat, 18 Jul 2026 12:07:02 +0200 Subject: [PATCH 09/31] Preserve Pending Run Editor Time Edits --- src/ui/views/RunEditor.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/ui/views/RunEditor.tsx b/src/ui/views/RunEditor.tsx index 62942132..81827f26 100644 --- a/src/ui/views/RunEditor.tsx +++ b/src/ui/views/RunEditor.tsx @@ -915,6 +915,19 @@ function startsUngroupedSectionAfterGroup( ); } +function commitFocusedInputBeforeSelectionChange() { + const focusedElement = document.activeElement; + if (focusedElement instanceof HTMLInputElement) { + // The time editors deliberately commit their parsed value on blur. A + // group header prevents the browser's default mouse-down behavior so + // that the header itself never takes focus, which also suppresses the + // browser-generated blur. Trigger it explicitly while the old segment + // is still active; its handler runs synchronously and commits the edit + // before selecting the group's range resets the row-local draft state. + focusedElement.blur(); + } +} + function selectSegmentGroup( editor: LiveSplit.RunEditorRefMut, editorState: LiveSplit.RunEditorStateJson, @@ -1168,6 +1181,7 @@ function SegmentsTable({ .filter(Boolean) .join(" ")} onMouseDown={(e) => { + commitFocusedInputBeforeSelectionChange(); e.preventDefault(); selectSegmentGroup( editor, From 1277402669e0e0c365581a25ad72e64ff983f94b Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Sat, 18 Jul 2026 12:14:39 +0200 Subject: [PATCH 10/31] Update livesplit-core Subsplit Fixes --- livesplit-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/livesplit-core b/livesplit-core index 62eb704b..09ffb185 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit 62eb704bcd369c1af26ff009b58296452a86772f +Subproject commit 09ffb185965c5a8c2c12ade6a4ef3d43edf44d60 From dd8d6803d9e4e01431ef7ed774f83a8fc9f05818 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Sat, 18 Jul 2026 14:27:08 +0200 Subject: [PATCH 11/31] Render Run Editor Rows From Core --- src/ui/views/RunEditor.tsx | 251 +++++++++++-------------------------- 1 file changed, 72 insertions(+), 179 deletions(-) diff --git a/src/ui/views/RunEditor.tsx b/src/ui/views/RunEditor.tsx index 81827f26..b1222af3 100644 --- a/src/ui/views/RunEditor.tsx +++ b/src/ui/views/RunEditor.tsx @@ -85,16 +85,7 @@ interface RowState { } type SegmentSelectionState = - LiveSplit.RunEditorStateJson["segments"][number]["selected"]; - -interface SegmentGroupBounds { - startIndex: number; - endIndex: number; - explicitName: string | null; - defaultName: string; - icon: LiveSplit.ImageId; - hasExplicitIcon: boolean; -} + LiveSplit.RunEditorSegmentRowJson["selected"]; enum Tab { RealTime, @@ -606,11 +597,11 @@ function SegmentListButtons({ @@ -861,60 +852,6 @@ function TabContent({ } } -function getSegmentGroupBounds( - editorState: LiveSplit.RunEditorStateJson, - groupIndex: number, -): SegmentGroupBounds | undefined { - const group = editorState.segment_groups[groupIndex]; - if (group === undefined || group.start >= group.end) { - return undefined; - } - - const startIndex = group.start; - const endIndex = group.end - 1; - const defaultName = editorState.segments[endIndex].name; - - return { - startIndex, - endIndex, - // An explicit name may intentionally match the major split's current - // name so it remains stable if that segment is renamed later. Keep the - // distinction visible instead of presenting persisted explicit data as - // an inherited placeholder value. - explicitName: group.name, - defaultName, - icon: group.icon, - hasExplicitIcon: group.has_explicit_icon, - }; -} - -function isFirstSegmentInGroup( - editorState: LiveSplit.RunEditorStateJson, - segmentIndex: number, -) { - const groupIndex = editorState.segments[segmentIndex].segment_group_index; - if (groupIndex === null) { - return false; - } - - return editorState.segment_groups[groupIndex]?.start === segmentIndex; -} - -function startsUngroupedSectionAfterGroup( - editorState: LiveSplit.RunEditorStateJson, - segmentIndex: number, -) { - if (editorState.segments[segmentIndex].segment_group_index !== null) { - return false; - } - - const previousSegment = editorState.segments[segmentIndex - 1]; - return ( - previousSegment !== undefined && - previousSegment.segment_group_index !== null - ); -} - function commitFocusedInputBeforeSelectionChange() { const focusedElement = document.activeElement; if (focusedElement instanceof HTMLInputElement) { @@ -930,23 +867,19 @@ function commitFocusedInputBeforeSelectionChange() { function selectSegmentGroup( editor: LiveSplit.RunEditorRefMut, - editorState: LiveSplit.RunEditorStateJson, groupIndex: number, rowState: RowState, setRowState: React.Dispatch>, update: () => LiveSplit.RunEditorStateJson, ) { - const bounds = getSegmentGroupBounds(editorState, groupIndex); - if (bounds === undefined) { - return editorState; + if (!editor.selectSegmentGroup(groupIndex)) { + return; } - editor.selectOnly(bounds.startIndex); - editor.selectRange(bounds.endIndex); const state = update(); setFocusedSegmentRowState( state, - getActiveSegmentIndex(state, bounds.endIndex), + getActiveSegmentIndex(state, rowState.index), rowState, setRowState, ); @@ -1122,61 +1055,16 @@ function SegmentsTable({ - {editorState.segments.flatMap((s, segmentIndex) => { - const segmentIcon = getSegmentIconUrl( - segmentIndex, - runEditorUrlCache, - editorState, - ); - const segmentGroupIndex = s.segment_group_index; - const segmentGroupBounds = - segmentGroupIndex !== null - ? getSegmentGroupBounds( - editorState, - segmentGroupIndex, - ) - : undefined; - const isGroupedSegment = segmentGroupIndex !== null; - const isSelected = - s.selected === "Selected" || s.selected === "Active"; - const rowClassName = [ - isSelected ? tableClasses.selected : "", - startsUngroupedSectionAfterGroup( - editorState, - segmentIndex, - ) - ? classes.segmentGroupBoundary - : "", - segmentGroupBounds?.endIndex === segmentIndex - ? classes.segmentGroupMajor - : "", - ] - .filter(Boolean) - .join(" "); - const rows: React.JSX.Element[] = []; - - if ( - segmentGroupIndex !== null && - segmentGroupBounds !== undefined && - isFirstSegmentInGroup(editorState, segmentIndex) - ) { - const groupSelected = editorState.segments - .slice( - segmentGroupBounds.startIndex, - segmentGroupBounds.endIndex + 1, - ) - .every( - (segment) => - segment.selected === "Selected" || - segment.selected === "Active", - ); + {editorState.rows.map((row) => { + if (row.kind === "SegmentGroup") { + const groupIndex = row.group_index; - rows.push( + return ( changeSegmentGroupIcon( - segmentGroupBounds, + groupIndex, editor, maybeUpdate, lang, @@ -1207,62 +1094,63 @@ function SegmentsTable({ } removeSegmentIcon={() => removeSegmentGroupIcon( - segmentGroupBounds, + groupIndex, editor, update, ) } className={classes.segmentGroupHeaderIcon} - isPlaceholder={ - !segmentGroupBounds.hasExplicitIcon - } - canRemoveIcon={ - segmentGroupBounds.hasExplicitIcon - } + isPlaceholder={!row.has_explicit_icon} + canRemoveIcon={row.has_explicit_icon} lang={lang} /> e.stopPropagation()} onClick={(e) => e.stopPropagation()} onFocus={(_) => selectSegmentGroup( editor, - editorState, - segmentGroupIndex, + groupIndex, rowState, setRowState, update, ) } onChange={(e) => { - editor.selectOnly( - segmentGroupBounds.startIndex, - ); - editor.selectRange( - segmentGroupBounds.endIndex, - ); - editor.renameActiveSegmentGroup( + editor.renameSegmentGroup( + groupIndex, e.target.value, ); update(); }} /> - , + ); } - rows.push( + const s = row; + const segmentIndex = s.segment_index; + const segmentIcon = runEditorUrlCache.cache(s.icon); + const isSelected = + s.selected === "Selected" || s.selected === "Active"; + const rowClassName = [ + isSelected ? tableClasses.selected : "", + s.starts_new_section + ? classes.segmentGroupBoundary + : "", + s.is_major_segment + ? classes.segmentGroupMajor + : "", + ] + .filter(Boolean) + .join(" "); + return ( - {editorState.segments[ - segmentIndex - ].comparison_times.map( + {s.comparison_times.map( (comparisonTime, comparisonIndex) => ( ), )} - , + ); - return rows; })} @@ -2961,14 +2846,6 @@ async function addCustomVariable( } } -function getSegmentIconUrl( - index: number, - runEditorUrlCache: UrlCache, - editorState: LiveSplit.RunEditorStateJson, -): string | undefined { - return runEditorUrlCache.cache(editorState.segments[index].icon); -} - function changeSegmentSelection( event: | React.MouseEvent @@ -3023,15 +2900,26 @@ function shouldFocusClickedRow( return true; } +function getSegmentRow( + editorState: LiveSplit.RunEditorStateJson, + segmentIndex: number, +): LiveSplit.RunEditorSegmentRowJson | undefined { + return editorState.rows.find( + (row): row is LiveSplit.RunEditorSegmentRowJson => + row.kind === "Segment" && row.segment_index === segmentIndex, + ); +} + function getActiveSegmentIndex( editorState: LiveSplit.RunEditorStateJson, fallbackIndex: number, ) { - const activeIndex = editorState.segments.findIndex( - (segment) => segment.selected === "Active", + const activeSegment = editorState.rows.find( + (row): row is LiveSplit.RunEditorSegmentRowJson => + row.kind === "Segment" && row.selected === "Active", ); - return activeIndex === -1 ? fallbackIndex : activeIndex; + return activeSegment?.segment_index ?? fallbackIndex; } async function changeSegmentIcon( @@ -3069,13 +2957,11 @@ function removeSegmentIcon( } async function changeSegmentGroupIcon( - bounds: SegmentGroupBounds, + groupIndex: number, editor: LiveSplit.RunEditorRefMut, maybeUpdate: () => void, lang: LiveSplit.Language | undefined, ) { - editor.selectOnly(bounds.startIndex); - editor.selectRange(bounds.endIndex); const maybeFile = await openFileAsArrayBuffer(FILE_EXT_IMAGES); if (maybeFile === undefined) { return; @@ -3087,19 +2973,22 @@ async function changeSegmentGroupIcon( return; } const [file] = maybeFile; - if (editor.activeSetSegmentGroupIconFromArray(new Uint8Array(file))) { + if ( + editor.setSegmentGroupIconFromArray( + groupIndex, + new Uint8Array(file), + ) + ) { maybeUpdate(); } } function removeSegmentGroupIcon( - bounds: SegmentGroupBounds, + groupIndex: number, editor: LiveSplit.RunEditorRefMut, update: () => void, ) { - editor.selectOnly(bounds.startIndex); - editor.selectRange(bounds.endIndex); - if (editor.activeRemoveSegmentGroupIcon()) { + if (editor.removeSegmentGroupIcon(groupIndex)) { update(); } } @@ -3143,7 +3032,11 @@ function setFocusedSegmentRowState( rowState: RowState, setRowState: (rowState: RowState) => void, ) { - const comparisonTimes = editorState.segments[index].comparison_times; + const segment = getSegmentRow(editorState, index); + if (segment === undefined) { + return; + } + const comparisonTimes = segment.comparison_times; setRowState({ ...rowState, splitTimeChanged: false, From 0421d8b230e4ed5e68702b654e8529ae362fe7f1 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Sat, 18 Jul 2026 14:29:20 +0200 Subject: [PATCH 12/31] Update livesplit-core Run Editor Rows --- livesplit-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/livesplit-core b/livesplit-core index 09ffb185..c20821b6 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit 09ffb185965c5a8c2c12ade6a4ef3d43edf44d60 +Subproject commit c20821b6e42fb2f3bc859a8c52231d380c000d31 From ce1c2bbf5a57226d0a81aa41faceb1c19a116fee Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Sat, 18 Jul 2026 17:40:05 +0200 Subject: [PATCH 13/31] Update livesplit-core Split Name Layout --- livesplit-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/livesplit-core b/livesplit-core index c20821b6..d4416bd8 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit c20821b6e42fb2f3bc859a8c52231d380c000d31 +Subproject commit d4416bd8ca289f4c924b99aefb82e1bac379be0f From acb10c197bd095ba533e75a2e8ffe3a7b203bf6c Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Sat, 18 Jul 2026 17:55:20 +0200 Subject: [PATCH 14/31] Update livesplit-core Parsing Fixtures --- livesplit-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/livesplit-core b/livesplit-core index d4416bd8..1c2a6c13 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit d4416bd8ca289f4c924b99aefb82e1bac379be0f +Subproject commit 1c2a6c13ab29a0c4ca621131c9fd5eb18a46e852 From 970cfeeda9d438c11fb133d83c7c15184a8f95cb Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Sat, 18 Jul 2026 18:13:49 +0200 Subject: [PATCH 15/31] Remove Major Segment Styling --- livesplit-core | 2 +- src/css/RunEditor.module.css | 4 ---- src/ui/views/RunEditor.tsx | 3 --- 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/livesplit-core b/livesplit-core index 1c2a6c13..6fa9bfb0 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit 1c2a6c13ab29a0c4ca621131c9fd5eb18a46e852 +Subproject commit 6fa9bfb08fd9922c2f931cc2da991ebfd8ad3e1a diff --git a/src/css/RunEditor.module.css b/src/css/RunEditor.module.css index d129b0db..a1a2e63d 100644 --- a/src/css/RunEditor.module.css +++ b/src/css/RunEditor.module.css @@ -232,10 +232,6 @@ padding-left: calc(var(--ui-large-margin) + var(--ui-margin)) !important; } -.segmentGroupMajor { - font-weight: bold; -} - .bottomSection { display: flex; flex-wrap: wrap; diff --git a/src/ui/views/RunEditor.tsx b/src/ui/views/RunEditor.tsx index b1222af3..a88eb89b 100644 --- a/src/ui/views/RunEditor.tsx +++ b/src/ui/views/RunEditor.tsx @@ -1144,9 +1144,6 @@ function SegmentsTable({ s.starts_new_section ? classes.segmentGroupBoundary : "", - s.is_major_segment - ? classes.segmentGroupMajor - : "", ] .filter(Boolean) .join(" "); From d648b979eef3cd64a183ee5da77b6be7eba56717 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Sat, 18 Jul 2026 18:21:14 +0200 Subject: [PATCH 16/31] Update livesplit-core Selection Insertion --- livesplit-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/livesplit-core b/livesplit-core index 6fa9bfb0..53f71119 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit 6fa9bfb08fd9922c2f931cc2da991ebfd8ad3e1a +Subproject commit 53f711194a23371453eac7cd37c2e9fc94c79e1d From d5b25d335d7c6f7a9786ddb96784d0d3d260f482 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Sat, 18 Jul 2026 20:25:39 +0200 Subject: [PATCH 17/31] Update livesplit-core Group Range Editing --- livesplit-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/livesplit-core b/livesplit-core index 53f71119..ae35af2f 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit 53f711194a23371453eac7cd37c2e9fc94c79e1d +Subproject commit ae35af2f6c3c585a76cf964013968def813b39c1 From ca5cf3a8e4db9191ec8d50a7ee313ce5200e8279 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Sat, 18 Jul 2026 21:27:15 +0200 Subject: [PATCH 18/31] Update livesplit-core Group Error APIs --- livesplit-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/livesplit-core b/livesplit-core index ae35af2f..98e0dc99 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit ae35af2f6c3c585a76cf964013968def813b39c1 +Subproject commit 98e0dc99b22ee3040da22c7a471872559b45527c From 917e921afc39b3d15744bc033bffded4828644b0 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Tue, 21 Jul 2026 10:03:55 +0200 Subject: [PATCH 19/31] Update livesplit-core Subsplit Layout Parsing --- livesplit-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/livesplit-core b/livesplit-core index 98e0dc99..56a8781b 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit 98e0dc99b22ee3040da22c7a471872559b45527c +Subproject commit 56a8781b2d8e02790fce9812fd914849ee322028 From d2022f9effc0de607f885ccd072d76350a54e6d1 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Tue, 21 Jul 2026 10:47:29 +0200 Subject: [PATCH 20/31] Update livesplit-core Segment Group Errors --- livesplit-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/livesplit-core b/livesplit-core index 56a8781b..f21775b4 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit 56a8781b2d8e02790fce9812fd914849ee322028 +Subproject commit f21775b4419f288d20369d9e7449329eff3fd59a From 01dcf089637ac5c46e571014ca84acc788939781 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Tue, 21 Jul 2026 11:14:41 +0200 Subject: [PATCH 21/31] Order Subsplit Display Modes by Expansion --- livesplit-core | 2 +- src/localization/chinese-simplified.ts | 4 ++-- src/localization/dutch.ts | 4 ++-- src/localization/english.ts | 4 ++-- src/localization/french.ts | 4 ++-- src/localization/german.ts | 4 ++-- src/localization/index.ts | 2 +- src/localization/italian.ts | 4 ++-- src/localization/japanese.ts | 4 ++-- src/localization/korean.ts | 4 ++-- src/localization/polish.ts | 4 ++-- src/localization/portuguese-brazil.ts | 4 ++-- src/localization/portuguese.ts | 4 ++-- src/localization/russian.ts | 4 ++-- src/localization/spanish.ts | 4 ++-- src/ui/components/Settings/SubsplitDisplayMode.tsx | 6 +++--- 16 files changed, 31 insertions(+), 31 deletions(-) diff --git a/livesplit-core b/livesplit-core index f21775b4..82b419c3 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit f21775b4419f288d20369d9e7449329eff3fd59a +Subproject commit 82b419c3466952a563b327070fd00b5c50a1f1e6 diff --git a/src/localization/chinese-simplified.ts b/src/localization/chinese-simplified.ts index b961906f..1c98c261 100644 --- a/src/localization/chinese-simplified.ts +++ b/src/localization/chinese-simplified.ts @@ -574,10 +574,10 @@ export function resolveChineseSimplified(text: Label): string { return "可节省时间"; case Label.SubsplitDisplayModeFlat: return "平铺"; - case Label.SubsplitDisplayModeAllGroupsExpanded: - return "全部分组展开"; case Label.SubsplitDisplayModeCurrentGroupExpanded: return "当前分组展开"; + case Label.SubsplitDisplayModeAllGroupsExpanded: + return "全部分组展开"; case Label.ColumnUpdateWithDontUpdate: return "不更新"; case Label.ColumnUpdateWithSplitTime: diff --git a/src/localization/dutch.ts b/src/localization/dutch.ts index 2c965649..be63aee2 100644 --- a/src/localization/dutch.ts +++ b/src/localization/dutch.ts @@ -574,10 +574,10 @@ export function resolveDutch(text: Label): string { return "Mogelijke tijdswinst"; case Label.SubsplitDisplayModeFlat: return "Plat"; - case Label.SubsplitDisplayModeAllGroupsExpanded: - return "Alle groepen uitgeklapt"; case Label.SubsplitDisplayModeCurrentGroupExpanded: return "Huidige groep uitgeklapt"; + case Label.SubsplitDisplayModeAllGroupsExpanded: + return "Alle groepen uitgeklapt"; case Label.ColumnUpdateWithDontUpdate: return "Niet bijwerken"; case Label.ColumnUpdateWithSplitTime: diff --git a/src/localization/english.ts b/src/localization/english.ts index 4d3ce6d1..11480359 100644 --- a/src/localization/english.ts +++ b/src/localization/english.ts @@ -574,10 +574,10 @@ export function resolveEnglish(text: Label): string { return "Possible Time Save"; case Label.SubsplitDisplayModeFlat: return "Flat"; - case Label.SubsplitDisplayModeAllGroupsExpanded: - return "All Groups Expanded"; case Label.SubsplitDisplayModeCurrentGroupExpanded: return "Current Group Expanded"; + case Label.SubsplitDisplayModeAllGroupsExpanded: + return "All Groups Expanded"; case Label.ColumnUpdateWithDontUpdate: return "Don't Update"; case Label.ColumnUpdateWithSplitTime: diff --git a/src/localization/french.ts b/src/localization/french.ts index 2cb2eb19..96840205 100644 --- a/src/localization/french.ts +++ b/src/localization/french.ts @@ -574,10 +574,10 @@ export function resolveFrench(text: Label): string { return "Gain de temps possible"; case Label.SubsplitDisplayModeFlat: return "À plat"; - case Label.SubsplitDisplayModeAllGroupsExpanded: - return "Tous les groupes dépliés"; case Label.SubsplitDisplayModeCurrentGroupExpanded: return "Groupe actuel déplié"; + case Label.SubsplitDisplayModeAllGroupsExpanded: + return "Tous les groupes dépliés"; case Label.ColumnUpdateWithDontUpdate: return "Ne pas mettre à jour"; case Label.ColumnUpdateWithSplitTime: diff --git a/src/localization/german.ts b/src/localization/german.ts index 316b7fc3..af3087e3 100644 --- a/src/localization/german.ts +++ b/src/localization/german.ts @@ -574,10 +574,10 @@ export function resolveGerman(text: Label): string { return "Mögliche Zeitersparnis"; case Label.SubsplitDisplayModeFlat: return "Flach"; - case Label.SubsplitDisplayModeAllGroupsExpanded: - return "Alle Gruppen erweitert"; case Label.SubsplitDisplayModeCurrentGroupExpanded: return "Aktuelle Gruppe erweitert"; + case Label.SubsplitDisplayModeAllGroupsExpanded: + return "Alle Gruppen erweitert"; case Label.ColumnUpdateWithDontUpdate: return "Nicht aktualisieren"; case Label.ColumnUpdateWithSplitTime: diff --git a/src/localization/index.ts b/src/localization/index.ts index d28475c4..0a30026f 100644 --- a/src/localization/index.ts +++ b/src/localization/index.ts @@ -301,8 +301,8 @@ export enum Label { ColumnStartWithComparisonSegmentTime, ColumnStartWithPossibleTimeSave, SubsplitDisplayModeFlat, - SubsplitDisplayModeAllGroupsExpanded, SubsplitDisplayModeCurrentGroupExpanded, + SubsplitDisplayModeAllGroupsExpanded, ColumnUpdateWithDontUpdate, ColumnUpdateWithSplitTime, ColumnUpdateWithDelta, diff --git a/src/localization/italian.ts b/src/localization/italian.ts index 91a74b32..cf53a42b 100644 --- a/src/localization/italian.ts +++ b/src/localization/italian.ts @@ -574,10 +574,10 @@ export function resolveItalian(text: Label): string { return "Tempo risparmiabile"; case Label.SubsplitDisplayModeFlat: return "Elenco piatto"; - case Label.SubsplitDisplayModeAllGroupsExpanded: - return "Tutti i gruppi espansi"; case Label.SubsplitDisplayModeCurrentGroupExpanded: return "Gruppo attuale espanso"; + case Label.SubsplitDisplayModeAllGroupsExpanded: + return "Tutti i gruppi espansi"; case Label.ColumnUpdateWithDontUpdate: return "Non aggiornare"; case Label.ColumnUpdateWithSplitTime: diff --git a/src/localization/japanese.ts b/src/localization/japanese.ts index a20d6123..29714520 100644 --- a/src/localization/japanese.ts +++ b/src/localization/japanese.ts @@ -574,10 +574,10 @@ export function resolveJapanese(text: Label): string { return "更新余地"; case Label.SubsplitDisplayModeFlat: return "フラット"; - case Label.SubsplitDisplayModeAllGroupsExpanded: - return "すべてのグループを展開"; case Label.SubsplitDisplayModeCurrentGroupExpanded: return "現在のグループを展開"; + case Label.SubsplitDisplayModeAllGroupsExpanded: + return "すべてのグループを展開"; case Label.ColumnUpdateWithDontUpdate: return "更新しない"; case Label.ColumnUpdateWithSplitTime: diff --git a/src/localization/korean.ts b/src/localization/korean.ts index d07ae915..e0cef6ce 100644 --- a/src/localization/korean.ts +++ b/src/localization/korean.ts @@ -574,10 +574,10 @@ export function resolveKorean(text: Label): string { return "절약 가능한 시간"; case Label.SubsplitDisplayModeFlat: return "플랫"; - case Label.SubsplitDisplayModeAllGroupsExpanded: - return "모든 그룹 펼침"; case Label.SubsplitDisplayModeCurrentGroupExpanded: return "현재 그룹 펼침"; + case Label.SubsplitDisplayModeAllGroupsExpanded: + return "모든 그룹 펼침"; case Label.ColumnUpdateWithDontUpdate: return "업데이트 안 함"; case Label.ColumnUpdateWithSplitTime: diff --git a/src/localization/polish.ts b/src/localization/polish.ts index 8269490e..5f33444b 100644 --- a/src/localization/polish.ts +++ b/src/localization/polish.ts @@ -574,10 +574,10 @@ export function resolvePolish(text: Label): string { return "Możliwa oszczędność czasu"; case Label.SubsplitDisplayModeFlat: return "Płasko"; - case Label.SubsplitDisplayModeAllGroupsExpanded: - return "Wszystkie grupy rozwinięte"; case Label.SubsplitDisplayModeCurrentGroupExpanded: return "Bieżąca grupa rozwinięta"; + case Label.SubsplitDisplayModeAllGroupsExpanded: + return "Wszystkie grupy rozwinięte"; case Label.ColumnUpdateWithDontUpdate: return "Nie aktualizuj"; case Label.ColumnUpdateWithSplitTime: diff --git a/src/localization/portuguese-brazil.ts b/src/localization/portuguese-brazil.ts index fb85e5db..474bd89b 100644 --- a/src/localization/portuguese-brazil.ts +++ b/src/localization/portuguese-brazil.ts @@ -574,10 +574,10 @@ export function resolveBrazilianPortuguese(text: Label): string { return "Tempo possível de economizar"; case Label.SubsplitDisplayModeFlat: return "Plano"; - case Label.SubsplitDisplayModeAllGroupsExpanded: - return "Todos os grupos expandidos"; case Label.SubsplitDisplayModeCurrentGroupExpanded: return "Grupo atual expandido"; + case Label.SubsplitDisplayModeAllGroupsExpanded: + return "Todos os grupos expandidos"; case Label.ColumnUpdateWithDontUpdate: return "Não atualizar"; case Label.ColumnUpdateWithSplitTime: diff --git a/src/localization/portuguese.ts b/src/localization/portuguese.ts index d3d1d8fc..a78a5c7e 100644 --- a/src/localization/portuguese.ts +++ b/src/localization/portuguese.ts @@ -574,10 +574,10 @@ export function resolvePortuguese(text: Label): string { return "Tempo possível de economizar"; case Label.SubsplitDisplayModeFlat: return "Plano"; - case Label.SubsplitDisplayModeAllGroupsExpanded: - return "Todos os grupos expandidos"; case Label.SubsplitDisplayModeCurrentGroupExpanded: return "Grupo atual expandido"; + case Label.SubsplitDisplayModeAllGroupsExpanded: + return "Todos os grupos expandidos"; case Label.ColumnUpdateWithDontUpdate: return "Não atualizar"; case Label.ColumnUpdateWithSplitTime: diff --git a/src/localization/russian.ts b/src/localization/russian.ts index 4d1861de..e4c6c92d 100644 --- a/src/localization/russian.ts +++ b/src/localization/russian.ts @@ -574,10 +574,10 @@ export function resolveRussian(text: Label): string { return "Возможная экономия времени"; case Label.SubsplitDisplayModeFlat: return "Плоский список"; - case Label.SubsplitDisplayModeAllGroupsExpanded: - return "Все группы раскрыты"; case Label.SubsplitDisplayModeCurrentGroupExpanded: return "Текущая группа раскрыта"; + case Label.SubsplitDisplayModeAllGroupsExpanded: + return "Все группы раскрыты"; case Label.ColumnUpdateWithDontUpdate: return "Не обновлять"; case Label.ColumnUpdateWithSplitTime: diff --git a/src/localization/spanish.ts b/src/localization/spanish.ts index 9d507208..c96d5638 100644 --- a/src/localization/spanish.ts +++ b/src/localization/spanish.ts @@ -574,10 +574,10 @@ export function resolveSpanish(text: Label): string { return "Tiempo posible de ahorrar"; case Label.SubsplitDisplayModeFlat: return "Plano"; - case Label.SubsplitDisplayModeAllGroupsExpanded: - return "Todos los grupos expandidos"; case Label.SubsplitDisplayModeCurrentGroupExpanded: return "Grupo actual expandido"; + case Label.SubsplitDisplayModeAllGroupsExpanded: + return "Todos los grupos expandidos"; case Label.ColumnUpdateWithDontUpdate: return "No actualizar"; case Label.ColumnUpdateWithSplitTime: diff --git a/src/ui/components/Settings/SubsplitDisplayMode.tsx b/src/ui/components/Settings/SubsplitDisplayMode.tsx index d60f4802..e7f7feac 100644 --- a/src/ui/components/Settings/SubsplitDisplayMode.tsx +++ b/src/ui/components/Settings/SubsplitDisplayMode.tsx @@ -37,15 +37,15 @@ export function SubsplitDisplayMode({ - + ); From 3868865cd08358f6edbbaf4388e1d17c6f3364ab Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Tue, 21 Jul 2026 12:41:42 +0200 Subject: [PATCH 22/31] Update livesplit-core Displayed Splits Iterator --- livesplit-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/livesplit-core b/livesplit-core index 82b419c3..55883aeb 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit 82b419c3466952a563b327070fd00b5c50a1f1e6 +Subproject commit 55883aebde7000a8ba68628f43748a55179ececc From 7ccbf07123bc67681a0be86709635809f73c61ce Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Tue, 21 Jul 2026 13:13:41 +0200 Subject: [PATCH 23/31] Update livesplit-core Active Subsplit Header --- livesplit-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/livesplit-core b/livesplit-core index 55883aeb..0f6b5eb3 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit 55883aebde7000a8ba68628f43748a55179ececc +Subproject commit 0f6b5eb3a72b0e0ab129a0e415bb72e438cf90ba From 9146892547dc3dc1b16b2c4bf059021c42098ab5 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Tue, 21 Jul 2026 13:31:18 +0200 Subject: [PATCH 24/31] Update livesplit-core Split Separators --- livesplit-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/livesplit-core b/livesplit-core index 0f6b5eb3..086fb49b 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit 0f6b5eb3a72b0e0ab129a0e415bb72e438cf90ba +Subproject commit 086fb49bfc724e0254ae452545d4d20fd32c0211 From 3e6af877710fcecc7c614389c8c6ef4dea6fc2b1 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Tue, 21 Jul 2026 14:15:57 +0200 Subject: [PATCH 25/31] Format Subsplit Run Editor --- src/css/RunEditor.module.css | 1 - src/ui/views/RunEditor.tsx | 10 ++-------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/css/RunEditor.module.css b/src/css/RunEditor.module.css index a1a2e63d..f2f873f3 100644 --- a/src/css/RunEditor.module.css +++ b/src/css/RunEditor.module.css @@ -223,7 +223,6 @@ } } - .placeholderSegmentIcon { opacity: 0.5; } diff --git a/src/ui/views/RunEditor.tsx b/src/ui/views/RunEditor.tsx index a88eb89b..ca5702ab 100644 --- a/src/ui/views/RunEditor.tsx +++ b/src/ui/views/RunEditor.tsx @@ -84,8 +84,7 @@ interface RowState { index: number; } -type SegmentSelectionState = - LiveSplit.RunEditorSegmentRowJson["selected"]; +type SegmentSelectionState = LiveSplit.RunEditorSegmentRowJson["selected"]; enum Tab { RealTime, @@ -2970,12 +2969,7 @@ async function changeSegmentGroupIcon( return; } const [file] = maybeFile; - if ( - editor.setSegmentGroupIconFromArray( - groupIndex, - new Uint8Array(file), - ) - ) { + if (editor.setSegmentGroupIconFromArray(groupIndex, new Uint8Array(file))) { maybeUpdate(); } } From 2d43e42d5f1dc0971146e6de1ce7defa30d2d28c Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Tue, 21 Jul 2026 14:16:12 +0200 Subject: [PATCH 26/31] Use Merged Native Subsplit Core --- livesplit-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/livesplit-core b/livesplit-core index 086fb49b..b282e3d6 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit 086fb49bfc724e0254ae452545d4d20fd32c0211 +Subproject commit b282e3d6d683146f36b97cab3c40247f25ec2c54 From 0ff5e62550b5c8850abd6565bb6b8b405f03ca8f Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Wed, 22 Jul 2026 12:09:34 +0200 Subject: [PATCH 27/31] Extract Run Editor Segment Table Move segment and group row rendering, selection state, and table-specific controls into focused modules. This keeps the large Run Editor view from accumulating another independent editing subsystem while preserving the existing behavior. --- src/ui/views/RunEditor.tsx | 1044 +----------------- src/ui/views/RunEditor/SegmentTableCells.tsx | 239 ++++ src/ui/views/RunEditor/SegmentsTable.tsx | 815 ++++++++++++++ 3 files changed, 1062 insertions(+), 1036 deletions(-) create mode 100644 src/ui/views/RunEditor/SegmentTableCells.tsx create mode 100644 src/ui/views/RunEditor/SegmentsTable.tsx diff --git a/src/ui/views/RunEditor.tsx b/src/ui/views/RunEditor.tsx index ca5702ab..d665e320 100644 --- a/src/ui/views/RunEditor.tsx +++ b/src/ui/views/RunEditor.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useState } from "react"; import * as LiveSplit from "../../livesplit-core"; import { FILE_EXT_IMAGES, @@ -48,6 +48,7 @@ import { LeaderboardButtons, } from "../components/Leaderboard"; import { Label, orAutoLang, resolve } from "../../localization"; +import { SegmentsTable } from "./RunEditor/SegmentsTable"; import classes from "../../css/RunEditor.module.css"; import buttonGroupClasses from "../../css/ButtonGroup.module.css"; @@ -72,20 +73,6 @@ interface Callbacks { closeRunEditor(save: boolean): void; } -interface RowState { - splitTime: string; - splitTimeChanged: boolean; - segmentTime: string; - segmentTimeChanged: boolean; - bestSegmentTime: string; - bestSegmentTimeChanged: boolean; - comparisonTimes: string[]; - comparisonTimesChanged: boolean[]; - index: number; -} - -type SegmentSelectionState = LiveSplit.RunEditorSegmentRowJson["selected"]; - enum Tab { RealTime, GameTime, @@ -812,6 +799,12 @@ function TabContent({ runEditorUrlCache={runEditorUrlCache} maybeUpdate={maybeUpdate} update={update} + renameComparison={(comparison) => + renameComparison(comparison, editor, update, lang) + } + copyComparison={(comparison) => + copyComparison(editor, update, lang, comparison) + } lang={lang} /> ); @@ -851,594 +844,6 @@ function TabContent({ } } -function commitFocusedInputBeforeSelectionChange() { - const focusedElement = document.activeElement; - if (focusedElement instanceof HTMLInputElement) { - // The time editors deliberately commit their parsed value on blur. A - // group header prevents the browser's default mouse-down behavior so - // that the header itself never takes focus, which also suppresses the - // browser-generated blur. Trigger it explicitly while the old segment - // is still active; its handler runs synchronously and commits the edit - // before selecting the group's range resets the row-local draft state. - focusedElement.blur(); - } -} - -function selectSegmentGroup( - editor: LiveSplit.RunEditorRefMut, - groupIndex: number, - rowState: RowState, - setRowState: React.Dispatch>, - update: () => LiveSplit.RunEditorStateJson, -) { - if (!editor.selectSegmentGroup(groupIndex)) { - return; - } - - const state = update(); - setFocusedSegmentRowState( - state, - getActiveSegmentIndex(state, rowState.index), - rowState, - setRowState, - ); - return state; -} - -function SegmentsTable({ - editor, - editorState, - runEditorUrlCache, - maybeUpdate, - update, - lang, -}: { - editor: LiveSplit.RunEditorRefMut; - editorState: LiveSplit.RunEditorStateJson; - runEditorUrlCache: UrlCache; - maybeUpdate: () => void; - update: () => LiveSplit.RunEditorStateJson; - lang: LiveSplit.Language | undefined; -}) { - const [dragIndex, setDragIndex] = useState(0); - const skipNextFocusedSegmentSelection = useRef(false); - const segmentNameInputRefs = useRef<(HTMLInputElement | null)[]>([]); - const [rowState, setRowState] = useState(() => ({ - bestSegmentTime: "", - bestSegmentTimeChanged: false, - comparisonTimes: [], - comparisonTimesChanged: [], - index: 0, - segmentTime: "", - segmentTimeChanged: false, - splitTime: "", - splitTimeChanged: false, - })); - - const handleSegmentInputMouseDown = ( - event: React.MouseEvent, - index: number, - selectionState: SegmentSelectionState, - ) => { - event.stopPropagation(); - - const preserveCurrentFocus = shouldPreserveCurrentFocus( - event, - selectionState, - ); - const focusClickedRow = shouldFocusClickedRow(event, selectionState); - - if (event.shiftKey || preserveCurrentFocus) { - event.preventDefault(); - } - - skipNextFocusedSegmentSelection.current = focusClickedRow; - - changeSegmentSelection( - event, - index, - selectionState, - editor, - rowState, - setRowState, - update, - ); - - if (event.shiftKey && focusClickedRow) { - event.currentTarget.focus(); - } - }; - - const handleSegmentInputClick = ( - event: React.MouseEvent, - ) => { - // Input selection is already handled on mousedown so the row's click - // handler must not run again on mouseup. - event.stopPropagation(); - }; - - const handleSegmentRowMouseDown = ( - event: React.MouseEvent, - index: number, - selectionState: SegmentSelectionState, - ) => { - const preserveCurrentFocus = shouldPreserveCurrentFocus( - event, - selectionState, - ); - const focusClickedRow = shouldFocusClickedRow(event, selectionState); - - if (event.shiftKey || preserveCurrentFocus) { - // Modifier-based selection should not trigger native text selection - // behavior or move focus away from the currently edited field. - event.preventDefault(); - } - - skipNextFocusedSegmentSelection.current = focusClickedRow; - - changeSegmentSelection( - event, - index, - selectionState, - editor, - rowState, - setRowState, - update, - ); - - if (focusClickedRow) { - segmentNameInputRefs.current[index]?.focus(); - } - }; - - const columnCount = 5 + editorState.comparison_names.length; - - return ( - - - - - - - - - {editorState.comparison_names.map( - (comparison, comparisonIndex) => { - return ( - { - e.dataTransfer.setData( - "text/plain", - "", - ); - setDragIndex(comparisonIndex); - }} - onDragEnd={(_) => update()} - onDrop={(e) => { - if (e.stopPropagation) { - e.stopPropagation(); - } - editor.moveComparison( - dragIndex, - comparisonIndex, - ); - // No update necessary, as we do it in onDragEnd. - return false; - }} - renameComparison={() => - renameComparison( - comparison, - editor, - update, - lang, - ) - } - copyComparison={() => - copyComparison( - editor, - update, - lang, - comparison, - ) - } - removeComparison={() => { - editor.removeComparison(comparison); - update(); - }} - lang={lang} - /> - ); - }, - )} - - - - {editorState.rows.map((row) => { - if (row.kind === "SegmentGroup") { - const groupIndex = row.group_index; - - return ( - { - commitFocusedInputBeforeSelectionChange(); - e.preventDefault(); - selectSegmentGroup( - editor, - groupIndex, - rowState, - setRowState, - update, - ); - }} - > - - changeSegmentGroupIcon( - groupIndex, - editor, - maybeUpdate, - lang, - ) - } - removeSegmentIcon={() => - removeSegmentGroupIcon( - groupIndex, - editor, - update, - ) - } - className={classes.segmentGroupHeaderIcon} - isPlaceholder={!row.has_explicit_icon} - canRemoveIcon={row.has_explicit_icon} - lang={lang} - /> - - - ); - } - - const s = row; - const segmentIndex = s.segment_index; - const segmentIcon = runEditorUrlCache.cache(s.icon); - const isSelected = - s.selected === "Selected" || s.selected === "Active"; - const rowClassName = [ - isSelected ? tableClasses.selected : "", - s.starts_new_section - ? classes.segmentGroupBoundary - : "", - ] - .filter(Boolean) - .join(" "); - return ( - - handleSegmentRowMouseDown( - e, - segmentIndex, - s.selected, - ) - } - > - - changeSegmentIcon( - segmentIndex, - editor, - maybeUpdate, - lang, - ) - } - removeSegmentIcon={() => - removeSegmentIcon( - segmentIndex, - editor, - update, - ) - } - lang={lang} - /> - - - - - {s.comparison_times.map( - (comparisonTime, comparisonIndex) => ( - - ), - )} - - ); - })} - -
{resolve(Label.Icon, lang)}{resolve(Label.SegmentName, lang)}{resolve(Label.SplitTime, lang)}{resolve(Label.SegmentTime, lang)}{resolve(Label.BestSegment, lang)}
- e.stopPropagation()} - onClick={(e) => e.stopPropagation()} - onFocus={(_) => - selectSegmentGroup( - editor, - groupIndex, - rowState, - setRowState, - update, - ) - } - onChange={(e) => { - editor.renameSegmentGroup( - groupIndex, - e.target.value, - ); - update(); - }} - /> -
- { - segmentNameInputRefs.current[ - segmentIndex - ] = element; - }} - value={s.name} - onClick={handleSegmentInputClick} - onMouseDown={(e) => - handleSegmentInputMouseDown( - e, - segmentIndex, - s.selected, - ) - } - onFocus={(_) => - focusSegment( - segmentIndex, - editor, - skipNextFocusedSegmentSelection, - rowState, - setRowState, - update, - ) - } - onChange={(e) => { - editor.activeSetName(e.target.value); - update(); - }} - /> - - - handleSegmentInputMouseDown( - e, - segmentIndex, - s.selected, - ) - } - onFocus={(_) => - focusSegment( - segmentIndex, - editor, - skipNextFocusedSegmentSelection, - rowState, - setRowState, - update, - ) - } - onChange={(e) => - setRowState({ - ...rowState, - splitTime: e.target.value, - splitTimeChanged: true, - }) - } - onBlur={(_) => - handleSplitTimeBlur( - editor, - rowState, - setRowState, - update, - lang, - ) - } - /> - - - handleSegmentInputMouseDown( - e, - segmentIndex, - s.selected, - ) - } - onFocus={(_) => - focusSegment( - segmentIndex, - editor, - skipNextFocusedSegmentSelection, - rowState, - setRowState, - update, - ) - } - onChange={(e) => - setRowState({ - ...rowState, - segmentTime: e.target.value, - segmentTimeChanged: true, - }) - } - onBlur={(_) => - handleSegmentTimeBlur( - editor, - rowState, - setRowState, - update, - lang, - ) - } - /> - - - handleSegmentInputMouseDown( - e, - segmentIndex, - s.selected, - ) - } - onFocus={(_) => - focusSegment( - segmentIndex, - editor, - skipNextFocusedSegmentSelection, - rowState, - setRowState, - update, - ) - } - onChange={(e) => - setRowState({ - ...rowState, - bestSegmentTime: e.target.value, - bestSegmentTimeChanged: true, - }) - } - onBlur={(_) => - handleBestSegmentTimeBlur( - editor, - rowState, - setRowState, - update, - lang, - ) - } - /> - - - handleSegmentInputMouseDown( - e, - segmentIndex, - s.selected, - ) - } - onFocus={(_) => - focusSegment( - segmentIndex, - editor, - skipNextFocusedSegmentSelection, - rowState, - setRowState, - update, - ) - } - onChange={(e) => { - const comparisonTimes = [ - ...rowState.comparisonTimes, - ]; - comparisonTimes[ - comparisonIndex - ] = e.target.value; - const comparisonTimesChanged = [ - ...rowState.comparisonTimesChanged, - ]; - comparisonTimesChanged[ - comparisonIndex - ] = true; - - setRowState({ - ...rowState, - comparisonTimes, - comparisonTimesChanged, - }); - }} - onBlur={(_) => - handleComparisonTimeBlur( - comparisonIndex, - editor, - editorState, - rowState, - setRowState, - update, - lang, - ) - } - /> -
- ); -} - function VariablesTab({ editor, editorState, @@ -2066,169 +1471,6 @@ function ComparisonsButton({ ); } -function SegmentIcon({ - segmentIcon, - changeSegmentIcon, - removeSegmentIcon, - className, - isPlaceholder = false, - canRemoveIcon = true, - lang, -}: { - segmentIcon: string | undefined; - changeSegmentIcon: () => void; - removeSegmentIcon: () => void; - className?: string; - isPlaceholder?: boolean; - canRemoveIcon?: boolean; - lang: LiveSplit.Language | undefined; -}) { - const [position, setPosition] = React.useState(null); - - return ( - { - if (position !== null) { - return; - } - if (segmentIcon !== undefined) { - setPosition({ x: e.clientX, y: e.clientY }); - } else { - changeSegmentIcon(); - } - }} - > - {segmentIcon !== undefined && ( - - )} - {position && ( - setPosition(null)} - > - - {resolve(Label.SetSegmentIcon, lang)} - - {resolve(Label.SetSegmentIconDescription, lang)} - - - {canRemoveIcon && ( - - {resolve(Label.RemoveSegmentIcon, lang)} - - {resolve( - Label.RemoveSegmentIconDescription, - lang, - )} - - - )} - - )} - - ); -} - -function CustomComparison({ - comparison, - onDragStart, - onDragEnd, - onDrop, - renameComparison, - copyComparison, - removeComparison, - lang, -}: { - comparison: string; - onDragStart: (e: React.DragEvent) => void; - onDragEnd: (e: React.DragEvent) => void; - onDrop: (e: React.DragEvent) => void; - renameComparison: () => void; - copyComparison: () => void; - removeComparison: () => void; - lang: LiveSplit.Language | undefined; -}) { - const [position, setPosition] = React.useState(null); - - return ( - { - if (position === null) { - setPosition({ x: e.clientX, y: e.clientY }); - } - }} - draggable - onDragStart={onDragStart} - onDragOver={(e) => { - if (e.preventDefault) { - e.preventDefault(); - } - e.dataTransfer.dropEffect = "move"; - }} - onDragEnd={onDragEnd} - onDrop={onDrop} - > - {comparison} - {position && ( - setPosition(null)} - > - - {resolve(Label.Rename, lang)} - - {resolve(Label.RenameDescription, lang)} - - - - {resolve(Label.CopyAction, lang)} - - {resolve(Label.CopyDescription, lang)} - - - - {resolve(Label.Remove, lang)} - - {resolve(Label.RemoveDescription, lang)} - - - - )} - - ); -} - function getCurrentCategoriesInfo( editorState: LiveSplit.RunEditorStateJson, lang: LiveSplit.Language | undefined, @@ -2842,276 +2084,6 @@ async function addCustomVariable( } } -function changeSegmentSelection( - event: - | React.MouseEvent - | React.MouseEvent, - index: number, - selectionState: SegmentSelectionState, - editor: LiveSplit.RunEditorRefMut, - rowState: RowState, - setRowState: (rowState: RowState) => void, - update: () => LiveSplit.RunEditorStateJson, -) { - if (event.shiftKey) { - editor.selectRange(index); - } else if (event.ctrlKey || event.metaKey) { - if (selectionState === "Selected") { - editor.unselect(index); - } else { - editor.selectAdditionally(index); - } - } else { - editor.selectOnly(index); - } - - const editorState = update(); - setFocusedSegmentRowState( - editorState, - getActiveSegmentIndex(editorState, index), - rowState, - setRowState, - ); -} - -function shouldPreserveCurrentFocus( - event: React.MouseEvent, - selectionState: SegmentSelectionState, -) { - return (event.ctrlKey || event.metaKey) && selectionState === "Selected"; -} - -function shouldFocusClickedRow( - event: React.MouseEvent, - selectionState: SegmentSelectionState, -) { - if (event.shiftKey) { - return true; - } - - if (event.ctrlKey || event.metaKey) { - return selectionState !== "Selected"; - } - - return true; -} - -function getSegmentRow( - editorState: LiveSplit.RunEditorStateJson, - segmentIndex: number, -): LiveSplit.RunEditorSegmentRowJson | undefined { - return editorState.rows.find( - (row): row is LiveSplit.RunEditorSegmentRowJson => - row.kind === "Segment" && row.segment_index === segmentIndex, - ); -} - -function getActiveSegmentIndex( - editorState: LiveSplit.RunEditorStateJson, - fallbackIndex: number, -) { - const activeSegment = editorState.rows.find( - (row): row is LiveSplit.RunEditorSegmentRowJson => - row.kind === "Segment" && row.selected === "Active", - ); - - return activeSegment?.segment_index ?? fallbackIndex; -} - -async function changeSegmentIcon( - index: number, - editor: LiveSplit.RunEditorRefMut, - maybeUpdate: () => void, - lang: LiveSplit.Language | undefined, -) { - editor.selectOnly(index); - const maybeFile = await openFileAsArrayBuffer(FILE_EXT_IMAGES); - if (maybeFile === undefined) { - return; - } - if (maybeFile instanceof Error) { - toast.error( - `${resolve(Label.FailedToReadFile, lang)} ${maybeFile.message}`, - ); - return; - } - const [file] = maybeFile; - // FIXME: Editor may not exist anymore if we close the view. Happens in - // other places too. - editor.activeSetIconFromArray(new Uint8Array(file)); - maybeUpdate(); -} - -function removeSegmentIcon( - index: number, - editor: LiveSplit.RunEditorRefMut, - update: () => void, -) { - editor.selectOnly(index); - editor.activeRemoveIcon(); - update(); -} - -async function changeSegmentGroupIcon( - groupIndex: number, - editor: LiveSplit.RunEditorRefMut, - maybeUpdate: () => void, - lang: LiveSplit.Language | undefined, -) { - const maybeFile = await openFileAsArrayBuffer(FILE_EXT_IMAGES); - if (maybeFile === undefined) { - return; - } - if (maybeFile instanceof Error) { - toast.error( - `${resolve(Label.FailedToReadFile, lang)} ${maybeFile.message}`, - ); - return; - } - const [file] = maybeFile; - if (editor.setSegmentGroupIconFromArray(groupIndex, new Uint8Array(file))) { - maybeUpdate(); - } -} - -function removeSegmentGroupIcon( - groupIndex: number, - editor: LiveSplit.RunEditorRefMut, - update: () => void, -) { - if (editor.removeSegmentGroupIcon(groupIndex)) { - update(); - } -} - -function focusSegment( - index: number, - editor: LiveSplit.RunEditorRefMut, - skipNextFocusedSegmentSelection: React.MutableRefObject, - rowState: RowState, - setRowState: (rowState: RowState) => void, - update: () => LiveSplit.RunEditorStateJson, -) { - // Mouse-based selection is handled on mousedown so modifier keys can change - // selection without the subsequent focus event collapsing it back to a - // single row. Keyboard focus still falls back to exclusive selection. - if (skipNextFocusedSegmentSelection.current) { - skipNextFocusedSegmentSelection.current = false; - const editorState = update(); - setFocusedSegmentRowState( - editorState, - getActiveSegmentIndex(editorState, index), - rowState, - setRowState, - ); - return; - } - - editor.selectOnly(index); - const editorState = update(); - setFocusedSegmentRowState( - editorState, - getActiveSegmentIndex(editorState, index), - rowState, - setRowState, - ); -} - -function setFocusedSegmentRowState( - editorState: LiveSplit.RunEditorStateJson, - index: number, - rowState: RowState, - setRowState: (rowState: RowState) => void, -) { - const segment = getSegmentRow(editorState, index); - if (segment === undefined) { - return; - } - const comparisonTimes = segment.comparison_times; - setRowState({ - ...rowState, - splitTimeChanged: false, - segmentTimeChanged: false, - bestSegmentTimeChanged: false, - comparisonTimes, - comparisonTimesChanged: comparisonTimes.map(() => false), - index, - }); -} - -function handleSplitTimeBlur( - editor: LiveSplit.RunEditorRefMut, - rowState: RowState, - setRowState: (rowState: RowState) => void, - update: () => void, - lang: LiveSplit.Language | undefined, -) { - if (rowState.splitTimeChanged) { - editor.activeParseAndSetSplitTime(rowState.splitTime, orAutoLang(lang)); - update(); - setRowState({ ...rowState, splitTimeChanged: false }); - } -} - -function handleSegmentTimeBlur( - editor: LiveSplit.RunEditorRefMut, - rowState: RowState, - setRowState: (rowState: RowState) => void, - update: () => void, - lang: LiveSplit.Language | undefined, -) { - if (rowState.segmentTimeChanged) { - editor.activeParseAndSetSegmentTime( - rowState.segmentTime, - orAutoLang(lang), - ); - update(); - setRowState({ ...rowState, segmentTimeChanged: false }); - } -} - -function handleBestSegmentTimeBlur( - editor: LiveSplit.RunEditorRefMut, - rowState: RowState, - setRowState: (rowState: RowState) => void, - update: () => void, - lang: LiveSplit.Language | undefined, -) { - if (rowState.bestSegmentTimeChanged) { - editor.activeParseAndSetBestSegmentTime( - rowState.bestSegmentTime, - orAutoLang(lang), - ); - update(); - setRowState({ ...rowState, bestSegmentTimeChanged: false }); - } -} - -function handleComparisonTimeBlur( - comparisonIndex: number, - editor: LiveSplit.RunEditorRefMut, - editorState: LiveSplit.RunEditorStateJson, - rowState: RowState, - setRowState: (rowState: RowState) => void, - update: () => void, - lang: LiveSplit.Language | undefined, -) { - if (rowState.comparisonTimesChanged[comparisonIndex]) { - const comparisonName = editorState.comparison_names[comparisonIndex]; - const comparisonTime = rowState.comparisonTimes[comparisonIndex]; - editor.activeParseAndSetComparisonTime( - comparisonName, - comparisonTime, - orAutoLang(lang), - ); - update(); - - const comparisonTimesChanged = [...rowState.comparisonTimesChanged]; - comparisonTimesChanged[comparisonIndex] = false; - setRowState({ ...rowState, comparisonTimesChanged }); - } -} - async function renameComparison( comparison: string, editor: LiveSplit.RunEditorRefMut, diff --git a/src/ui/views/RunEditor/SegmentTableCells.tsx b/src/ui/views/RunEditor/SegmentTableCells.tsx new file mode 100644 index 00000000..ec4949a0 --- /dev/null +++ b/src/ui/views/RunEditor/SegmentTableCells.tsx @@ -0,0 +1,239 @@ +import React from "react"; +import { toast } from "react-toastify"; + +import * as LiveSplit from "../../../livesplit-core"; +import { FILE_EXT_IMAGES, openFileAsArrayBuffer } from "../../../util/FileUtil"; +import { Label, resolve } from "../../../localization"; +import { ContextMenu, MenuItem, Position } from "../../components/ContextMenu"; + +import classes from "../../../css/RunEditor.module.css"; +import tooltipClasses from "../../../css/Tooltip.module.css"; + +export function SegmentIcon({ + segmentIcon, + changeSegmentIcon, + removeSegmentIcon, + className, + isPlaceholder = false, + canRemoveIcon = true, + lang, +}: { + segmentIcon: string | undefined; + changeSegmentIcon: () => void; + removeSegmentIcon: () => void; + className?: string; + isPlaceholder?: boolean; + canRemoveIcon?: boolean; + lang: LiveSplit.Language | undefined; +}) { + const [position, setPosition] = React.useState(null); + + return ( + { + if (position !== null) { + return; + } + if (segmentIcon !== undefined) { + setPosition({ x: e.clientX, y: e.clientY }); + } else { + changeSegmentIcon(); + } + }} + > + {segmentIcon !== undefined && ( + + )} + {position && ( + setPosition(null)} + > + + {resolve(Label.SetSegmentIcon, lang)} + + {resolve(Label.SetSegmentIconDescription, lang)} + + + {canRemoveIcon && ( + + {resolve(Label.RemoveSegmentIcon, lang)} + + {resolve( + Label.RemoveSegmentIconDescription, + lang, + )} + + + )} + + )} + + ); +} + +export function CustomComparison({ + comparison, + onDragStart, + onDragEnd, + onDrop, + renameComparison, + copyComparison, + removeComparison, + lang, +}: { + comparison: string; + onDragStart: (e: React.DragEvent) => void; + onDragEnd: (e: React.DragEvent) => void; + onDrop: (e: React.DragEvent) => void; + renameComparison: () => void; + copyComparison: () => void; + removeComparison: () => void; + lang: LiveSplit.Language | undefined; +}) { + const [position, setPosition] = React.useState(null); + + return ( + { + if (position === null) { + setPosition({ x: e.clientX, y: e.clientY }); + } + }} + draggable + onDragStart={onDragStart} + onDragOver={(e) => { + if (e.preventDefault) { + e.preventDefault(); + } + e.dataTransfer.dropEffect = "move"; + }} + onDragEnd={onDragEnd} + onDrop={onDrop} + > + {comparison} + {position && ( + setPosition(null)} + > + + {resolve(Label.Rename, lang)} + + {resolve(Label.RenameDescription, lang)} + + + + {resolve(Label.CopyAction, lang)} + + {resolve(Label.CopyDescription, lang)} + + + + {resolve(Label.Remove, lang)} + + {resolve(Label.RemoveDescription, lang)} + + + + )} + + ); +} + +export async function changeSegmentIcon( + index: number, + editor: LiveSplit.RunEditorRefMut, + maybeUpdate: () => void, + lang: LiveSplit.Language | undefined, +) { + editor.selectOnly(index); + const maybeFile = await openFileAsArrayBuffer(FILE_EXT_IMAGES); + if (maybeFile === undefined) { + return; + } + if (maybeFile instanceof Error) { + toast.error( + `${resolve(Label.FailedToReadFile, lang)} ${maybeFile.message}`, + ); + return; + } + const [file] = maybeFile; + // FIXME: Editor may not exist anymore if we close the view. Happens in + // other places too. + editor.activeSetIconFromArray(new Uint8Array(file)); + maybeUpdate(); +} + +export function removeSegmentIcon( + index: number, + editor: LiveSplit.RunEditorRefMut, + update: () => void, +) { + editor.selectOnly(index); + editor.activeRemoveIcon(); + update(); +} + +export async function changeSegmentGroupIcon( + groupIndex: number, + editor: LiveSplit.RunEditorRefMut, + maybeUpdate: () => void, + lang: LiveSplit.Language | undefined, +) { + const maybeFile = await openFileAsArrayBuffer(FILE_EXT_IMAGES); + if (maybeFile === undefined) { + return; + } + if (maybeFile instanceof Error) { + toast.error( + `${resolve(Label.FailedToReadFile, lang)} ${maybeFile.message}`, + ); + return; + } + const [file] = maybeFile; + if (editor.setSegmentGroupIconFromArray(groupIndex, new Uint8Array(file))) { + maybeUpdate(); + } +} + +export function removeSegmentGroupIcon( + groupIndex: number, + editor: LiveSplit.RunEditorRefMut, + update: () => void, +) { + if (editor.removeSegmentGroupIcon(groupIndex)) { + update(); + } +} diff --git a/src/ui/views/RunEditor/SegmentsTable.tsx b/src/ui/views/RunEditor/SegmentsTable.tsx new file mode 100644 index 00000000..86d97e8b --- /dev/null +++ b/src/ui/views/RunEditor/SegmentsTable.tsx @@ -0,0 +1,815 @@ +import React, { useRef, useState } from "react"; +import * as LiveSplit from "../../../livesplit-core"; +import { Label, orAutoLang, resolve } from "../../../localization"; +import { UrlCache } from "../../../util/UrlCache"; +import { + changeSegmentGroupIcon, + changeSegmentIcon, + CustomComparison, + removeSegmentGroupIcon, + removeSegmentIcon, + SegmentIcon, +} from "./SegmentTableCells"; + +import classes from "../../../css/RunEditor.module.css"; +import tableClasses from "../../../css/Table.module.css"; + +interface RowState { + splitTime: string; + splitTimeChanged: boolean; + segmentTime: string; + segmentTimeChanged: boolean; + bestSegmentTime: string; + bestSegmentTimeChanged: boolean; + comparisonTimes: string[]; + comparisonTimesChanged: boolean[]; + index: number; +} + +type SegmentSelectionState = LiveSplit.RunEditorSegmentRowJson["selected"]; + +function commitFocusedInputBeforeSelectionChange() { + const focusedElement = document.activeElement; + if (focusedElement instanceof HTMLInputElement) { + // The time editors deliberately commit their parsed value on blur. A + // group header prevents the browser's default mouse-down behavior so + // that the header itself never takes focus, which also suppresses the + // browser-generated blur. Trigger it explicitly while the old segment + // is still active; its handler runs synchronously and commits the edit + // before selecting the group's range resets the row-local draft state. + focusedElement.blur(); + } +} + +function selectSegmentGroup( + editor: LiveSplit.RunEditorRefMut, + groupIndex: number, + rowState: RowState, + setRowState: React.Dispatch>, + update: () => LiveSplit.RunEditorStateJson, +) { + if (!editor.selectSegmentGroup(groupIndex)) { + return; + } + + const state = update(); + setFocusedSegmentRowState( + state, + getActiveSegmentIndex(state, rowState.index), + rowState, + setRowState, + ); + return state; +} + +export function SegmentsTable({ + editor, + editorState, + runEditorUrlCache, + maybeUpdate, + update, + renameComparison, + copyComparison, + lang, +}: { + editor: LiveSplit.RunEditorRefMut; + editorState: LiveSplit.RunEditorStateJson; + runEditorUrlCache: UrlCache; + maybeUpdate: () => void; + update: () => LiveSplit.RunEditorStateJson; + renameComparison: (comparison: string) => void; + copyComparison: (comparison: string) => void; + lang: LiveSplit.Language | undefined; +}) { + const [dragIndex, setDragIndex] = useState(0); + const skipNextFocusedSegmentSelection = useRef(false); + const segmentNameInputRefs = useRef<(HTMLInputElement | null)[]>([]); + const [rowState, setRowState] = useState(() => ({ + bestSegmentTime: "", + bestSegmentTimeChanged: false, + comparisonTimes: [], + comparisonTimesChanged: [], + index: 0, + segmentTime: "", + segmentTimeChanged: false, + splitTime: "", + splitTimeChanged: false, + })); + + const handleSegmentInputMouseDown = ( + event: React.MouseEvent, + index: number, + selectionState: SegmentSelectionState, + ) => { + event.stopPropagation(); + + const preserveCurrentFocus = shouldPreserveCurrentFocus( + event, + selectionState, + ); + const focusClickedRow = shouldFocusClickedRow(event, selectionState); + + if (event.shiftKey || preserveCurrentFocus) { + event.preventDefault(); + } + + skipNextFocusedSegmentSelection.current = focusClickedRow; + + changeSegmentSelection( + event, + index, + selectionState, + editor, + rowState, + setRowState, + update, + ); + + if (event.shiftKey && focusClickedRow) { + event.currentTarget.focus(); + } + }; + + const handleSegmentInputClick = ( + event: React.MouseEvent, + ) => { + // Input selection is already handled on mousedown so the row's click + // handler must not run again on mouseup. + event.stopPropagation(); + }; + + const handleSegmentRowMouseDown = ( + event: React.MouseEvent, + index: number, + selectionState: SegmentSelectionState, + ) => { + const preserveCurrentFocus = shouldPreserveCurrentFocus( + event, + selectionState, + ); + const focusClickedRow = shouldFocusClickedRow(event, selectionState); + + if (event.shiftKey || preserveCurrentFocus) { + // Modifier-based selection should not trigger native text selection + // behavior or move focus away from the currently edited field. + event.preventDefault(); + } + + skipNextFocusedSegmentSelection.current = focusClickedRow; + + changeSegmentSelection( + event, + index, + selectionState, + editor, + rowState, + setRowState, + update, + ); + + if (focusClickedRow) { + segmentNameInputRefs.current[index]?.focus(); + } + }; + + const columnCount = 5 + editorState.comparison_names.length; + + return ( + + + + + + + + + {editorState.comparison_names.map( + (comparison, comparisonIndex) => { + return ( + { + e.dataTransfer.setData( + "text/plain", + "", + ); + setDragIndex(comparisonIndex); + }} + onDragEnd={(_) => update()} + onDrop={(e) => { + if (e.stopPropagation) { + e.stopPropagation(); + } + editor.moveComparison( + dragIndex, + comparisonIndex, + ); + // No update necessary, as we do it in onDragEnd. + return false; + }} + renameComparison={() => + renameComparison(comparison) + } + copyComparison={() => + copyComparison(comparison) + } + removeComparison={() => { + editor.removeComparison(comparison); + update(); + }} + lang={lang} + /> + ); + }, + )} + + + + {editorState.rows.map((row) => { + if (row.kind === "SegmentGroup") { + const groupIndex = row.group_index; + + return ( + { + commitFocusedInputBeforeSelectionChange(); + e.preventDefault(); + selectSegmentGroup( + editor, + groupIndex, + rowState, + setRowState, + update, + ); + }} + > + + changeSegmentGroupIcon( + groupIndex, + editor, + maybeUpdate, + lang, + ) + } + removeSegmentIcon={() => + removeSegmentGroupIcon( + groupIndex, + editor, + update, + ) + } + className={classes.segmentGroupHeaderIcon} + isPlaceholder={!row.has_explicit_icon} + canRemoveIcon={row.has_explicit_icon} + lang={lang} + /> + + + ); + } + + const s = row; + const segmentIndex = s.segment_index; + const segmentIcon = runEditorUrlCache.cache(s.icon); + const isSelected = + s.selected === "Selected" || s.selected === "Active"; + const rowClassName = [ + isSelected ? tableClasses.selected : "", + s.starts_new_section + ? classes.segmentGroupBoundary + : "", + ] + .filter(Boolean) + .join(" "); + return ( + + handleSegmentRowMouseDown( + e, + segmentIndex, + s.selected, + ) + } + > + + changeSegmentIcon( + segmentIndex, + editor, + maybeUpdate, + lang, + ) + } + removeSegmentIcon={() => + removeSegmentIcon( + segmentIndex, + editor, + update, + ) + } + lang={lang} + /> + + + + + {s.comparison_times.map( + (comparisonTime, comparisonIndex) => ( + + ), + )} + + ); + })} + +
{resolve(Label.Icon, lang)}{resolve(Label.SegmentName, lang)}{resolve(Label.SplitTime, lang)}{resolve(Label.SegmentTime, lang)}{resolve(Label.BestSegment, lang)}
+ e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + onFocus={(_) => + selectSegmentGroup( + editor, + groupIndex, + rowState, + setRowState, + update, + ) + } + onChange={(e) => { + editor.renameSegmentGroup( + groupIndex, + e.target.value, + ); + update(); + }} + /> +
+ { + segmentNameInputRefs.current[ + segmentIndex + ] = element; + }} + value={s.name} + onClick={handleSegmentInputClick} + onMouseDown={(e) => + handleSegmentInputMouseDown( + e, + segmentIndex, + s.selected, + ) + } + onFocus={(_) => + focusSegment( + segmentIndex, + editor, + skipNextFocusedSegmentSelection, + rowState, + setRowState, + update, + ) + } + onChange={(e) => { + editor.activeSetName(e.target.value); + update(); + }} + /> + + + handleSegmentInputMouseDown( + e, + segmentIndex, + s.selected, + ) + } + onFocus={(_) => + focusSegment( + segmentIndex, + editor, + skipNextFocusedSegmentSelection, + rowState, + setRowState, + update, + ) + } + onChange={(e) => + setRowState({ + ...rowState, + splitTime: e.target.value, + splitTimeChanged: true, + }) + } + onBlur={(_) => + handleSplitTimeBlur( + editor, + rowState, + setRowState, + update, + lang, + ) + } + /> + + + handleSegmentInputMouseDown( + e, + segmentIndex, + s.selected, + ) + } + onFocus={(_) => + focusSegment( + segmentIndex, + editor, + skipNextFocusedSegmentSelection, + rowState, + setRowState, + update, + ) + } + onChange={(e) => + setRowState({ + ...rowState, + segmentTime: e.target.value, + segmentTimeChanged: true, + }) + } + onBlur={(_) => + handleSegmentTimeBlur( + editor, + rowState, + setRowState, + update, + lang, + ) + } + /> + + + handleSegmentInputMouseDown( + e, + segmentIndex, + s.selected, + ) + } + onFocus={(_) => + focusSegment( + segmentIndex, + editor, + skipNextFocusedSegmentSelection, + rowState, + setRowState, + update, + ) + } + onChange={(e) => + setRowState({ + ...rowState, + bestSegmentTime: e.target.value, + bestSegmentTimeChanged: true, + }) + } + onBlur={(_) => + handleBestSegmentTimeBlur( + editor, + rowState, + setRowState, + update, + lang, + ) + } + /> + + + handleSegmentInputMouseDown( + e, + segmentIndex, + s.selected, + ) + } + onFocus={(_) => + focusSegment( + segmentIndex, + editor, + skipNextFocusedSegmentSelection, + rowState, + setRowState, + update, + ) + } + onChange={(e) => { + const comparisonTimes = [ + ...rowState.comparisonTimes, + ]; + comparisonTimes[ + comparisonIndex + ] = e.target.value; + const comparisonTimesChanged = [ + ...rowState.comparisonTimesChanged, + ]; + comparisonTimesChanged[ + comparisonIndex + ] = true; + + setRowState({ + ...rowState, + comparisonTimes, + comparisonTimesChanged, + }); + }} + onBlur={(_) => + handleComparisonTimeBlur( + comparisonIndex, + editor, + editorState, + rowState, + setRowState, + update, + lang, + ) + } + /> +
+ ); +} + +function changeSegmentSelection( + event: + | React.MouseEvent + | React.MouseEvent, + index: number, + selectionState: SegmentSelectionState, + editor: LiveSplit.RunEditorRefMut, + rowState: RowState, + setRowState: (rowState: RowState) => void, + update: () => LiveSplit.RunEditorStateJson, +) { + if (event.shiftKey) { + editor.selectRange(index); + } else if (event.ctrlKey || event.metaKey) { + if (selectionState === "Selected") { + editor.unselect(index); + } else { + editor.selectAdditionally(index); + } + } else { + editor.selectOnly(index); + } + + const editorState = update(); + setFocusedSegmentRowState( + editorState, + getActiveSegmentIndex(editorState, index), + rowState, + setRowState, + ); +} + +function shouldPreserveCurrentFocus( + event: React.MouseEvent, + selectionState: SegmentSelectionState, +) { + return (event.ctrlKey || event.metaKey) && selectionState === "Selected"; +} + +function shouldFocusClickedRow( + event: React.MouseEvent, + selectionState: SegmentSelectionState, +) { + if (event.shiftKey) { + return true; + } + + if (event.ctrlKey || event.metaKey) { + return selectionState !== "Selected"; + } + + return true; +} + +function getSegmentRow( + editorState: LiveSplit.RunEditorStateJson, + segmentIndex: number, +): LiveSplit.RunEditorSegmentRowJson | undefined { + return editorState.rows.find( + (row): row is LiveSplit.RunEditorSegmentRowJson => + row.kind === "Segment" && row.segment_index === segmentIndex, + ); +} + +function getActiveSegmentIndex( + editorState: LiveSplit.RunEditorStateJson, + fallbackIndex: number, +) { + const activeSegment = editorState.rows.find( + (row): row is LiveSplit.RunEditorSegmentRowJson => + row.kind === "Segment" && row.selected === "Active", + ); + + return activeSegment?.segment_index ?? fallbackIndex; +} + +function focusSegment( + index: number, + editor: LiveSplit.RunEditorRefMut, + skipNextFocusedSegmentSelection: React.MutableRefObject, + rowState: RowState, + setRowState: (rowState: RowState) => void, + update: () => LiveSplit.RunEditorStateJson, +) { + // Mouse-based selection is handled on mousedown so modifier keys can change + // selection without the subsequent focus event collapsing it back to a + // single row. Keyboard focus still falls back to exclusive selection. + if (skipNextFocusedSegmentSelection.current) { + skipNextFocusedSegmentSelection.current = false; + const editorState = update(); + setFocusedSegmentRowState( + editorState, + getActiveSegmentIndex(editorState, index), + rowState, + setRowState, + ); + return; + } + + editor.selectOnly(index); + const editorState = update(); + setFocusedSegmentRowState( + editorState, + getActiveSegmentIndex(editorState, index), + rowState, + setRowState, + ); +} + +function setFocusedSegmentRowState( + editorState: LiveSplit.RunEditorStateJson, + index: number, + rowState: RowState, + setRowState: (rowState: RowState) => void, +) { + const segment = getSegmentRow(editorState, index); + if (segment === undefined) { + return; + } + const comparisonTimes = segment.comparison_times; + setRowState({ + ...rowState, + splitTimeChanged: false, + segmentTimeChanged: false, + bestSegmentTimeChanged: false, + comparisonTimes, + comparisonTimesChanged: comparisonTimes.map(() => false), + index, + }); +} + +function handleSplitTimeBlur( + editor: LiveSplit.RunEditorRefMut, + rowState: RowState, + setRowState: (rowState: RowState) => void, + update: () => void, + lang: LiveSplit.Language | undefined, +) { + if (rowState.splitTimeChanged) { + editor.activeParseAndSetSplitTime(rowState.splitTime, orAutoLang(lang)); + update(); + setRowState({ ...rowState, splitTimeChanged: false }); + } +} + +function handleSegmentTimeBlur( + editor: LiveSplit.RunEditorRefMut, + rowState: RowState, + setRowState: (rowState: RowState) => void, + update: () => void, + lang: LiveSplit.Language | undefined, +) { + if (rowState.segmentTimeChanged) { + editor.activeParseAndSetSegmentTime( + rowState.segmentTime, + orAutoLang(lang), + ); + update(); + setRowState({ ...rowState, segmentTimeChanged: false }); + } +} + +function handleBestSegmentTimeBlur( + editor: LiveSplit.RunEditorRefMut, + rowState: RowState, + setRowState: (rowState: RowState) => void, + update: () => void, + lang: LiveSplit.Language | undefined, +) { + if (rowState.bestSegmentTimeChanged) { + editor.activeParseAndSetBestSegmentTime( + rowState.bestSegmentTime, + orAutoLang(lang), + ); + update(); + setRowState({ ...rowState, bestSegmentTimeChanged: false }); + } +} + +function handleComparisonTimeBlur( + comparisonIndex: number, + editor: LiveSplit.RunEditorRefMut, + editorState: LiveSplit.RunEditorStateJson, + rowState: RowState, + setRowState: (rowState: RowState) => void, + update: () => void, + lang: LiveSplit.Language | undefined, +) { + if (rowState.comparisonTimesChanged[comparisonIndex]) { + const comparisonName = editorState.comparison_names[comparisonIndex]; + const comparisonTime = rowState.comparisonTimes[comparisonIndex]; + editor.activeParseAndSetComparisonTime( + comparisonName, + comparisonTime, + orAutoLang(lang), + ); + update(); + + const comparisonTimesChanged = [...rowState.comparisonTimesChanged]; + comparisonTimesChanged[comparisonIndex] = false; + setRowState({ ...rowState, comparisonTimesChanged }); + } +} From b832bdab1f7a3da7601cc566a0b89a0a42c4bc34 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Wed, 22 Jul 2026 12:17:08 +0200 Subject: [PATCH 28/31] Fix Segment Group Selection Interactions Expire mouse-focus suppression after its originating event so later keyboard navigation always activates the focused segment. Route Ctrl, Command, and Shift group-header selection through livesplit-core so complete group selection follows the same backend rules in every frontend. --- livesplit-core | 2 +- src/ui/views/RunEditor/SegmentsTable.tsx | 70 ++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/livesplit-core b/livesplit-core index b282e3d6..2a0fd71e 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit b282e3d6d683146f36b97cab3c40247f25ec2c54 +Subproject commit 2a0fd71e5ba22aa010bf75e5b3d91d9061015d2a diff --git a/src/ui/views/RunEditor/SegmentsTable.tsx b/src/ui/views/RunEditor/SegmentsTable.tsx index 86d97e8b..d2fd8c38 100644 --- a/src/ui/views/RunEditor/SegmentsTable.tsx +++ b/src/ui/views/RunEditor/SegmentsTable.tsx @@ -27,6 +27,7 @@ interface RowState { } type SegmentSelectionState = LiveSplit.RunEditorSegmentRowJson["selected"]; +type SegmentGroupSelectionMode = "Exclusive" | "Toggle" | "Range"; function commitFocusedInputBeforeSelectionChange() { const focusedElement = document.activeElement; @@ -44,11 +45,21 @@ function commitFocusedInputBeforeSelectionChange() { function selectSegmentGroup( editor: LiveSplit.RunEditorRefMut, groupIndex: number, + selectionMode: SegmentGroupSelectionMode, rowState: RowState, setRowState: React.Dispatch>, update: () => LiveSplit.RunEditorStateJson, ) { - if (!editor.selectSegmentGroup(groupIndex)) { + // Group membership and the Run Editor's non-empty-selection invariant are + // backend concerns. Dispatch the user's selection intent by group identity + // instead of reconstructing or mutating the group's segment range here. + const selectionSucceeded = + selectionMode === "Range" + ? editor.selectSegmentGroupRange(groupIndex) + : selectionMode === "Toggle" + ? editor.toggleSegmentGroupSelection(groupIndex) + : editor.selectSegmentGroup(groupIndex); + if (!selectionSucceeded) { return; } @@ -62,6 +73,18 @@ function selectSegmentGroup( return state; } +function segmentGroupSelectionMode( + event: React.MouseEvent, +): SegmentGroupSelectionMode { + if (event.shiftKey) { + return "Range"; + } + if (event.ctrlKey || event.metaKey) { + return "Toggle"; + } + return "Exclusive"; +} + export function SegmentsTable({ editor, editorState, @@ -96,6 +119,17 @@ export function SegmentsTable({ splitTimeChanged: false, })); + const expectMouseGeneratedSegmentFocus = () => { + skipNextFocusedSegmentSelection.current = true; + // Focus caused by this mouse-down is delivered before the next event + // loop task. Expire the token afterwards so clicking an input that was + // already focused cannot leave it behind for a later Tab / Shift+Tab + // navigation and make that newly focused row edit the wrong segment. + window.setTimeout(() => { + skipNextFocusedSegmentSelection.current = false; + }, 0); + }; + const handleSegmentInputMouseDown = ( event: React.MouseEvent, index: number, @@ -113,7 +147,11 @@ export function SegmentsTable({ event.preventDefault(); } - skipNextFocusedSegmentSelection.current = focusClickedRow; + if (focusClickedRow) { + expectMouseGeneratedSegmentFocus(); + } else { + skipNextFocusedSegmentSelection.current = false; + } changeSegmentSelection( event, @@ -155,7 +193,11 @@ export function SegmentsTable({ event.preventDefault(); } - skipNextFocusedSegmentSelection.current = focusClickedRow; + if (focusClickedRow) { + expectMouseGeneratedSegmentFocus(); + } else { + skipNextFocusedSegmentSelection.current = false; + } changeSegmentSelection( event, @@ -241,9 +283,17 @@ export function SegmentsTable({ onMouseDown={(e) => { commitFocusedInputBeforeSelectionChange(); e.preventDefault(); + // A pending token only describes the focus + // generated by a segment input's own mouse + // down. A group-header interaction starts a + // new selection operation, so it must not + // affect whichever segment receives focus + // later through keyboard navigation. + skipNextFocusedSegmentSelection.current = false; selectSegmentGroup( editor, groupIndex, + segmentGroupSelectionMode(e), rowState, setRowState, update, @@ -282,15 +332,23 @@ export function SegmentsTable({ placeholder={row.name} onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} - onFocus={(_) => + onFocus={(_) => { + // Focusing the editable header is + // not the follow-up focus event of + // a segment mouse-down. Expire any + // unconsumed token before selecting + // the group so Tab cannot skip the + // next segment's selection. + skipNextFocusedSegmentSelection.current = false; selectSegmentGroup( editor, groupIndex, + "Exclusive", rowState, setRowState, update, - ) - } + ); + }} onChange={(e) => { editor.renameSegmentGroup( groupIndex, From f2cbece40c2ac15fb6a27995af463abcd299c32e Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Wed, 22 Jul 2026 14:38:37 +0200 Subject: [PATCH 29/31] Update livesplit-core Gap Separators Advance livesplit-core to the generalized gap-separator setting. The frontend settings UI now receives the accurate localized label and description while existing serialized layouts continue loading through the core compatibility alias. --- livesplit-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/livesplit-core b/livesplit-core index 2a0fd71e..6f85290d 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit 2a0fd71e5ba22aa010bf75e5b3d91d9061015d2a +Subproject commit 6f85290dace6ea5ad81b28405c6400243a31b14f From 800c0d4f9bbbab00d680a0403b31e3ef1267d8ee Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Wed, 22 Jul 2026 14:49:12 +0200 Subject: [PATCH 30/31] Honor Group Header Textbox Modifiers Handle group-header textbox selection on mouse-down just like segment textboxes, while suppressing only the focus event that will actually occur. This preserves Ctrl, Command, and Shift selection without a timer or stale focus token, keeps keyboard focus exclusively selecting its group, and prevents a successfully deselected group from retaining an editable textbox. --- src/ui/views/RunEditor/SegmentsTable.tsx | 133 +++++++++++++++-------- 1 file changed, 87 insertions(+), 46 deletions(-) diff --git a/src/ui/views/RunEditor/SegmentsTable.tsx b/src/ui/views/RunEditor/SegmentsTable.tsx index d2fd8c38..5bd78d17 100644 --- a/src/ui/views/RunEditor/SegmentsTable.tsx +++ b/src/ui/views/RunEditor/SegmentsTable.tsx @@ -105,7 +105,7 @@ export function SegmentsTable({ lang: LiveSplit.Language | undefined; }) { const [dragIndex, setDragIndex] = useState(0); - const skipNextFocusedSegmentSelection = useRef(false); + const skipNextFocusedSelection = useRef(false); const segmentNameInputRefs = useRef<(HTMLInputElement | null)[]>([]); const [rowState, setRowState] = useState(() => ({ bestSegmentTime: "", @@ -119,17 +119,6 @@ export function SegmentsTable({ splitTimeChanged: false, })); - const expectMouseGeneratedSegmentFocus = () => { - skipNextFocusedSegmentSelection.current = true; - // Focus caused by this mouse-down is delivered before the next event - // loop task. Expire the token afterwards so clicking an input that was - // already focused cannot leave it behind for a later Tab / Shift+Tab - // navigation and make that newly focused row edit the wrong segment. - window.setTimeout(() => { - skipNextFocusedSegmentSelection.current = false; - }, 0); - }; - const handleSegmentInputMouseDown = ( event: React.MouseEvent, index: number, @@ -147,11 +136,11 @@ export function SegmentsTable({ event.preventDefault(); } - if (focusClickedRow) { - expectMouseGeneratedSegmentFocus(); - } else { - skipNextFocusedSegmentSelection.current = false; - } + // Only suppress a focus event that will actually occur. An already + // focused input does not emit another focus event, so leaving a token + // behind here would make later keyboard navigation skip selection. + skipNextFocusedSelection.current = + focusClickedRow && document.activeElement !== event.currentTarget; changeSegmentSelection( event, @@ -193,11 +182,9 @@ export function SegmentsTable({ event.preventDefault(); } - if (focusClickedRow) { - expectMouseGeneratedSegmentFocus(); - } else { - skipNextFocusedSegmentSelection.current = false; - } + const segmentNameInput = segmentNameInputRefs.current[index]; + skipNextFocusedSelection.current = + focusClickedRow && document.activeElement !== segmentNameInput; changeSegmentSelection( event, @@ -210,7 +197,7 @@ export function SegmentsTable({ ); if (focusClickedRow) { - segmentNameInputRefs.current[index]?.focus(); + segmentNameInput?.focus(); } }; @@ -283,13 +270,11 @@ export function SegmentsTable({ onMouseDown={(e) => { commitFocusedInputBeforeSelectionChange(); e.preventDefault(); - // A pending token only describes the focus - // generated by a segment input's own mouse - // down. A group-header interaction starts a - // new selection operation, so it must not - // affect whichever segment receives focus - // later through keyboard navigation. - skipNextFocusedSegmentSelection.current = false; + // A pending token belongs to a textbox + // mouse-down. The header background prevents + // focus and starts a new selection operation, + // so it must not affect later keyboard focus. + skipNextFocusedSelection.current = false; selectSegmentGroup( editor, groupIndex, @@ -330,16 +315,72 @@ export function SegmentsTable({ type="text" value={row.explicit_name ?? ""} placeholder={row.name} - onMouseDown={(e) => e.stopPropagation()} + onMouseDown={(e) => { + // The input stops propagation to + // retain normal caret placement, + // so it needs to dispatch the same + // modifier-aware selection as the + // surrounding header itself. Blur + // first to commit a pending time + // edit while its segment is active. + commitFocusedInputBeforeSelectionChange(); + e.stopPropagation(); + + const selectionMode = + segmentGroupSelectionMode(e); + const state = selectSegmentGroup( + editor, + groupIndex, + selectionMode, + rowState, + setRowState, + update, + ); + // Toggling the sole selected group + // succeeds but intentionally leaves + // it selected, matching the current + // segment's non-empty-selection rule. + // Use the backend-produced state + // instead of inferring the result + // from the requested operation. + const groupIsStillSelected = + state?.rows.some( + (updatedRow) => + updatedRow.kind === + "SegmentGroup" && + updatedRow.group_index === + groupIndex && + updatedRow.selected, + ) ?? row.selected; + const groupWasDeselected = + row.selected && + !groupIsStillSelected; + + // A deselected group must not retain + // an editable input. Blurring above + // commits any pending edit, while + // preventing the default action + // keeps the mouse-down from focusing + // the textbox again afterwards. + if (groupWasDeselected) { + e.preventDefault(); + } + skipNextFocusedSelection.current = + !groupWasDeselected && + document.activeElement !== + e.currentTarget; + }} onClick={(e) => e.stopPropagation()} onFocus={(_) => { - // Focusing the editable header is - // not the follow-up focus event of - // a segment mouse-down. Expire any - // unconsumed token before selecting - // the group so Tab cannot skip the - // next segment's selection. - skipNextFocusedSegmentSelection.current = false; + if ( + skipNextFocusedSelection.current + ) { + skipNextFocusedSelection.current = false; + return; + } + // Keyboard focus has no modifier + // selection to preserve, so it + // activates this group exclusively. selectSegmentGroup( editor, groupIndex, @@ -434,7 +475,7 @@ export function SegmentsTable({ focusSegment( segmentIndex, editor, - skipNextFocusedSegmentSelection, + skipNextFocusedSelection, rowState, setRowState, update, @@ -468,7 +509,7 @@ export function SegmentsTable({ focusSegment( segmentIndex, editor, - skipNextFocusedSegmentSelection, + skipNextFocusedSelection, rowState, setRowState, update, @@ -520,7 +561,7 @@ export function SegmentsTable({ focusSegment( segmentIndex, editor, - skipNextFocusedSegmentSelection, + skipNextFocusedSelection, rowState, setRowState, update, @@ -566,7 +607,7 @@ export function SegmentsTable({ focusSegment( segmentIndex, editor, - skipNextFocusedSegmentSelection, + skipNextFocusedSelection, rowState, setRowState, update, @@ -619,7 +660,7 @@ export function SegmentsTable({ focusSegment( segmentIndex, editor, - skipNextFocusedSegmentSelection, + skipNextFocusedSelection, rowState, setRowState, update, @@ -747,7 +788,7 @@ function getActiveSegmentIndex( function focusSegment( index: number, editor: LiveSplit.RunEditorRefMut, - skipNextFocusedSegmentSelection: React.MutableRefObject, + skipNextFocusedSelection: React.MutableRefObject, rowState: RowState, setRowState: (rowState: RowState) => void, update: () => LiveSplit.RunEditorStateJson, @@ -755,8 +796,8 @@ function focusSegment( // Mouse-based selection is handled on mousedown so modifier keys can change // selection without the subsequent focus event collapsing it back to a // single row. Keyboard focus still falls back to exclusive selection. - if (skipNextFocusedSegmentSelection.current) { - skipNextFocusedSegmentSelection.current = false; + if (skipNextFocusedSelection.current) { + skipNextFocusedSelection.current = false; const editorState = update(); setFocusedSegmentRowState( editorState, From b316e0c4d2dac843bff2ae8ab86c5b5d0e445145 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Wed, 22 Jul 2026 19:14:15 +0200 Subject: [PATCH 31/31] Release Native Subsplit Support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This releases native subsplits throughout LiveSplit One. The updated `livesplit-core` submodule owns segment group parsing, serialization, editing operations, and the presentation rows shared by all frontends. The Run Editor now consumes that backend model to create, rename, select, reorder, and remove groups without reconstructing group membership in TypeScript. The Splits component can present runs flat, expand the current group, or expand every group. Group headers, scrolling, indentation, section boundaries, and gap separators are likewise derived from core so the web frontend stays aligned with other current and future LiveSplit frontends. Changelog (en): Subsplits are here! Organize detailed segments under clear group headers while keeping your split list focused on the group you are currently running. Open Edit Splits, select consecutive segments, and choose Create Group. The Splits component expands the current group automatically. Want a different view? Use Subsplit Display Mode in the Layout Editor to show a flat list or keep every group expanded. Existing splits and layouts that use subsplits are supported automatically. Changelog (de): Subsplits sind da! Organisiere detaillierte Segmente unter übersichtlichen Gruppenüberschriften, während sich deine Split-Liste auf die aktuell laufende Gruppe konzentriert. Öffne „Splits bearbeiten“, markiere aufeinanderfolgende Segmente und wähle „Gruppe erstellen“ – die Splits-Komponente zeigt die aktuelle Gruppe automatisch erweitert an. Du möchtest eine andere Ansicht? Über den „Subsplit-Anzeigemodus“ im Layout-Editor kannst du eine flache Liste oder alle Gruppen erweitert anzeigen. Vorhandene Splits und Layouts mit Subsplits werden automatisch unterstützt. Changelog (fr): Les subsplits sont arrivés ! Organisez vos segments détaillés sous des en-têtes de groupe clairs tout en gardant la liste centrée sur le groupe en cours. Ouvrez l'éditeur de splits, sélectionnez des segments consécutifs, puis choisissez « Créer un groupe » : le composant Splits déplie automatiquement le groupe actuel. Vous préférez une autre vue ? Utilisez le « Mode d'affichage des subsplits » dans l'éditeur de layout pour afficher une liste à plat ou garder tous les groupes dépliés. Les splits et layouts existants utilisant des subsplits sont automatiquement pris en charge. Changelog (nl): Subsplits zijn er! Orden gedetailleerde segmenten onder duidelijke groepskoppen terwijl je splitslijst op de huidige groep gericht blijft. Open de splitseditor, selecteer opeenvolgende segmenten en kies ‘Groep aanmaken’; het onderdeel Splits klapt de huidige groep automatisch uit. Wil je een andere weergave? Gebruik ‘Weergavemodus voor subsplits’ in de lay-outeditor om een platte lijst te tonen of alle groepen uitgeklapt te houden. Bestaande splits en lay-outs met subsplits worden automatisch ondersteund. Changelog (es): ¡Ya están aquí los subsplits! Organiza los segmentos detallados bajo encabezados de grupo claros mientras la lista de splits se centra en el grupo actual. Abre el editor de splits, selecciona segmentos consecutivos y elige «Crear grupo»; el componente Splits expande automáticamente el grupo actual. ¿Prefieres otra vista? Usa el «Modo de visualización de subsplits» en el editor de diseño para mostrar una lista plana o mantener expandidos todos los grupos. Los splits y diseños existentes que usan subsplits se admiten automáticamente. Changelog (it): Sono arrivati i subsplit! Organizza i segmenti dettagliati sotto intestazioni di gruppo chiare, mantenendo l'elenco degli split incentrato sul gruppo attuale. Apri l'editor degli split, seleziona segmenti consecutivi e scegli «Crea gruppo»: il componente Splits espande automaticamente il gruppo attuale. Preferisci un'altra visualizzazione? Usa la «Modalità di visualizzazione dei subsplit» nell'editor del layout per mostrare un elenco piatto o mantenere espansi tutti i gruppi. Gli split e i layout esistenti che usano i subsplit sono supportati automaticamente. Changelog (pt): Os subsplits chegaram! Organiza os segmentos detalhados sob cabeçalhos de grupo claros, mantendo a lista de splits focada no grupo atual. Abre o editor de splits, seleciona segmentos consecutivos e escolhe «Criar grupo»; o componente Splits expande automaticamente o grupo atual. Preferes outra vista? Usa o «Modo de exibição dos subsplits» no editor de layout para mostrar uma lista plana ou manter todos os grupos expandidos. Os splits e layouts existentes que usam subsplits são suportados automaticamente. Changelog (pt-BR): Os subsplits chegaram! Organize os segmentos detalhados sob cabeçalhos de grupo claros, mantendo a lista de splits focada no grupo atual. Abra o editor de splits, selecione segmentos consecutivos e escolha «Criar grupo»; o componente Splits expande automaticamente o grupo atual. Prefere outra visualização? Use o «Modo de exibição dos subsplits» no editor de layout para mostrar uma lista plana ou manter todos os grupos expandidos. Os splits e layouts existentes que usam subsplits são compatíveis automaticamente. Changelog (pl): Subsplity już są! Uporządkuj szczegółowe segmenty pod czytelnymi nagłówkami grup, pozostawiając listę splitów skupioną na bieżącej grupie. Otwórz edytor splitów, zaznacz kolejne segmenty i wybierz „Utwórz grupę”. Komponent Splits automatycznie rozwinie bieżącą grupę. Wolisz inny widok? Użyj opcji „Tryb wyświetlania podziałów podrzędnych” w edytorze układu, aby wyświetlić płaską listę lub pozostawić wszystkie grupy rozwinięte. Istniejące splity i układy korzystające z subsplitów są obsługiwane automatycznie. Changelog (ru): Подсегменты уже здесь! Организуйте подробные сегменты под понятными заголовками групп, сохраняя внимание списка сплитов на текущей группе. Откройте редактор сплитов, выделите последовательные сегменты и выберите «Создать группу». Компонент Splits автоматически раскроет текущую группу. Нужен другой вид? В редакторе раскладки используйте «Режим отображения подсегментов», чтобы показать плоский список или оставить все группы раскрытыми. Существующие сплиты и раскладки с подсегментами поддерживаются автоматически. Changelog (ja): サブスプリットが登場しました!詳細なセグメントを分かりやすい グループ見出しの下にまとめ、スプリット一覧を現在のグループに集中させられます。 スプリットエディタを開き、連続するセグメントを選択して「グループを作成」を選ぶだけで、 Splits コンポーネントが現在のグループを自動的に展開します。別の表示にしたい場合は、 レイアウトエディタの「サブスプリット表示モード」で、フラットな一覧またはすべての グループを展開する表示を選べます。サブスプリットを使用する既存のスプリットとレイアウトにも自動的に対応します。 Changelog (ko): 이제 하위 스플릿을 사용할 수 있습니다! 세부 세그먼트를 명확한 그룹 머리글 아래에 정리하면서 스플릿 목록이 현재 그룹에 집중되도록 할 수 있습니다. 스플릿 편집기를 열고 연속된 세그먼트를 선택한 다음 «그룹 만들기»를 선택하면 Splits 컴포넌트가 현재 그룹을 자동으로 펼칩니다. 다른 보기를 원하시나요? 레이아웃 편집기의 «하위 스플릿 표시 모드»에서 플랫 목록을 표시하거나 모든 그룹을 펼친 상태로 유지할 수 있습니다. 하위 스플릿을 사용하는 기존 스플릿과 레이아웃도 자동으로 지원됩니다. Changelog (zh-Hans): 子分段功能现已上线!将详细分段整理到清晰的分组标题下,让分段 列表始终聚焦于当前分组。打开分段编辑器,选中连续的分段并选择“建立群组”,Splits 组件便会自动展开当前分组。想使用其他显示方式?可在布局编辑器的“子分段显示模式”中 选择平铺列表或始终展开全部分组。已有使用子分段的分段和布局也会自动获得支持。 Changelog (zh-Hant): 子分段功能現已推出!將詳細分段整理到清楚的群組標題下,讓分段 清單持續聚焦於目前群組。開啟分段編輯器,選取連續的分段並選擇「建立群組」,Splits 元件便會自動展開目前群組。想使用其他顯示方式?可在版面配置編輯器的「子分段顯示 模式」中選擇平面清單或持續展開所有群組。現有使用子分段的分段和版面配置也會自動獲得支援。 --- livesplit-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/livesplit-core b/livesplit-core index 6f85290d..99c6fb59 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit 6f85290dace6ea5ad81b28405c6400243a31b14f +Subproject commit 99c6fb5973f486bc546f082ca3a3f0ea9a8b7a5a