diff --git a/livesplit-core b/livesplit-core index cb5f1368..99c6fb59 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit cb5f1368eb6cabe1e5c85c27a152112329fc5879 +Subproject commit 99c6fb5973f486bc546f082ca3a3f0ea9a8b7a5a diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 35330307..7235c54c 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,10 +2127,11 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "sha2", + "sha2 0.11.0", "simdutf8", "slab", "smallstr", + "smallvec", "snafu", "time", "tiny-skia", @@ -2087,14 +2140,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 +2373,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 +3821,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 +3914,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 +4322,7 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "syn 2.0.117", "tauri-utils", "thiserror 2.0.18", @@ -4899,9 +4963,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 +6036,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..f2f873f3 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 { @@ -179,6 +192,45 @@ } } +.segmentGroupHeader { + td { + border-top: 1px solid var(--border-color); + font-weight: bold; + } + + td.segmentIconContainer { + display: table-cell; + height: auto; + text-align: center; + vertical-align: middle; + } +} + +.segmentGroupBoundary { + td { + border-top: 1px solid var(--border-color); + } +} + +.segmentGroupHeaderInput { + display: block; + font-weight: bold; + width: 100%; + + &::placeholder { + color: currentColor; + opacity: 0.65; + } +} + +.placeholderSegmentIcon { + opacity: 0.5; +} + +.segmentGroupName { + padding-left: calc(var(--ui-large-margin) + var(--ui-margin)) !important; +} + .bottomSection { display: flex; flex-wrap: wrap; diff --git a/src/localization/chinese-simplified.ts b/src/localization/chinese-simplified.ts index 30d6f22e..1c98c261 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: @@ -568,6 +572,12 @@ export function resolveChineseSimplified(text: Label): string { return "对比区间时间"; case Label.ColumnStartWithPossibleTimeSave: return "可节省时间"; + case Label.SubsplitDisplayModeFlat: + return "平铺"; + case Label.SubsplitDisplayModeCurrentGroupExpanded: + return "当前分组展开"; + case Label.SubsplitDisplayModeAllGroupsExpanded: + return "全部分组展开"; case Label.ColumnUpdateWithDontUpdate: return "不更新"; case Label.ColumnUpdateWithSplitTime: 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 e01fd0c0..be63aee2 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: @@ -568,6 +572,12 @@ export function resolveDutch(text: Label): string { return "Vergelijking segmenttijd"; case Label.ColumnStartWithPossibleTimeSave: return "Mogelijke tijdswinst"; + case Label.SubsplitDisplayModeFlat: + return "Plat"; + 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 9c7fe671..11480359 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: @@ -568,6 +572,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.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 5d5d9474..96840205 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: @@ -568,6 +572,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.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 13be887f..af3087e3 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: @@ -568,6 +572,12 @@ export function resolveGerman(text: Label): string { return "Vergleichs‑Segmentzeit"; case Label.ColumnStartWithPossibleTimeSave: return "Mögliche Zeitersparnis"; + case Label.SubsplitDisplayModeFlat: + return "Flach"; + 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 4b6bb7bf..0a30026f 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, @@ -298,6 +300,9 @@ export enum Label { ColumnStartWithComparisonTime, ColumnStartWithComparisonSegmentTime, ColumnStartWithPossibleTimeSave, + SubsplitDisplayModeFlat, + SubsplitDisplayModeCurrentGroupExpanded, + SubsplitDisplayModeAllGroupsExpanded, ColumnUpdateWithDontUpdate, ColumnUpdateWithSplitTime, ColumnUpdateWithDelta, diff --git a/src/localization/italian.ts b/src/localization/italian.ts index 4a52ecb6..cf53a42b 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: @@ -568,6 +572,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.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 e018fa23..29714520 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: @@ -568,6 +572,12 @@ export function resolveJapanese(text: Label): string { return "比較対象区間タイム"; case Label.ColumnStartWithPossibleTimeSave: return "更新余地"; + case Label.SubsplitDisplayModeFlat: + 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 c38966c5..e0cef6ce 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: @@ -568,6 +572,12 @@ export function resolveKorean(text: Label): string { return "비교 세그먼트 시간"; case Label.ColumnStartWithPossibleTimeSave: return "절약 가능한 시간"; + case Label.SubsplitDisplayModeFlat: + 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 d3f7285b..5f33444b 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: @@ -568,6 +572,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.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 97b0ed3e..474bd89b 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: @@ -568,6 +572,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.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 89662eab..a78a5c7e 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: @@ -568,6 +572,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.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 8985b563..e4c6c92d 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: @@ -568,6 +572,12 @@ export function resolveRussian(text: Label): string { return "Время сегмента сравнения"; case Label.ColumnStartWithPossibleTimeSave: return "Возможная экономия времени"; + case Label.SubsplitDisplayModeFlat: + 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 80aafce1..c96d5638 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: @@ -568,6 +572,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.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 new file mode 100644 index 00000000..e7f7feac --- /dev/null +++ b/src/ui/components/Settings/SubsplitDisplayMode.tsx @@ -0,0 +1,52 @@ +import * as React from "react"; +import { expect } from "../../../util/OptionUtil"; +import { + SubsplitDisplayMode as SubsplitDisplayModeType, + 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: SubsplitDisplayModeType; + 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)} @@ -793,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} /> ); @@ -832,474 +844,6 @@ function TabContent({ } } -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(); - } - }; - - 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.segments.map((s, segmentIndex) => { - const segmentIcon = getSegmentIconUrl( - segmentIndex, - runEditorUrlCache, - editorState, - ); - - return ( - - handleSegmentRowMouseDown( - e, - segmentIndex, - s.selected, - ) - } - > - - changeSegmentIcon( - segmentIndex, - editor, - maybeUpdate, - lang, - ) - } - removeSegmentIcon={() => - removeSegmentIcon( - segmentIndex, - editor, - update, - ) - } - lang={lang} - /> - - - - - {editorState.segments[ - segmentIndex - ].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)}
- { - 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, @@ -1927,149 +1471,6 @@ function ComparisonsButton({ ); } -function SegmentIcon({ - segmentIcon, - changeSegmentIcon, - removeSegmentIcon, - lang, -}: { - segmentIcon: string | undefined; - changeSegmentIcon: () => void; - removeSegmentIcon: () => void; - 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)} - - - - {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, @@ -2683,237 +2084,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 - | 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 getActiveSegmentIndex( - editorState: LiveSplit.RunEditorStateJson, - fallbackIndex: number, -) { - const activeIndex = editorState.segments.findIndex( - (segment) => segment.selected === "Active", - ); - - return activeIndex === -1 ? fallbackIndex : activeIndex; -} - -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(); -} - -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 comparisonTimes = editorState.segments[index].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..5bd78d17 --- /dev/null +++ b/src/ui/views/RunEditor/SegmentsTable.tsx @@ -0,0 +1,914 @@ +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"]; +type SegmentGroupSelectionMode = "Exclusive" | "Toggle" | "Range"; + +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, + selectionMode: SegmentGroupSelectionMode, + rowState: RowState, + setRowState: React.Dispatch>, + update: () => LiveSplit.RunEditorStateJson, +) { + // 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; + } + + const state = update(); + setFocusedSegmentRowState( + state, + getActiveSegmentIndex(state, rowState.index), + rowState, + setRowState, + ); + 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, + 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 skipNextFocusedSelection = 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(); + } + + // 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, + 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(); + } + + const segmentNameInput = segmentNameInputRefs.current[index]; + skipNextFocusedSelection.current = + focusClickedRow && document.activeElement !== segmentNameInput; + + changeSegmentSelection( + event, + index, + selectionState, + editor, + rowState, + setRowState, + update, + ); + + if (focusClickedRow) { + segmentNameInput?.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(); + // 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, + segmentGroupSelectionMode(e), + 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)}
+ { + // 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={(_) => { + if ( + skipNextFocusedSelection.current + ) { + skipNextFocusedSelection.current = false; + return; + } + // Keyboard focus has no modifier + // selection to preserve, so it + // activates this group exclusively. + selectSegmentGroup( + editor, + groupIndex, + "Exclusive", + 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, + skipNextFocusedSelection, + rowState, + setRowState, + update, + ) + } + onChange={(e) => { + editor.activeSetName(e.target.value); + update(); + }} + /> + + + handleSegmentInputMouseDown( + e, + segmentIndex, + s.selected, + ) + } + onFocus={(_) => + focusSegment( + segmentIndex, + editor, + skipNextFocusedSelection, + 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, + skipNextFocusedSelection, + 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, + skipNextFocusedSelection, + 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, + skipNextFocusedSelection, + 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, + skipNextFocusedSelection: 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 (skipNextFocusedSelection.current) { + skipNextFocusedSelection.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 }); + } +}