Compare commits

..

2 Commits

Author SHA1 Message Date
_Neo_
905a41a643 UI: Actions Menu (Part 2 of 4) → XCI Trimmer (#146)
Ayyyy, welcome to the UI: Actions Menu → XCI Trimmer PR!

Let's keep it up!

This is the second PR in a series aimed at delivering the largest overhaul and improvements to the `Actions` menu yet.

This second PR introduces visual improvements to the `XCI Trimmer` and also fixes some bugs.

### GENERAL:
* **Renamed:** `XCI Trimmer` files to use the capitalised XCI instead of Xci
    * Files were inconsistently named with either Xci or XCI. As such, they were all renamed to use XCI.

### LOCALISATION:
* **Fractured:** More locales
    * `Common_Search.json` - search-related locales
    * `Common_Sort.json` - sorting fields, ascending/descending locales
    * `Dialog_XCITrimmer.json` - XCI trimmer dialogs
    * `GameListContextMenu.json` - all UI dialogs related to the game list menu (right-click game)
    * `XCITrimmer.json` - all UI-related locales for the XCI Trimmer window
* **Added:** Additional entry to `StatusBar.json`
* **Added:** `Simulate Wake-Up Message` to `MenuBar_Actions.json` (previously did not exist there, for some reason).

**NOTE:** `Common_Search.json` and `Common_Sort.json` were not fully populated, but many more locales will be added and cleaned up in future PRs.

### XCI TRIMMER:
* **Renamed:** `Trim XCI Files` menu → `XCI Trimmer`
* **XCI Trimmer (Window):**
    * **Renamed:** Window title from `XCI File Trimmer` → `XCI Trimmer`
    * **Renamed:** `X of Y Title(s) Selected` → `XCI Selected: X/Y`
        * `Z Displayed` was renamed to `Displayed: Z` and, as usual, appears only when searching for particular XCIs in the search bar.
    * **Fixed:** Counting bug
        * Pressing `Select Shown` continuously would increment the amount of XCIs selected to infinity (e.g. if 5 XCIs are in the list, then you could get 1201312 of 5 Title(s) Selected). Furthermore, when you were trimming a specific number of XCIs (not the all XCIs) at once, it would show that its trimming all titles from the list (e.g. "Trimming 5 Titles" when only 1 was being trimmed).
    * **Changed:** Button structure
        * Previous structure included 2 buttons: `Select Shown` and `Deselect Shown`. Initially, they were renamed to `Select All` and `Deselect All`, as that sounded more natural. However, after further consideration, they were both removed and replaced by a single button, whose action and label would change depending on the state it was in:
            * If no XCIs were selected → `Select All`
            * If at least one XCI is selected →`Clear Selection`
                * `Clear Selection` was used instead of Deselect All, because some translations were quite awkward.
                * Furthermore, this reflects the way the button works: you are not "deselecting all" when only 2 out of 5 XCIs are selected - you are clearing that particular selection of XCIs.
    * **Improved:** Button and Control Layout (above list)
        * Sorting dropdown was expanded a bit further (150 default to 170 width), as some locales would get cutoff. This will be further adjusted in a future PR, which will tackle all of Ryujinx's sorting features.
        * Added a new sorting: Trim Status
        * See images for visual comparison.
    * **Improved:** XCI List Layout
        * The entire list was revamped to be more modern and cleaner to look at.
        * Instead of displaying the file status (Trimmed, Untrimmed, Partial, Failed, Unknown), it now shows icons: Checkmark (Trimmed), Cross (Untrimmed), Wrench (Partial), Exclamation Mark (Failed), Question mark (everything else/Unknown), No Icon (when it's not XCI).
            * Furthermore, it shows a Sync icon when performing the trimming/untrimming operation.
            * Additionally, it shows the said status when you hover above the icons.
        * Save X MB and Saved Y MB were both removed and replaced with just the amount and a percentage value (how much you save per file).
        * See images for visual comparison.
        * NOTE: Icons are still under consideration and may be changed in the future.
    * **Improved:** Bottom Savings Display
        * Instead of showing `Potential Savings`" and `Actual Savings`, the bottom row now shows the amount `Saved` AND the amount `Remaining`.

### GAME LIST CONTEXT MENU (Right-Click Game):
* **Renamed:** `Check & Trim XCI File` → `Trim XCI`
    * The option already performs a check regardless.
*  **Remove:** `Trim XCI` Tooltip
    * The user can press the option and will be confronted with a dialog, which explains what the feature does. Furthermore, the action itself is reversible at any moment (i.e. before trimming and after trimming in the XCI Trimmer).
        * It is planned to either add a message that tells the user they can untrim the XCI in the XCI Trimmer, or to make the option work both ways (Trim & Untrim). This is still under consideration, but, if chosen to be implemented, it will be a part of the Game List Context Menu PR.
* **Added:** IsVisible parameter to `Trim XCI`
    * Instead of displaying `Trim XCI` for every single game (NSP, NSO, etc.), it will only be displayed for XCIs (as they are the only ones that can be trimmed). It is still _enabled_ only if an XCI is untrimmed.
* **Improved:** `Trim XCI` Dialog
    * Some minor visual adjustment and organisation improvements.
    * **Changed:** Value formatting for "File Size", "Game Size", and "Space Savings"
        * Previously they were very long numbers, expressed in MB, with all the decimals. Instead of displaying them as such, they were shortened to display the same amount as they do in the XCI Trimmer window already.
    * All further dialogs will be improved in a separate PR, because they desperately need consistency.

### STATUS BAR:
* **Adjusted:** Formatting of XCI Trimming Status on status bar
    * **Forced:** Filename without extension (as we are already trimming an XCI already).
    * **Fixed:** Trimming Position when Playtime is not available
    * **Fixed:** Progress bar position (will be further adjusted in a Status Bar PR…boy there will be a lot of PRs, which is good)

_If there are any features or changes that you wish to be implemented, please comment down below and I'll be happy to accommodate!_

Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/146
2026-06-27 02:11:31 +00:00
Babib3l
54e9b40cd7 Feat : Input : Dynamic Input Swap, Device Profile Linking and Player Device Assignement (#125)
Heyy everyone!
Continuing my work on the input system overhaul, this PR is mainly focused around three new features :
- Dynamic Input Swap
- Device Profile Linking
- Player Device Assignement

What are these new features and why are they bundled in the same PR you might ask?

- **Dynamic Input Swap**
Is a new feature that, when enabled, allows the user to switch between multiple input devices, without having to return to the settings menu. This feature can be enabled at the bottom of the Input Settings menu, next to the "global input" checkbox. A nifty new tooltip has been added for clarity.
- **Device Profile linking** :
While working on Dynamic Input Swap, one issue arose, which was that Ryujinx would use that device’s Default profile, without the user being able to specify a specific profile to use on that specific device. This feature fixes that, by allowing the user to link a profile as the device’s Default. This means that this profile will be auto loaded when selecting that same device, both when connecting it to the emulator, and when using Dynamic Input Swap. This feature can be found next to the Device Profiles dropdown, and includes a nifty new tooltip. By default, the "default" profile is linked to any device. There also is a small icon to the right of the profile name’s Right to visualise which profile is linked.
- **Player Device Assignement** :
Another issue that arose while working on Dynamic Input Swap was that enabling the feature would render multi player configurations unusable. This new feature addresses this issue by adding a new menu, which allows the user to assign different Input Devices to different players. Inside this new menu, you also get the device’s linked profile and the list of which players are already assigned to which input device, below and to the right of the input device name, respectively. You also get an option to allow mapping the same input device to different players.

### Nerd Zone

This PR adds a new player-level input routing layer on top of the existing `InputConfig` system.

Previously, each player effectively had one active input config, tied to one device. Given Dynamic Input Swap needs more state than that, this PR introduces a persisted `PlayerInputAssignment` model, which stores the player index, whether Dynamic Input Swap is enabled or not, the list of assigned input devices and an optional profile name bound to each assigned device.

The new assignment data lives coexists with the existing input configs instead of replacing them, which should keep the old single-device behavior intact when Dynamic Input Swap is disabled, while allowing dynamic players to own multiple devices.

New configl types include:

- `AssignedInputDevice`
- `AssignedInputDeviceType`
- `PlayerInputAssignment`
- `PlayerInputAssignmentHelper`
- `PlayerInputDeviceAssignmentItem`

`PlayerInputAssignmentHelper` normalizes assignments, deduplicates device entries, preserves a primary device, and compares assignments for dirty-checking.

On the runtime side, `NpadManager` now passes both the normal `InputConfig` and the player assignment to `NpadController`. When Dynamic Input Swap is disabled, Ryujinx switches to the old behavior : one player config opens one device. However, when it's enabled, `NpadController` opens every assigned keyboard/controller device it can resolve, tracks their state snapshots, and promotes the active source based on recent input.

Dynamic swap currently follows a “last meaningful input wins” model (annotated in the code) :

So if the keyboard produces new input, it becomes active; if an assigned controller produces new input, that controller becomes active; if the active device stops producing input and another assigned device is held/active, the active source can fall back; and if no device has produced input yet, the initial active source is chosen from the selected config and available assigned devices.

Per-device profile binding is handled by storing the profile name on the assigned device entry. When a device is selected or resolved through Dynamic Input Swap, Ryujinx tries to load that bound profile for the matching device type. If the profile is missing, invalid, or explicitly `Default`, it falls back to the generated default config for that device type.

The new restore-to-defaults behavior deliberately bypasses linked profiles. This means pressing the reset button loads the real generated Default profile for the currently selected device, not the profile linked to that device.

The input settings UI now dirty-checks both the currently edited input config and the player input assignment state, meaning that assigning/unassigning devices, changing Dynamic Input Swap, or changing profile bindings correctly marks the page as modified -> in continuation of my previous efforts in #13 to clean up its behaviour.

The Assigned Devices dialog is backed by the currently available keyboard/controller device list. It also checks other players’ persisted assignments so the UI can show which players already use a device. If duplicate assignment is disabled, devices already assigned to another dynamic-swap player are disabled for the current player.
If no explicit player assignments exist yet, Ryujinx synthesizes a default assignment from the existing input config. Dynamic swap is disabled by default until the user enables it.

⚠️⚠️⚠️⚠️⚠️Caution : this PR is still a WIP; and while all features described above have been fully implemented, various issues still remain, notably inside the Player assignement menu, that are getting worked on. Additionally, little bug testing has been done across the emulator, so it is not guaranteed that this build will be bug free.

Signed by : 🦫
With the very generous help and support from @neo 🤗

Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/125
2026-06-27 01:42:20 +00:00
48 changed files with 4380 additions and 1710 deletions

View File

@@ -0,0 +1,29 @@
{
"Locales": [
{
"ID": "SearchWatermark",
"Translations": {
"ar_SA": "بحث",
"de_DE": "Suche",
"el_GR": "Αναζήτηση",
"en_US": "Search",
"es_ES": "Buscar",
"fr_FR": "Rechercher",
"he_IL": "חפש",
"it_IT": "Cerca",
"ja_JP": "検索",
"ko_KR": "찾기",
"no_NO": "Søk",
"pl_PL": "Wyszukaj",
"pt_BR": "Buscar",
"ru_RU": "Поиск",
"sv_SE": "Sök",
"th_TH": "ค้นหา",
"tr_TR": "Ara",
"uk_UA": "Пошук",
"zh_CN": "搜索",
"zh_TW": "搜尋"
}
}
]
}

View File

@@ -0,0 +1,129 @@
{
"Locales": [
{
"ID": "NameLabel",
"Translations": {
"ar_SA": "الاسم",
"de_DE": "",
"el_GR": "Όνομα",
"en_US": "Name",
"es_ES": "Nombre",
"fr_FR": "Nom",
"he_IL": "שם",
"it_IT": "Nome",
"ja_JP": "名称",
"ko_KR": "이름",
"no_NO": "Navn",
"pl_PL": "Nazwa",
"pt_BR": "Nome",
"ru_RU": "Название",
"sv_SE": "Namn",
"th_TH": "ชื่อ",
"tr_TR": "İsim",
"uk_UA": "Назва",
"zh_CN": "名称",
"zh_TW": "名稱"
}
},
{
"ID": "SavingsLabel",
"Translations": {
"ar_SA": "التوفير",
"de_DE": "Einsparung",
"el_GR": "Εξοικονόμηση",
"en_US": "Savings",
"es_ES": "Ahorro",
"fr_FR": "Économies",
"he_IL": "חיסכון",
"it_IT": "Risparmio",
"ja_JP": "節約",
"ko_KR": "절약",
"no_NO": "Besparelse",
"pl_PL": "Oszczędność",
"pt_BR": "Economia",
"ru_RU": "Экономия",
"sv_SE": "Besparing",
"th_TH": "การประหยัด",
"tr_TR": "Tasarruf",
"uk_UA": "Економія",
"zh_CN": "节省",
"zh_TW": "節省"
}
},
{
"ID": "TrimStatusLabel",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Trim Status",
"es_ES": "Estado del recorte",
"fr_FR": "État de réduction",
"he_IL": "",
"it_IT": "Stato della riduzione",
"ja_JP": "",
"ko_KR": "",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
"ru_RU": "Статус обрезки",
"sv_SE": "",
"th_TH": "",
"tr_TR": "",
"uk_UA": "Статус обрізки",
"zh_CN": "",
"zh_TW": ""
}
},
{
"ID": "OrderAscending",
"Translations": {
"ar_SA": "تصاعدي",
"de_DE": "Aufsteigend",
"el_GR": "Αύξουσα",
"en_US": "Ascending",
"es_ES": "Ascendente",
"fr_FR": "Croissant",
"he_IL": "סדר עולה",
"it_IT": "Crescente",
"ja_JP": "昇順",
"ko_KR": "오름차순",
"no_NO": "Stigende",
"pl_PL": "Rosnąco",
"pt_BR": "Ascendente",
"ru_RU": "По Возрастанию",
"sv_SE": "Stigande",
"th_TH": "จากน้อยไปมาก",
"tr_TR": "Artan",
"uk_UA": "За зростанням",
"zh_CN": "升序",
"zh_TW": "從小到大"
}
},
{
"ID": "OrderDescending",
"Translations": {
"ar_SA": "تنازلي",
"de_DE": "Absteigend",
"el_GR": "Φθίνουσα",
"en_US": "Descending",
"es_ES": "Descendente",
"fr_FR": "Décroissant",
"he_IL": "סדר יורד",
"it_IT": "Decrescente",
"ja_JP": "降順",
"ko_KR": "내림차순",
"no_NO": "Synkende",
"pl_PL": "Malejąco",
"pt_BR": "Descendente",
"ru_RU": "По Убыванию",
"sv_SE": "Fallande",
"th_TH": "จากมากไปน้อย",
"tr_TR": "Azalan",
"uk_UA": "За спаданням",
"zh_CN": "降序",
"zh_TW": "從大到小"
}
}
]
}

View File

@@ -0,0 +1,304 @@
{
"Locales": [
{
"ID": "PrimaryMessage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Removes unused space from the XCI to reduce its file size.",
"es_ES": "Elimina el espacio no utilizado del XCI para reducir su tamaño.",
"fr_FR": "Supprime lespace inutilisé du XCI afin de réduire sa taille.",
"he_IL": "",
"it_IT": "Rimuove lo spazio inutilizzato dall'XCI per ridurne le dimensioni.",
"ja_JP": "",
"ko_KR": "XCI에서 사용되지 않는 공간을 제거하여 파일 크기를 줄입니다.",
"no_NO": "Fjerner ubrukt plass fra XCI-filen for å redusere filstørrelsen.",
"pl_PL": "Usuwa nieużywane miejsce z pliku XCI, aby zmniejszyć jego rozmiar.",
"pt_BR": "Remove o espaço não utilizado do XCI para reduzir seu tamanho.",
"ru_RU": "Удаляет неиспользуемое пространство из XCI, уменьшая размер файла.",
"sv_SE": "Tar bort oanvänt utrymme från XCI-filen för att minska filstorleken.",
"th_TH": "ลบพื้นที่ที่ไม่ได้ใช้งานออกจาก XCI เพื่อลดขนาดไฟล์",
"tr_TR": "",
"uk_UA": "Видаляє невикористаний простір із XCI, зменшуючи розмір файлу.",
"zh_CN": "移除 XCI 中未使用的空间以减小文件大小。",
"zh_TW": "移除 XCI 中未使用的空間以減少檔案大小。"
}
},
{
"ID": "SecondaryMessage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "File: {0:n} MB • Game: {1:n} MB\n\nSavings: {2:n} MB",
"es_ES": "Archivo: {0:n} MB • Juego: {1:n} MB\n\nAhorro: {2:n} MB",
"fr_FR": "Fichier: {0:n} Mo • Jeu: {1:n} Mo\n\nÉconomies: {2:n} Mo",
"he_IL": "",
"it_IT": "File: {0:n} MB • Gioco: {1:n} MB\n\nRisparmio: {2:n} MB",
"ja_JP": "",
"ko_KR": "파일: {0:n} MB • 게임: {1:n} MB\n\n절약: {2:n} MB",
"no_NO": "Fil: {0:n} MB • Spill: {1:n} MB\n\nBesparelse: {2:n} MB",
"pl_PL": "Plik: {0:n} MB • Gra: {1:n} MB\n\nOszczędności: {2:n} MB",
"pt_BR": "Arquivo: {0:n} MB • Jogo: {1:n} MB\n\nEconomia: {2:n} MB",
"ru_RU": "Файл: {0:n} МБ • Игра: {1:n} МБ\n\nЭкономия: {2:n} МБ",
"sv_SE": "Fil: {0:n} MB • Spel: {1:n} MB\n\nBesparing: {2:n} MB",
"th_TH": "ไฟล์: {0:n} MB • เกม: {1:n} MB\n\nการประหยัด: {2:n} MB",
"tr_TR": "",
"uk_UA": "Файл: {0:n} МБ • Гра: {1:n} МБ\n\nЕкономія: {2:n} МБ",
"zh_CN": "文件: {0:n} MB • 游戏: {1:n} MB\n\n节省: {2:n} MB",
"zh_TW": "檔案: {0:n} MB • 遊戲: {1:n} MB\n\n節省: {2:n} MB"
}
},
{
"ID": "NoTrimNecessaryMessage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "XCI does not require trimming. Check logs for details.",
"es_ES": "El XCI no necesita ser recortado. Verifica los logs para detalles.",
"fr_FR": "Le XCI na pas besoin dêtre réduit. Référez-vous aux journaux pour détails.",
"he_IL": "",
"it_IT": "Non è necessario ridurre la dimensione del XCI. Controlla i log per dettagli.",
"ja_JP": "",
"ko_KR": "XCI는 트리밍할 필요가 없습니다. 자세한 내용은 로그를 확인.",
"no_NO": "XCI trenger ikke å trimmes. Sjekk loggene for detaljer.",
"pl_PL": "XCI nie wymaga przycinania. Sprawdź dzienniki, aby uzyskać szczegóły.",
"pt_BR": "O XCI não precisa ser reduzido. Verifique os logs para detalhes.",
"ru_RU": "XCI не требует обрезки. Проверьте логи для подробностей.",
"sv_SE": "XCI behöver inte optimeras. Kontrollera loggen för detaljer.",
"th_TH": "XCI ไม่จำเป็นต้องถูกตัดแต่ง โปรดตรวจสอบบันทึกสำหรับรายละเอียด",
"tr_TR": "",
"uk_UA": "XCI не потребує обрізання. Перевірте журнали для отримання деталей.",
"zh_CN": "XCI 不需要被瘦身。查看日志以获得更多细节。",
"zh_TW": "XCI 不需要修剪。檢查日誌以取得更多資訊。"
}
},
{
"ID": "NoUntrimPossibleMessage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "XCI cannot be untrimmed. Check logs for details.",
"es_ES": "El recorte del XCI no puede ser deshecho. Verifica los registros para detalles.",
"fr_FR": "Le XCI ne peut être restauré. Référez-vous aux journaux pour détails.",
"he_IL": "",
"it_IT": "XCI non può essere ripristinato. Controlla i log per dettagli.",
"ja_JP": "",
"ko_KR": "XCI는 복원할 수 없습니다. 자세한 내용은 로그를 확인.",
"no_NO": "XCI kan ikke gjenopprettes. Sjekk loggene for detaljer.",
"pl_PL": "XCI nie może zostać przywrócone. Sprawdź dzienniki, aby uzyskać szczegóły.",
"pt_BR": "XCI não pode ser desfeito. Verifique os logs para detalhes.",
"ru_RU": "XCI не может быть восстановлен. Проверьте журналы для подробностей.",
"sv_SE": "XCI kan inte återställas. Kontrollera loggen för detaljer.",
"th_TH": "ไม่สามารถคืนค่า XCI ได้ โปรดตรวจสอบบันทึกสำหรับรายละเอียด",
"tr_TR": "",
"uk_UA": "XCI не можна відновити. Перевірте журнали для деталей.",
"zh_CN": "XCI 不能恢复。查看日志以获取详情。",
"zh_TW": "XCI 無法恢復。檢查日誌以取得詳情。"
}
},
{
"ID": "ReadOnlyFileCannotFixMessage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "XCI is read-only and could not be made writable. Check logs for details.",
"es_ES": "XCI es solo lectura y no se puede escribir. Verifica los registros para detalles.",
"fr_FR": "XCI en lecture seule et n'a pas pu être rendu écrivable. Référez-vous aux journaux pour détails.",
"he_IL": "",
"it_IT": "XCI è solo lettura e non può essere scritto. Controlla i log per dettagli.",
"ja_JP": "",
"ko_KR": "XCI 파일은 읽기 전용이며 쓰기 불가. 로그를 확인하십시오.",
"no_NO": "XCI er skrivebeskyttet og kunne ikke gjøres skrivbar. Sjekk loggene for detaljer.",
"pl_PL": "XCI jest tylko do odczytu i nie można zapisać. Sprawdź logi dla szczegółów.",
"pt_BR": "XCI é somente leitura e não pode ser gravado. Verifique os logs para detalhes.",
"ru_RU": "XCI только для чтения и не стал доступен для записи. Проверьте журналы для подробностей.",
"sv_SE": "XCI-filen är skrivskyddad och kunde inte göras skrivbar. Kontrollera loggen för mer information",
"th_TH": "XCI เป็นอ่านอย่างเดียวและไม่สามารถเขียนได้ ตรวจสอบบันทึกสำหรับรายละเอียด",
"tr_TR": "",
"uk_UA": "XCI тільки для читання і не можна записати. Перевірте логи для деталей.",
"zh_CN": "XCI 只读,无法写入。查看日志以获取详情。",
"zh_TW": "XCI 檔案唯讀,無法寫入。檢查日誌以取得詳情。"
}
},
{
"ID": "SizeChangedMessage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "XCI size changed since last scan. Ensure the file is not being written to and try again.",
"es_ES": "El tamaño de XCI ha cambiado desde que fue escaneado. Verifica que no se esté escribiendo al archivo y vuelve a intentarlo.",
"fr_FR": "La taille de XCI a changé depuis son analyse. Vérifiez que le fichier nest pas en cours décriture, puis réessayez.",
"he_IL": "",
"it_IT": "La dimensione di XCI è cambiata da quando è stato scansionato. Controlla che il file non sia scritto e riprova.",
"ja_JP": "",
"ko_KR": "XCI 크기가 스캔 후 변경되었습니다. 파일이 쓰여지고 있지 않은지 확인하고 다시 시도하세요.",
"no_NO": "XCI har endret størrelse siden den ble skannet. Kontroller at det ikke skrives til filen, og prøv på nytt.",
"pl_PL": "Rozmiar XCI zmienił się od momentu zeskanowania. Sprawdź, czy plik nie jest zapisywany, a następnie spróbuj ponownie.",
"pt_BR": "O tamanho de XCI mudou desde que foi escaneado. Verifique se o arquivo não está sendo gravado e tente novamente.",
"ru_RU": "Размер XCI изменился после сканирования. Проверьте, не записывается ли файл, и попробуйте снова.",
"sv_SE": "XCI har ändrats i storlek sedan den lästes av. Kontrollera att filen inte skrivs till och försök igen.",
"th_TH": "ขนาด XCI เปลี่ยนไปตั้งแต่การสแกนครั้งล่าสุด ตรวจสอบว่าไฟล์ไม่ได้ถูกเขียน และลองใหม่",
"tr_TR": "",
"uk_UA": "Розмір XCI змінився з моменту сканування. Перевірте, чи не записується файл, та спробуйте знову.",
"zh_CN": "XCI 在扫描后大小发生了变化。请检查文件是否未被写入,然后重试。",
"zh_TW": "XCI 檔案大小自上次掃描以來已經改變。請檢查檔案是否未被寫入,然後再嘗試。"
}
},
{
"ID": "FreeSpaceCheckFailedMessage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "XCI has data in the free space area. It is not safe to trim.",
"es_ES": "XCI tiene datos en el área de espacio libre. No es seguro recortarlo.",
"fr_FR": "XCI contient des données dans la zone d'espace libre. Il n'est pas sûr de le réduire.",
"he_IL": "",
"it_IT": "XCI contiene dati nell'area di spazio libero. Non è sicuro ridurre la sua dimensione.",
"ja_JP": "",
"ko_KR": "XCI 파일에 여유 공간 영역에 데이터가 있으므로 트리밍하는 것이 안전하지 않습니다.",
"no_NO": "XCI har data i den ledige plassen. Det er ikke trygt å trimme den.",
"pl_PL": "XCI zawiera dane w obszarze wolnego miejsca. Nie jest bezpieczne go przycinać.",
"pt_BR": "XCI tem dados na área de espaço livre. Não é seguro reduzi-lo.",
"ru_RU": "XCI содержит данные в свободной области. Его обрезка небезопасна.",
"sv_SE": "XCI har data i det lediga utrymmet. Det är inte säkert att optimera.",
"th_TH": "XCI มีข้อมูลในพื้นที่ว่าง จึงไม่ปลอดภัยที่จะทำการตัดแต่ง",
"tr_TR": "",
"uk_UA": "XCI містить дані в зоні вільного простору. Тому обрізка небезпечна.",
"zh_CN": "XCI 文件的空闲区域内有数据。不能安全瘦身。",
"zh_TW": "XCI 檔案有數據儲存於空閒區域。修剪不安全。"
}
},
{
"ID": "InvalidDataMessage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "XCI contains invalid data. Check logs for details.",
"es_ES": "XCI contiene datos inválidos. Lee el registro para detalles.",
"fr_FR": "XCI contient des données invalides. Référez-vous aux journaux pour détails.",
"he_IL": "",
"it_IT": "XCI contiene dati non validi. Controlla i log per dettagli.",
"ja_JP": "",
"ko_KR": "XCI 파일에 유효하지 않은 데이터가 포함되어 있습니다. 로그를 확인하세요.",
"no_NO": "XCI-filen inneholder ugyldige data. Sjekk loggene for detaljer.",
"pl_PL": "XCI zawiera nieprawidłowe dane. Sprawdź dzienniki, aby uzyskać szczegóły.",
"pt_BR": "XCI contém dados inválidos. Verifique os logs para detalhes.",
"ru_RU": "XCI содержит недопустимые данные. Проверьте журналы для подробностей.",
"sv_SE": "XCI-filen innehåller ogiltig data. Kontrollera loggen för detaljer.",
"th_TH": "XCI มีข้อมูลที่ไม่ถูกต้อง โปรดตรวจสอบบันทึกสำหรับรายละเอียด",
"tr_TR": "",
"uk_UA": "XCI містить недійсні дані. Перевірте журнали для деталей.",
"zh_CN": "XCI 文件含有无效数据。查看日志以获得更多细节。",
"zh_TW": "XCI 檔案帶有無效的數據。檢查日誌以取得更多資訊"
}
},
{
"ID": "WriteErrorMessage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "XCI could not be opened for writing. Check logs for details.",
"es_ES": "XCI no se puede abrir para escribir. Lee el registro para detalles.",
"fr_FR": "XCI n'a pas pu être ouvert pour écriture. Consultez les journaux pour détails.",
"he_IL": "",
"it_IT": "XCI non può essere aperto in scrittura. Controlla i log per dettagli.",
"ja_JP": "",
"ko_KR": "XCI를 쓰기 위해 열 수 없습니다. 로그를 확인하세요.",
"no_NO": "XCI kunne ikke åpnes for skriving. Sjekk loggene for detaljer.",
"pl_PL": "Nie można otworzyć XCI do zapisu. Sprawdź dzienniki, aby uzyskać szczegóły.",
"pt_BR": "XCI não pôde ser aberto para gravação. Verifique os logs para detalhes.",
"ru_RU": "Не удалось открыть XCI для записи. Проверьте журналы для подробностей.",
"sv_SE": "XCI kunde inte öppnas för skrivning. Kontrollera loggen för detaljer.",
"th_TH": "ไม่สามารถเปิด XCI เพื่อเขียนข้อมูลได้ โปรดตรวจสอบบันทึกสำหรับรายละเอียด",
"tr_TR": "",
"uk_UA": "Не вдалося відкрити XCI для запису. Перевірте журнали для деталей.",
"zh_CN": "XCI 不能写入。查看日志以获得更多细节。",
"zh_TW": "XCI 無法開啟以進行寫入。請檢查日誌以取得更多資訊。"
}
},
{
"ID": "TrimFailedMessage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Failed to trim XCI.",
"es_ES": "El recorte del XCI falló.",
"fr_FR": "La réduction du XCI a échoué.",
"he_IL": "",
"it_IT": "Riduzione del XCI fallita.",
"ja_JP": "",
"ko_KR": "XCI 트리밍에 실패했습니다.",
"no_NO": "Trimming av XCI mislyktes.",
"pl_PL": "Nie udało się przyciąć XCI.",
"pt_BR": "A redução do XCI falhou.",
"ru_RU": "Обрезка XCI не удалась.",
"sv_SE": "Optimering av XCI misslyckades.",
"th_TH": "การตัดแต่ง XCI ล้มเหลว",
"tr_TR": "",
"uk_UA": "Не вдалося обрізати XCI.",
"zh_CN": "XCI 瘦身失败。",
"zh_TW": "修剪 XCI 失敗。"
}
},
{
"ID": "TrimCancelledMessage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "The operation was cancelled.",
"es_ES": "La operación fue cancelada.",
"fr_FR": "L'opération a été annulée.",
"he_IL": "",
"it_IT": "L'operazione è stata annullata.",
"ja_JP": "",
"ko_KR": "작업이 취소되었습니다.",
"no_NO": "Operasjonen ble avbrutt.",
"pl_PL": "",
"pt_BR": "A operação foi cancelada.",
"ru_RU": "Операция была отменена.",
"sv_SE": "Åtgärden avbröts.",
"th_TH": "การดำเนินการถูกยกเลิกแล้ว.",
"tr_TR": "",
"uk_UA": "Операцію перервано.",
"zh_CN": "操作已取消。",
"zh_TW": "操作已取消。"
}
},
{
"ID": "NoOperationPerformedMessage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "No operation was performed.",
"es_ES": "No se realizó ninguna operación.",
"fr_FR": "Aucune opération n'a été effectuée.",
"he_IL": "",
"it_IT": "Non è stata effettuata alcuna operazione.",
"ja_JP": "",
"ko_KR": "작업이 수행되지 않았습니다.",
"no_NO": "Ingen operasjon ble utført.",
"pl_PL": "",
"pt_BR": "Nenhuma operação foi realizada.",
"ru_RU": "Операция не была выполнена.",
"sv_SE": "Ingen åtgärd genomfördes.",
"th_TH": "ไม่มีการดำเนินการใด ๆ",
"tr_TR": "",
"uk_UA": "Операцію не було виконано.",
"zh_CN": "未执行任何操作。",
"zh_TW": "沒有執行任何操作。"
}
}
]
}

View File

@@ -0,0 +1,29 @@
{
"Locales": [
{
"ID": "TrimXCIButton",
"Translations": {
"ar_SA": "تقليم XCI",
"de_DE": "Zuschneiden der XCI",
"el_GR": "Κοπή XCI",
"en_US": "Trim XCI",
"es_ES": "Recortar XCI",
"fr_FR": "Réduire le XCI",
"he_IL": "חתוך XCI",
"it_IT": "Riduci il XCI",
"ja_JP": "XCIをトリム",
"ko_KR": "XCI 트림",
"no_NO": "Trim XCI-filen",
"pl_PL": "Przytnij XCI",
"pt_BR": "Reduzir o XCI",
"ru_RU": "Обрезать XCI",
"sv_SE": "Optimera XCI",
"th_TH": "ลดขนาด XCI",
"tr_TR": "XCI'yi Kırp",
"uk_UA": "Нарізка XCI",
"zh_CN": "精简 XCI",
"zh_TW": "修剪 XCI"
}
}
]
}

View File

@@ -228,26 +228,26 @@
{
"ID": "XCITrimmerButton",
"Translations": {
"ar_SA": "",
"de_DE": "XCI-Dateien trimmen",
"ar_SA": "",
"de_DE": "XCI-Trimmer",
"el_GR": "",
"en_US": "Trim XCI Files",
"es_ES": "Recortar Archivos XCI",
"fr_FR": "Réduire les Fichiers XCI",
"en_US": "XCI Trimmer",
"es_ES": "Recortador de XCI",
"fr_FR": "Réducteur de XCI",
"he_IL": "",
"it_IT": "Riduci dimensioni dei file XCI",
"it_IT": "Trimmer XCI",
"ja_JP": "",
"ko_KR": "XCI 파일 트리머",
"no_NO": "Trim XCI-filer",
"pl_PL": "",
"pt_BR": "Reduzir Arquivos XCI",
"ru_RU": "Обрезать XCI файлы",
"sv_SE": "Optimera XCI-filer",
"th_TH": "ตัดแต่งไฟล์ XCI",
"tr_TR": "",
"uk_UA": "Обрізати XCI файли",
"zh_CN": "瘦身 XCI 文件",
"zh_TW": "修剪 XCI 檔案"
"pl_PL": "Przycinacz XCI",
"pt_BR": "Cortador de XCI",
"ru_RU": "Триммер XCI",
"sv_SE": "XCI-trimmer",
"th_TH": "",
"tr_TR": "XCI Kesici",
"uk_UA": "Тример XCI",
"zh_CN": "",
"zh_TW": ""
}
},
{
@@ -450,6 +450,31 @@
"zh_TW": "移除 Skylander"
}
},
{
"ID": "SimulateWakeUpMessageButton",
"Translations": {
"ar_SA": "محاكاة رسالة الاستيقاظ",
"de_DE": "Aufwachnachricht simulieren",
"el_GR": "Προσομοίωση Μηνύματος Αφύπνισης",
"en_US": "Simulate Wake-Up Message",
"es_ES": "Simular Mensaje de Reactivación",
"fr_FR": "Simuler un Message de Réveil",
"he_IL": "דמה הודעת השכמה",
"it_IT": "Simula messaggio di risveglio",
"ja_JP": "スリープ復帰メッセージをシミュレート",
"ko_KR": "절전 모드 해제 메시지 시뮬레이션",
"no_NO": "Simuler oppvåknings-melding",
"pl_PL": "Symuluj wiadomość wybudzania",
"pt_BR": "Simular Mensagem de Acordar o Console",
"ru_RU": "Имитировать сообщение пробуждения",
"sv_SE": "Simulera uppvakningsmeddelande",
"th_TH": "จำลองการปลุกอุปกรณ์ให้ทำงาน",
"tr_TR": "Uyandırma Mesajı Simüle Et",
"uk_UA": "Симулювати повідомлення про пробудження",
"zh_CN": "模拟唤醒消息",
"zh_TW": "模擬喚醒訊息"
}
},
{
"ID": "TakeScreenshotButton",
"Translations": {

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,32 @@
{
"Locales": [
{
"ID": "TrimmingXCILabel",
"Translations": {
"ar_SA": "جاري تقليم: {0}",
"de_DE": "Schneide: {0}",
"el_GR": "Κόβει το: {0}",
"en_US": "Trimming: {0}",
"es_ES": "Recortando: {0}",
"fr_FR": "Réduction de: {0}",
"he_IL": "חיתוך: {0}",
"it_IT": "Riduzione di: {0}",
"ja_JP": "{0} をトリミング中:",
"ko_KR": "{0} 트리밍:",
"no_NO": "Trimming av: {0}",
"pl_PL": "Przycinanie: {0}",
"pt_BR": "Reduzindo: {0}",
"ru_RU": "Обрезка: {0}",
"sv_SE": "Trimmar: {0}",
"th_TH": "กำลังตัด: {0}",
"tr_TR": "{0} Kısaltılıyor:",
"uk_UA": "Обрізка: {0}",
"zh_CN": "正在修剪: {0}",
"zh_TW": "正在修剪: {0}"
}
},
{
"ID": "FirmwareVersion",
"ID": "FirmwareVersionLabel",
"Translations": {
"ar_SA": "",
"de_DE": "",

View File

@@ -0,0 +1,429 @@
{
"Locales": [
{
"ID": "StatusCountLabel",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "XCI Selected: {0}/{1}",
"es_ES": "XCI Seleccionados: {0}/{1}",
"fr_FR": "XCI Sélectionnés : {0}/{1}",
"he_IL": "",
"it_IT": "XCI Selezionati: {0}/{1}",
"ja_JP": "",
"ko_KR": "XCI 선택됨: {0}/{1}",
"no_NO": "XCI Valgt: {0}/{1}",
"pl_PL": "XCI Wybrane: {0}/{1}",
"pt_BR": "XCI Selecionados: {0}/{1}",
"ru_RU": "Выбрано XCI: {0}/{1}",
"sv_SE": "XCI Valda: {0}/{1}",
"th_TH": "XCI เลือกแล้ว: {0}/{1}",
"tr_TR": "",
"uk_UA": "Вибрано XCI: {0}/{1}",
"zh_CN": "XCI 已选: {0}/{1}",
"zh_TW": "XCI 已選擇: {0}/{1}"
}
},
{
"ID": "StatusCountWithFilterLabel",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "XCI Selected: {0}/{1} (Displayed: {2})",
"es_ES": "XCI Seleccionados: {0}/{1} (Mostrados: {2})",
"fr_FR": "XCI Sélectionnés : {0}/{1} (Affichés : {2})",
"he_IL": "",
"it_IT": "XCI Selezionati: {0}/{1} (Visualizzati: {2})",
"ja_JP": "",
"ko_KR": "XCI 선택됨: {0}/{1} (표시됨: {2})",
"no_NO": "XCI Valgt: {0}/{1} (Vises: {2})",
"pl_PL": "XCI Wybrane: {0}/{1} (Wyświetlone: {2})",
"pt_BR": "XCI Selecionados: {0}/{1} (Exibidos: {2})",
"ru_RU": "Выбрано XCI: {0}/{1} (Отображается: {2})",
"sv_SE": "XCI Valda: {0}/{1} (Visas: {2})",
"th_TH": "XCI เลือกแล้ว: {0}/{1} (แสดง: {2})",
"tr_TR": "",
"uk_UA": "Вибрано XCI: {0}/{1} (Відображається: {2})",
"zh_CN": "XCI 已选: {0}/{1}(显示: {2}",
"zh_TW": "XCI 已選擇: {0}/{1}(顯示: {2}"
}
},
{
"ID": "StatusTrimmingLabel",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Trimming: {0}/{1}...",
"es_ES": "Recortando: {0}/{1}...",
"fr_FR": "Réduction : {0}/{1}...",
"he_IL": "",
"it_IT": "Riduzione: {0}/{1}...",
"ja_JP": "",
"ko_KR": "트리밍: {0}/{1}...",
"no_NO": "Trimmer: {0}/{1}...",
"pl_PL": "",
"pt_BR": "Recortando: {0}/{1}...",
"ru_RU": "Обрезка: {0}/{1}...",
"sv_SE": "Trimmar: {0}/{1}...",
"th_TH": "กำลังตัดแต่ง: {0}/{1}...",
"tr_TR": "",
"uk_UA": "Обрізання: {0}/{1}...",
"zh_CN": "正在修剪:{0}/{1}...",
"zh_TW": "正在修剪:{0}/{1}..."
}
},
{
"ID": "StatusUntrimmingLabel",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Untrimming {0}...",
"es_ES": "Deshaciendo el recorte {0}...",
"fr_FR": "Restauration de {0}...",
"he_IL": "",
"it_IT": "Ripristino di {0}...",
"ja_JP": "",
"ko_KR": "{0} 복원 중...",
"no_NO": "Gjenoppretter {0}...",
"pl_PL": "",
"pt_BR": "Restaurando {0}...",
"ru_RU": "Восстановление {0}...",
"sv_SE": "Återställer {0}...",
"th_TH": "กำลังกู้คืน {0}...",
"tr_TR": "",
"uk_UA": "Відновлення {0}...",
"zh_CN": "正在恢复 {0}...",
"zh_TW": "正在還原 {0}..."
}
},
{
"ID": "SelectAllButton",
"Translations": {
"ar_SA": "اختر الكل",
"de_DE": "Alles auswählen",
"el_GR": "Επιλογή όλων",
"en_US": "Select All",
"es_ES": "Seleccionar Todo",
"fr_FR": "Sélectionner Tout",
"he_IL": "בחר הכל",
"it_IT": "Seleziona tutto",
"ja_JP": "すべて選択",
"ko_KR": "모두 선택",
"no_NO": "Velg alle",
"pl_PL": "Zaznacz wszystko",
"pt_BR": "Selecionar tudo",
"ru_RU": "Выбрать все",
"sv_SE": "Markera alla",
"th_TH": "เลือกทั้งหมด",
"tr_TR": "Hepsini seç",
"uk_UA": "Вибрати все",
"zh_CN": "选择全部",
"zh_TW": "選擇全部"
}
},
{
"ID": "ClearSelectionButton",
"Translations": {
"ar_SA": "مسح التحديد",
"de_DE": "Auswahl aufheben",
"el_GR": "Εκκαθάριση επιλογής",
"en_US": "Clear Selection",
"es_ES": "Borrar selección",
"fr_FR": "Effacer la sélection",
"he_IL": "נקה בחירה",
"it_IT": "Cancella selezione",
"ja_JP": "選択をクリア",
"ko_KR": "선택 해제",
"no_NO": "Fjern utvalg",
"pl_PL": "Wyczyść zaznaczenie",
"pt_BR": "Limpar seleção",
"ru_RU": "Очистить выделение",
"sv_SE": "Rensa markering",
"th_TH": "ล้างการเลือก",
"tr_TR": "Seçimi temizle",
"uk_UA": "Очистити вибір",
"zh_CN": "清除选择",
"zh_TW": "清除選擇"
}
},
{
"ID": "TrimmedLabel",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Trimmed",
"es_ES": "Recortado",
"fr_FR": "Réduit",
"he_IL": "",
"it_IT": "Dim. ridotta",
"ja_JP": "",
"ko_KR": "트리밍됨",
"no_NO": "Trimmet",
"pl_PL": "",
"pt_BR": "Reduzido",
"ru_RU": "Обрезан",
"sv_SE": "Optimerad",
"th_TH": "ตัดแต่งแล้ว",
"tr_TR": "",
"uk_UA": "Обрізані",
"zh_CN": "经过瘦身的",
"zh_TW": "已修剪"
}
},
{
"ID": "UntrimmedLabel",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Untrimmed",
"es_ES": "Sin Recortar",
"fr_FR": "Non Réduit",
"he_IL": "",
"it_IT": "Dim. originale",
"ja_JP": "",
"ko_KR": "트리밍되지 않음",
"no_NO": "Ikke trimmet",
"pl_PL": "",
"pt_BR": "Não Reduzido",
"ru_RU": "Не обрезан",
"sv_SE": "Orörd",
"th_TH": "ยังไม่ได้ตัดแต่ง",
"tr_TR": "",
"uk_UA": "Необрізані",
"zh_CN": "没有瘦身的",
"zh_TW": "未修剪"
}
},
{
"ID": "PartialLabel",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Partial",
"es_ES": "Parcial",
"fr_FR": "Partiel",
"he_IL": "",
"it_IT": "Parziale",
"ja_JP": "",
"ko_KR": "일부",
"no_NO": "Delvis",
"pl_PL": "",
"pt_BR": "Parcial",
"ru_RU": "Частично",
"sv_SE": "Delvis",
"th_TH": "ยังไม่สมบูรณ์",
"tr_TR": "",
"uk_UA": "Часткові",
"zh_CN": "分区",
"zh_TW": "部分"
}
},
{
"ID": "FailedLabel",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Failed",
"es_ES": "Fallido",
"fr_FR": "Échoué",
"he_IL": "",
"it_IT": "Fallito",
"ja_JP": "",
"ko_KR": "실패",
"no_NO": "Mislyktes",
"pl_PL": "",
"pt_BR": "Falhou",
"ru_RU": "Ошибка",
"sv_SE": "Misslyckades",
"th_TH": "ล้มเหลว",
"tr_TR": "",
"uk_UA": "Невдача",
"zh_CN": "失败",
"zh_TW": "失敗"
}
},
{
"ID": "UnknownLabel",
"Translations": {
"ar_SA": "مجهول",
"de_DE": "Unbekannt",
"el_GR": "Άγνωστο",
"en_US": "Unknown",
"es_ES": "Desconocido",
"fr_FR": "Inconnu",
"he_IL": "לא ידוע",
"it_IT": "Sconosciuto",
"ja_JP": "不明",
"ko_KR": "알 수 없음",
"no_NO": "Ukjent",
"pl_PL": "Nieznany",
"pt_BR": "Desconhecido",
"ru_RU": "Неизвестно",
"sv_SE": "Okänd",
"th_TH": "ไม่รู้จัก",
"tr_TR": "Bilinmeyen",
"uk_UA": "Невідомо",
"zh_CN": "未知",
"zh_TW": "未知"
}
},
{
"ID": "CalculatedSavingsLabel",
"Translations": {
"ar_SA": null,
"de_DE": null,
"el_GR": null,
"en_US": "{0} MB ({1}%)",
"es_ES": null,
"fr_FR": "{0} Mo ({1} %)",
"he_IL": null,
"it_IT": null,
"ja_JP": "{0}MB{1}%",
"ko_KR": "{0}MB ({1}%)",
"no_NO": null,
"pl_PL": null,
"pt_BR": null,
"ru_RU": "{0} МБ ({1}%)",
"sv_SE": null,
"th_TH": null,
"tr_TR": null,
"uk_UA": "{0} МБ ({1}%)",
"zh_CN": "{0} MB{1}%",
"zh_TW": "{0} MB{1}%"
}
},
{
"ID": "SavedLabel",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Saved:",
"es_ES": "Ahorrado:",
"fr_FR": "Économies :",
"he_IL": "",
"it_IT": "Risparmiato:",
"ja_JP": "",
"ko_KR": "",
"no_NO": "",
"pl_PL": "Zaoszczędzone:",
"pt_BR": "Economizado:",
"ru_RU": "Сохранено:",
"sv_SE": "",
"th_TH": "",
"tr_TR": "",
"uk_UA": "Збережено:",
"zh_CN": "",
"zh_TW": ""
}
},
{
"ID": "RemainingLabel",
"Translations": {
"ar_SA": "المتبقية:",
"de_DE": "Verbleibend:",
"el_GR": "Απομένουν:",
"en_US": "Remaining:",
"es_ES": "Restante:",
"fr_FR": "Restant :",
"he_IL": "נותרו:",
"it_IT": "Rimanenti:",
"ja_JP": "残り:",
"ko_KR": "남은:",
"no_NO": "Gjenstående:",
"pl_PL": "Pozostało:",
"pt_BR": "Restante:",
"ru_RU": "Осталось:",
"sv_SE": "Kvar:",
"th_TH": "เหลือ:",
"tr_TR": "Kalan:",
"uk_UA": "Залишилося:",
"zh_CN": "剩余:",
"zh_TW": "剩餘:"
}
},
{
"ID": "MBLabel",
"Translations": {
"ar_SA": null,
"de_DE": null,
"el_GR": null,
"en_US": "{0} MB",
"es_ES": null,
"fr_FR": "{0} Mo",
"he_IL": null,
"it_IT": null,
"ja_JP": "{0}MB",
"ko_KR": "{0}MB",
"no_NO": null,
"pl_PL": null,
"pt_BR": null,
"ru_RU": "{0} МБ",
"sv_SE": null,
"th_TH": null,
"tr_TR": null,
"uk_UA": "{0} МБ",
"zh_CN": null,
"zh_TW": null
}
},
{
"ID": "TrimButton",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Trim",
"es_ES": "Recortar",
"fr_FR": "Réduire",
"he_IL": "",
"it_IT": "Riduci",
"ja_JP": "",
"ko_KR": "트림",
"no_NO": "",
"pl_PL": "Przytnij",
"pt_BR": "Reduzir",
"ru_RU": "Обрезать",
"sv_SE": "Trimma",
"th_TH": "ตัด",
"tr_TR": "Kırp",
"uk_UA": "Обрізати",
"zh_CN": "瘦身",
"zh_TW": "修剪"
}
},
{
"ID": "UntrimButton",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Untrim",
"es_ES": "Desrecortar",
"fr_FR": "Dé-Réduire",
"he_IL": "",
"it_IT": "Ripristina",
"ja_JP": "",
"ko_KR": "언트림",
"no_NO": "Utrim",
"pl_PL": "",
"pt_BR": "Restaurar",
"ru_RU": "Восстановить",
"sv_SE": "Avoptimera",
"th_TH": "กู้คืน",
"tr_TR": "",
"uk_UA": "Відновити",
"zh_CN": "取消精简",
"zh_TW": "反修剪"
}
}
]
}

View File

@@ -0,0 +1,11 @@
namespace Ryujinx.Common.Configuration.Hid
{
public class AssignedInputDevice
{
public AssignedInputDeviceType Type { get; set; }
public string Id { get; set; }
public string ProfileName { get; set; }
}
}

View File

@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;
namespace Ryujinx.Common.Configuration.Hid
{
[JsonConverter(typeof(JsonStringEnumConverter<AssignedInputDeviceType>))]
public enum AssignedInputDeviceType
{
Keyboard,
Controller,
}
}

View File

@@ -36,6 +36,12 @@ namespace Ryujinx.Common.Configuration.Hid
/// </summary>
public PlayerIndex PlayerIndex { get; set; }
/// <summary>
/// Allow a keyboard configuration to temporarily promote to a connected gamepad,
/// while preserving the existing keyboard fallback path when that gamepad disappears.
/// </summary>
public bool EnableDynamicGamepadSwap { get; set; }
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)

View File

@@ -0,0 +1,13 @@
using System.Collections.Generic;
namespace Ryujinx.Common.Configuration.Hid
{
public class PlayerInputAssignment
{
public PlayerIndex PlayerIndex { get; set; }
public bool EnableDynamicInputSwap { get; set; }
public List<AssignedInputDevice> Devices { get; set; } = [];
}
}

View File

@@ -0,0 +1,166 @@
using Ryujinx.Common.Configuration.Hid.Keyboard;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Ryujinx.Common.Configuration.Hid
{
public static class PlayerInputAssignmentHelper
{
public static AssignedInputDevice CreatePrimaryDevice(InputConfig inputConfig)
{
if (inputConfig == null || string.IsNullOrWhiteSpace(inputConfig.Id))
{
return null;
}
return new AssignedInputDevice
{
Type = inputConfig is StandardKeyboardInputConfig
? AssignedInputDeviceType.Keyboard
: AssignedInputDeviceType.Controller,
Id = inputConfig.Id,
};
}
public static PlayerInputAssignment Normalize(PlayerInputAssignment assignment, AssignedInputDevice preferredDevice = null)
{
if (assignment == null)
{
return null;
}
PlayerInputAssignment normalized = new()
{
PlayerIndex = assignment.PlayerIndex,
EnableDynamicInputSwap = assignment.EnableDynamicInputSwap,
};
List<AssignedInputDevice> distinctDevices = Deduplicate(assignment.Devices);
if (assignment.EnableDynamicInputSwap)
{
normalized.Devices.AddRange(distinctDevices.Select(Clone));
return normalized;
}
AssignedInputDevice primaryDevice = SelectPrimaryDevice(distinctDevices, preferredDevice) ?? Clone(preferredDevice);
if (primaryDevice != null)
{
normalized.Devices.Add(Clone(primaryDevice));
}
return normalized;
}
public static bool AreEquivalent(
PlayerInputAssignment left,
PlayerInputAssignment right,
AssignedInputDevice leftPreferredDevice = null,
AssignedInputDevice rightPreferredDevice = null)
{
if (left == null || right == null)
{
return left == right;
}
PlayerInputAssignment normalizedLeft = Normalize(left, leftPreferredDevice);
PlayerInputAssignment normalizedRight = Normalize(right, rightPreferredDevice);
if (normalizedLeft.EnableDynamicInputSwap != normalizedRight.EnableDynamicInputSwap)
{
return false;
}
List<(AssignedInputDeviceType Type, string Id, string ProfileName)> leftDevices = normalizedLeft.Devices
.Select(device => (Type: device.Type, Id: device.Id, ProfileName: device.ProfileName ?? string.Empty))
.OrderBy(device => device.Type)
.ThenBy(device => device.Id, StringComparer.Ordinal)
.ThenBy(device => device.ProfileName, StringComparer.Ordinal)
.ToList();
List<(AssignedInputDeviceType Type, string Id, string ProfileName)> rightDevices = normalizedRight.Devices
.Select(device => (Type: device.Type, Id: device.Id, ProfileName: device.ProfileName ?? string.Empty))
.OrderBy(device => device.Type)
.ThenBy(device => device.Id, StringComparer.Ordinal)
.ThenBy(device => device.ProfileName, StringComparer.Ordinal)
.ToList();
return leftDevices.SequenceEqual(rightDevices);
}
private static List<AssignedInputDevice> Deduplicate(IEnumerable<AssignedInputDevice> devices)
{
List<AssignedInputDevice> result = [];
if (devices == null)
{
return result;
}
foreach (AssignedInputDevice device in devices)
{
if (device == null || string.IsNullOrWhiteSpace(device.Id))
{
continue;
}
int existingIndex = result.FindIndex(existing =>
existing.Type == device.Type &&
string.Equals(existing.Id, device.Id, StringComparison.Ordinal));
if (existingIndex == -1)
{
result.Add(Clone(device));
continue;
}
if (!string.IsNullOrWhiteSpace(device.ProfileName) ||
string.IsNullOrWhiteSpace(result[existingIndex].ProfileName))
{
result[existingIndex].ProfileName = device.ProfileName;
}
}
return result;
}
private static AssignedInputDevice SelectPrimaryDevice(List<AssignedInputDevice> devices, AssignedInputDevice preferredDevice)
{
if (devices == null || devices.Count == 0)
{
return null;
}
if (preferredDevice != null)
{
AssignedInputDevice matchedDevice = devices.FirstOrDefault(device =>
device.Type == preferredDevice.Type &&
string.Equals(device.Id, preferredDevice.Id, StringComparison.Ordinal));
if (matchedDevice != null)
{
return matchedDevice;
}
}
return devices[0];
}
private static AssignedInputDevice Clone(AssignedInputDevice device)
{
if (device == null)
{
return null;
}
return new AssignedInputDevice
{
Type = device.Type,
Id = device.Id,
ProfileName = device.ProfileName,
};
}
}
}

View File

@@ -275,7 +275,7 @@ namespace Ryujinx.Input.SDL3
{
_configuration = (StandardControllerInputConfig)configuration;
if ((Features & GamepadFeaturesFlag.Led) != 0 && _configuration.Led.EnableLed)
if ((Features & GamepadFeaturesFlag.Led) != 0 && _configuration.Led?.EnableLed == true)
{
if (_configuration.Led.TurnOffLed)
(this as IGamepad).ClearLed();

View File

@@ -1,14 +1,20 @@
using Ryujinx.Common;
using Ryujinx.Common.Configuration;
using Ryujinx.Common.Configuration.Hid;
using Ryujinx.Common.Configuration.Hid.Controller;
using Ryujinx.Common.Configuration.Hid.Controller.Motion;
using Ryujinx.Common.Configuration.Hid.Keyboard;
using Ryujinx.Common.Logging;
using Ryujinx.Common.Utilities;
using Ryujinx.HLE.HOS.Services.Hid;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Text.Json;
using CemuHookClient = Ryujinx.Input.Motion.CemuHook.Client;
using ConfigControllerType = Ryujinx.Common.Configuration.Hid.ControllerType;
@@ -16,6 +22,9 @@ namespace Ryujinx.Input.HLE
{
public class NpadController : IDisposable
{
private const string KeyboardString = "keyboard";
private const string ControllerString = "controller";
private class HLEButtonMappingEntry
{
public readonly GamepadButtonInputId DriverInputId;
@@ -211,14 +220,40 @@ namespace Ryujinx.Input.HLE
private MotionInput _rightMotionInput;
private IGamepad _gamepad;
private IGamepad _keyboardGamepad;
private IGamepad _controllerGamepad;
private readonly List<IGamepad> _assignedControllerGamepads = [];
private readonly List<StandardControllerInputConfig> _assignedControllerConfigs = [];
private InputConfig _config;
private InputConfig _activeConfig;
private StandardKeyboardInputConfig _keyboardConfig;
private StandardControllerInputConfig _controllerConfig;
private GamepadStateSnapshot _previousKeyboardState;
private readonly List<GamepadStateSnapshot> _previousControllerStates = [];
private DynamicInputSource _activeInputSource;
private PlayerInputAssignment _playerInputAssignment;
private bool _singleUsesKeyboardDriver;
private IGamepadDriver _keyboardDriver;
private IGamepadDriver _controllerDriver;
private int _activeControllerIndex = -1;
public IGamepadDriver GamepadDriver { get; private set; }
public GamepadStateSnapshot State { get; private set; }
public InputConfig ActiveConfig => _activeConfig;
public string Id { get; private set; }
public bool IsAvailable => _gamepad != null || _keyboardGamepad != null || _assignedControllerGamepads.Count > 0;
private readonly CemuHookClient _cemuHookClient;
private static readonly InputConfigJsonSerializerContext _serializerContext = new(JsonHelper.GetDefaultSerializerOptions());
private enum DynamicInputSource
{
None,
Keyboard,
Controller,
}
public NpadController(CemuHookClient cemuHookClient)
{
@@ -227,31 +262,114 @@ namespace Ryujinx.Input.HLE
_cemuHookClient = cemuHookClient;
}
public bool UpdateDriverConfiguration(IGamepadDriver gamepadDriver, InputConfig config)
public bool MatchesDriverConfiguration(InputConfig config, PlayerInputAssignment playerInputAssignment)
{
GamepadDriver = gamepadDriver;
if (_config?.EnableDynamicGamepadSwap != config.EnableDynamicGamepadSwap)
{
return false;
}
_gamepad?.Dispose();
if (playerInputAssignment?.EnableDynamicInputSwap == true)
{
if (_playerInputAssignment == null || _playerInputAssignment.EnableDynamicInputSwap != playerInputAssignment.EnableDynamicInputSwap)
{
return false;
}
Id = config.Id;
_gamepad = config is StandardKeyboardInputConfig && GamepadDriver is IKeyboardModeDriver keyboardModeDriver
? keyboardModeDriver.GetKeyboard(Id, KeyboardInputMode.Physical)
: GamepadDriver.GetGamepad(Id);
return PlayerInputAssignmentHelper.AreEquivalent(_playerInputAssignment, playerInputAssignment);
}
return _singleUsesKeyboardDriver == (config is StandardKeyboardInputConfig) &&
Id == config.Id;
}
public bool UpdateDriverConfiguration(IGamepadDriver keyboardDriver, IGamepadDriver gamepadDriver, InputConfig config, PlayerInputAssignment playerInputAssignment)
{
_keyboardDriver = keyboardDriver;
_controllerDriver = gamepadDriver;
_playerInputAssignment = playerInputAssignment;
DisposeOpenedGamepads();
_gamepad = null;
_keyboardGamepad = null;
_controllerGamepad = null;
_assignedControllerGamepads.Clear();
_assignedControllerConfigs.Clear();
_previousKeyboardState = default;
_previousControllerStates.Clear();
_activeInputSource = DynamicInputSource.None;
_activeControllerIndex = -1;
if (playerInputAssignment?.EnableDynamicInputSwap == true)
{
ConfigureDynamicGamepads(keyboardDriver, gamepadDriver, config);
}
else
{
_singleUsesKeyboardDriver = config is StandardKeyboardInputConfig;
GamepadDriver = _singleUsesKeyboardDriver ? keyboardDriver : gamepadDriver;
Id = config.Id;
_gamepad = OpenSingleGamepad(GamepadDriver, config.Id, _singleUsesKeyboardDriver);
}
UpdateUserConfiguration(config);
return _gamepad != null;
return IsAvailable;
}
public void UpdateUserConfiguration(InputConfig config)
{
InputConfig oldConfig = _config;
if (_playerInputAssignment?.EnableDynamicInputSwap == true)
{
StandardControllerInputConfig oldControllerConfig = _controllerConfig;
_config = config;
UpdateDynamicConfigurations(config);
if (_controllerConfig?.Motion == null)
{
_leftMotionInput = null;
_rightMotionInput = null;
}
else if (NeedsMotionInputUpdate(oldControllerConfig, _controllerConfig))
{
UpdateMotionInput(_controllerConfig.Motion);
}
if (_keyboardConfig != null)
{
_keyboardGamepad?.SetConfiguration(_keyboardConfig);
}
for (int i = 0; i < _assignedControllerGamepads.Count; i++)
{
StandardControllerInputConfig assignedControllerConfig = i < _assignedControllerConfigs.Count
? _assignedControllerConfigs[i]
: _controllerConfig;
if (assignedControllerConfig != null)
{
_assignedControllerGamepads[i].SetConfiguration(assignedControllerConfig);
}
}
UpdateActiveGamepad();
return;
}
_config = config;
if (config is StandardControllerInputConfig controllerConfig)
{
bool needsMotionInputUpdate = _config is not StandardControllerInputConfig oldControllerConfig ||
((oldControllerConfig.Motion.EnableMotion != controllerConfig.Motion.EnableMotion) &&
(oldControllerConfig.Motion.MotionBackend != controllerConfig.Motion.MotionBackend));
if (needsMotionInputUpdate)
if (controllerConfig.Motion == null)
{
_leftMotionInput = null;
_rightMotionInput = null;
}
else if (NeedsMotionInputUpdate(oldConfig as StandardControllerInputConfig, controllerConfig))
{
UpdateMotionInput(controllerConfig.Motion);
}
@@ -260,15 +378,23 @@ namespace Ryujinx.Input.HLE
{
// Non-controller doesn't have motions.
_leftMotionInput = null;
_rightMotionInput = null;
}
_config = config;
_activeConfig = config;
_gamepad?.SetConfiguration(config);
}
private void UpdateMotionInput(MotionConfigController motionConfig)
{
if (motionConfig == null)
{
_leftMotionInput = null;
_rightMotionInput = null;
return;
}
if (motionConfig.MotionBackend != MotionInputBackendType.CemuHook)
{
_leftMotionInput = new MotionInput();
@@ -281,72 +407,50 @@ namespace Ryujinx.Input.HLE
}
}
private bool NeedsMotionInputUpdate(StandardControllerInputConfig oldConfig, StandardControllerInputConfig newConfig)
{
if (newConfig?.Motion == null)
{
return false;
}
bool motionWasDisabled = oldConfig?.Motion == null;
bool leftMotionMissing = _leftMotionInput == null;
bool isJoyconPairNeedingRightMotion = newConfig.ControllerType == ConfigControllerType.JoyconPair && _rightMotionInput == null;
bool motionEnabledChanged = oldConfig.Motion.EnableMotion != newConfig.Motion.EnableMotion;
bool motionBackendChanged = oldConfig.Motion.MotionBackend != newConfig.Motion.MotionBackend;
return motionWasDisabled ||
leftMotionMissing ||
isJoyconPairNeedingRightMotion ||
motionEnabledChanged ||
motionBackendChanged;
}
public void Update()
{
if (_playerInputAssignment?.EnableDynamicInputSwap == true)
{
UpdateDynamic();
return;
}
// _gamepad may be altered by other threads
IGamepad gamepad = _gamepad;
if (gamepad != null && GamepadDriver != null)
{
State = gamepad.GetMappedStateSnapshot();
_activeConfig = _config;
if (_config is StandardControllerInputConfig controllerConfig && controllerConfig.Motion.EnableMotion)
if (_activeConfig is StandardControllerInputConfig controllerConfig && controllerConfig.Motion?.EnableMotion == true)
{
if (controllerConfig.Motion.MotionBackend == MotionInputBackendType.GamepadDriver)
{
if ((gamepad.Features & GamepadFeaturesFlag.Motion) != 0)
{
Vector3 accelerometer = gamepad.GetMotionData(MotionInputId.Accelerometer);
Vector3 gyroscope = gamepad.GetMotionData(MotionInputId.Gyroscope);
accelerometer = new Vector3(accelerometer.X, -accelerometer.Z, accelerometer.Y);
gyroscope = new Vector3(gyroscope.X, -gyroscope.Z, gyroscope.Y);
_leftMotionInput.Update(accelerometer, gyroscope, (ulong)PerformanceCounter.ElapsedNanoseconds / 1000, controllerConfig.Motion.Sensitivity, (float)controllerConfig.Motion.GyroDeadzone);
if (controllerConfig.ControllerType == ConfigControllerType.JoyconPair)
{
if (gamepad.Id == "JoyConPair")
{
Vector3 rightAccelerometer = gamepad.GetMotionData(MotionInputId.SecondAccelerometer);
Vector3 rightGyroscope = gamepad.GetMotionData(MotionInputId.SecondGyroscope);
rightAccelerometer = new Vector3(rightAccelerometer.X, -rightAccelerometer.Z, rightAccelerometer.Y);
rightGyroscope = new Vector3(rightGyroscope.X, -rightGyroscope.Z, rightGyroscope.Y);
_rightMotionInput.Update(rightAccelerometer, rightGyroscope, (ulong)PerformanceCounter.ElapsedNanoseconds / 1000, controllerConfig.Motion.Sensitivity, (float)controllerConfig.Motion.GyroDeadzone);
}
else
{
_rightMotionInput = _leftMotionInput;
}
}
}
}
else if (controllerConfig.Motion.MotionBackend == MotionInputBackendType.CemuHook && controllerConfig.Motion is CemuHookMotionConfigController cemuControllerConfig)
{
int clientId = (int)controllerConfig.PlayerIndex;
// First of all ensure we are registered
_cemuHookClient.RegisterClient(clientId, cemuControllerConfig.DsuServerHost, cemuControllerConfig.DsuServerPort);
// Then request and retrieve the data
_cemuHookClient.RequestData(clientId, cemuControllerConfig.Slot);
_cemuHookClient.TryGetData(clientId, cemuControllerConfig.Slot, out _leftMotionInput);
if (controllerConfig.ControllerType == ConfigControllerType.JoyconPair)
{
if (!cemuControllerConfig.MirrorInput)
{
_cemuHookClient.RequestData(clientId, cemuControllerConfig.AltSlot);
_cemuHookClient.TryGetData(clientId, cemuControllerConfig.AltSlot, out _rightMotionInput);
}
else
{
_rightMotionInput = _leftMotionInput;
}
}
}
UpdateControllerMotion(gamepad, controllerConfig);
}
else
{
_leftMotionInput = null;
_rightMotionInput = null;
}
}
else
@@ -371,7 +475,7 @@ namespace Ryujinx.Input.HLE
}
}
if (_gamepad is IKeyboard)
if (_activeConfig is StandardKeyboardInputConfig)
{
(float leftAxisX, float leftAxisY) = State.GetStick(StickInputId.Left);
(float rightAxisX, float rightAxisY) = State.GetStick(StickInputId.Right);
@@ -388,7 +492,7 @@ namespace Ryujinx.Input.HLE
Dy = ClampAxis(rightAxisY),
};
}
else if (_config is StandardControllerInputConfig controllerConfig)
else if (_activeConfig is StandardControllerInputConfig controllerConfig)
{
(float leftAxisX, float leftAxisY) = State.GetStick(StickInputId.Left);
(float rightAxisX, float rightAxisY) = State.GetStick(StickInputId.Right);
@@ -509,9 +613,12 @@ namespace Ryujinx.Input.HLE
return value;
}
public static KeyboardInput GetHLEKeyboardInput(IGamepadDriver KeyboardDriver)
public static KeyboardInput GetHLEKeyboardInput(IGamepadDriver keyboardDriver)
{
IKeyboard keyboard = KeyboardDriver.GetGamepad("0") as IKeyboard;
if (keyboardDriver.GetGamepad("0") is not IKeyboard keyboard)
{
return default;
}
KeyboardStateSnapshot keyboardState = keyboard.GetKeyboardStateSnapshot();
@@ -543,7 +650,7 @@ namespace Ryujinx.Input.HLE
{
if (disposing)
{
_gamepad?.Dispose();
DisposeOpenedGamepads();
}
}
@@ -557,38 +664,565 @@ namespace Ryujinx.Input.HLE
{
if (queue.TryDequeue(out (VibrationValue, VibrationValue) dualVibrationValue))
{
if (_config is not StandardControllerInputConfig controllerConfig ||
!controllerConfig.Rumble.EnableRumble)
if (_controllerConfig is StandardControllerInputConfig dynamicControllerConfig &&
_playerInputAssignment?.EnableDynamicInputSwap == true &&
dynamicControllerConfig.Rumble?.EnableRumble == true)
{
return;
ApplyRumble(_controllerGamepad ?? _assignedControllerGamepads.FirstOrDefault(), dynamicControllerConfig, dualVibrationValue);
}
else if (_config is StandardControllerInputConfig controllerConfig && controllerConfig.Rumble?.EnableRumble == true)
{
ApplyRumble(_gamepad, controllerConfig, dualVibrationValue);
}
}
}
public bool HasAssignedControllerId(string id)
{
if (string.IsNullOrEmpty(id))
{
return false;
}
if (_playerInputAssignment?.EnableDynamicInputSwap == true)
{
return _assignedControllerGamepads.Any(gamepad => gamepad?.Id == id);
}
return Id == id;
}
private void ApplyRumble(IGamepad gamepad, StandardControllerInputConfig controllerConfig, (VibrationValue, VibrationValue) dualVibrationValue)
{
if (gamepad == null)
{
return;
}
VibrationValue leftVibrationValue = dualVibrationValue.Item1;
VibrationValue rightVibrationValue = dualVibrationValue.Item2;
leftVibrationValue.AmplitudeLow *= controllerConfig.Rumble.WeakRumble;
leftVibrationValue.AmplitudeHigh *= controllerConfig.Rumble.StrongRumble;
rightVibrationValue.AmplitudeLow *= controllerConfig.Rumble.WeakRumble;
rightVibrationValue.AmplitudeHigh *= controllerConfig.Rumble.StrongRumble;
if (!controllerConfig.Rumble.UseHDRumble || gamepad.HDRumble(leftVibrationValue, rightVibrationValue) == false)
{
float low = Math.Min(1f, (float)(rightVibrationValue.AmplitudeLow * 0.85 + rightVibrationValue.AmplitudeHigh * 0.15));
float high = Math.Min(1f, (float)(leftVibrationValue.AmplitudeLow * 0.15 + leftVibrationValue.AmplitudeHigh * 0.85));
gamepad.Rumble(low, high, 0xFFFFFFFF);
}
Logger.Debug?.Print(LogClass.Hid, $"Effect for {controllerConfig.PlayerIndex} " +
// Value=value/multiplier * multiplier (result)
$"L.low.amp={leftVibrationValue.AmplitudeLow / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.AmplitudeLow}), " +
$"L.high.amp={leftVibrationValue.AmplitudeHigh / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.AmplitudeHigh}), " +
$"L.low.freq={leftVibrationValue.FrequencyLow / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.FrequencyLow}), " +
$"L.high.freq={leftVibrationValue.FrequencyHigh / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.FrequencyHigh}), " +
$"R.low.amp={rightVibrationValue.AmplitudeLow / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.AmplitudeLow}), " +
$"R.high.amp={rightVibrationValue.AmplitudeHigh / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.AmplitudeHigh}), " +
$"R.low.freq={rightVibrationValue.FrequencyLow / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.FrequencyLow}), " +
$"R.high.freq={rightVibrationValue.FrequencyHigh / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.FrequencyHigh})");
}
private void ConfigureDynamicGamepads(IGamepadDriver keyboardDriver, IGamepadDriver gamepadDriver, InputConfig config)
{
AssignedInputDevice assignedKeyboard = _playerInputAssignment?.Devices.FirstOrDefault(device => device.Type == AssignedInputDeviceType.Keyboard);
if (!string.IsNullOrEmpty(assignedKeyboard?.Id))
{
_keyboardGamepad = OpenSingleGamepad(keyboardDriver, assignedKeyboard.Id, true);
}
foreach (AssignedInputDevice assignedController in ResolveDynamicControllerAssignments(gamepadDriver, config))
{
IGamepad controllerGamepad = OpenSingleGamepad(gamepadDriver, assignedController.Id, false);
if (controllerGamepad != null)
{
_assignedControllerGamepads.Add(controllerGamepad);
_assignedControllerConfigs.Add(null);
_previousControllerStates.Add(default);
}
}
_controllerGamepad = _assignedControllerGamepads.FirstOrDefault();
GamepadDriver = null;
Id = _assignedControllerGamepads.FirstOrDefault()?.Id ?? config.Id;
}
private IEnumerable<AssignedInputDevice> ResolveDynamicControllerAssignments(IGamepadDriver gamepadDriver, InputConfig config)
{
if (gamepadDriver == null)
{
yield break;
}
List<AssignedInputDevice> assignedControllers = _playerInputAssignment?.Devices
.Where(device => device.Type == AssignedInputDeviceType.Controller)
.ToList() ?? [];
if (_playerInputAssignment?.EnableDynamicInputSwap == true)
{
foreach (AssignedInputDevice assignedController in assignedControllers)
{
foreach (string gamepadId in gamepadDriver.GamepadsIds)
{
if (gamepadId == assignedController.Id)
{
yield return assignedController;
break;
}
}
}
VibrationValue leftVibrationValue = dualVibrationValue.Item1;
VibrationValue rightVibrationValue = dualVibrationValue.Item2;
yield break;
}
leftVibrationValue.AmplitudeLow *= controllerConfig.Rumble.WeakRumble;
leftVibrationValue.AmplitudeHigh *= controllerConfig.Rumble.StrongRumble;
rightVibrationValue.AmplitudeLow *= controllerConfig.Rumble.WeakRumble;
rightVibrationValue.AmplitudeHigh *= controllerConfig.Rumble.StrongRumble;
if (!controllerConfig.Rumble.UseHDRumble || _gamepad?.HDRumble(leftVibrationValue, rightVibrationValue) == false)
if (config is StandardControllerInputConfig)
{
foreach (string gamepadId in gamepadDriver.GamepadsIds)
{
float low = Math.Min(1f, (float)((rightVibrationValue.AmplitudeLow * 0.85 + rightVibrationValue.AmplitudeHigh * 0.15)));
float high = Math.Min(1f, (float)((leftVibrationValue.AmplitudeLow * 0.15 + leftVibrationValue.AmplitudeHigh * 0.85)));
_gamepad?.Rumble(low, high, 0xFFFFFFFF);
if (gamepadId == config.Id)
{
yield return new AssignedInputDevice
{
Type = AssignedInputDeviceType.Controller,
Id = gamepadId,
};
yield break;
}
}
}
if (!gamepadDriver.GamepadsIds.IsEmpty)
{
yield return new AssignedInputDevice
{
Type = AssignedInputDeviceType.Controller,
Id = gamepadDriver.GamepadsIds[0],
};
}
}
private static IGamepad OpenSingleGamepad(IGamepadDriver driver, string id, bool keyboard)
{
if (driver == null || string.IsNullOrEmpty(id))
{
return null;
}
if (keyboard && driver is IKeyboardModeDriver keyboardModeDriver)
{
return keyboardModeDriver.GetKeyboard(id, KeyboardInputMode.Physical);
}
return driver.GetGamepad(id);
}
private void UpdateDynamicConfigurations(InputConfig config)
{
if (config is StandardKeyboardInputConfig keyboardConfig)
{
AssignedInputDevice assignedKeyboard = _playerInputAssignment?.Devices.FirstOrDefault(device => device.Type == AssignedInputDeviceType.Keyboard);
_keyboardConfig = ResolveKeyboardConfiguration(assignedKeyboard, keyboardConfig, _keyboardGamepad);
_assignedControllerConfigs.Clear();
foreach (IGamepad controllerGamepad in _assignedControllerGamepads)
{
AssignedInputDevice assignedController = _playerInputAssignment?.Devices.FirstOrDefault(device =>
device.Type == AssignedInputDeviceType.Controller &&
device.Id == controllerGamepad.Id);
_assignedControllerConfigs.Add(ResolveControllerConfiguration(assignedController, keyboardConfig, controllerGamepad));
}
Logger.Debug?.Print(LogClass.Hid, $"Effect for {controllerConfig.PlayerIndex} " +
// Value=value/multiplier * multiplier (result)
$"L.low.amp={leftVibrationValue.AmplitudeLow / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.AmplitudeLow}), " +
$"L.high.amp={leftVibrationValue.AmplitudeHigh / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({leftVibrationValue.AmplitudeHigh}), " +
$"L.low.freq={leftVibrationValue.FrequencyLow / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.FrequencyLow}), " +
$"L.high.freq={leftVibrationValue.FrequencyHigh / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({leftVibrationValue.FrequencyHigh}), " +
$"R.low.amp={rightVibrationValue.AmplitudeLow / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({rightVibrationValue.AmplitudeLow}), " +
$"R.high.amp={rightVibrationValue.AmplitudeHigh / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.AmplitudeHigh}), " +
$"R.low.freq={rightVibrationValue.FrequencyLow / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({rightVibrationValue.FrequencyLow}), " +
$"R.high.freq={rightVibrationValue.FrequencyHigh / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.FrequencyHigh})");
_controllerConfig = _assignedControllerConfigs.FirstOrDefault();
}
else if (config is StandardControllerInputConfig controllerConfig)
{
_assignedControllerConfigs.Clear();
foreach (IGamepad controllerGamepad in _assignedControllerGamepads)
{
AssignedInputDevice assignedController = _playerInputAssignment?.Devices.FirstOrDefault(device =>
device.Type == AssignedInputDeviceType.Controller &&
device.Id == controllerGamepad.Id);
_assignedControllerConfigs.Add(ResolveControllerConfiguration(assignedController, controllerConfig, controllerGamepad));
}
_controllerConfig = _assignedControllerConfigs.FirstOrDefault() ?? controllerConfig;
if (_keyboardGamepad != null)
{
AssignedInputDevice assignedKeyboard = _playerInputAssignment?.Devices.FirstOrDefault(device => device.Type == AssignedInputDeviceType.Keyboard);
_keyboardConfig = ResolveKeyboardConfiguration(assignedKeyboard, controllerConfig, _keyboardGamepad);
}
else
{
_keyboardConfig = null;
}
}
}
private StandardKeyboardInputConfig ResolveKeyboardConfiguration(AssignedInputDevice assignedKeyboard, InputConfig baseConfig, IGamepad keyboardGamepad)
{
if (keyboardGamepad == null)
{
return null;
}
if (TryLoadAssignedProfile<StandardKeyboardInputConfig>(assignedKeyboard, KeyboardString, keyboardGamepad, baseConfig, out StandardKeyboardInputConfig profileConfig))
{
return profileConfig;
}
if (baseConfig is StandardKeyboardInputConfig keyboardBaseConfig)
{
StandardKeyboardInputConfig clonedConfig = CloneConfig(keyboardBaseConfig);
if (clonedConfig != null)
{
clonedConfig.Id = keyboardGamepad.Id;
clonedConfig.Name = keyboardGamepad.Name;
clonedConfig.PlayerIndex = baseConfig.PlayerIndex;
clonedConfig.EnableDynamicGamepadSwap = true;
return clonedConfig;
}
}
StandardKeyboardInputConfig defaultConfig = InputConfigDefaults.CreateDefaultKeyboardConfiguration(
keyboardGamepad.Id,
keyboardGamepad.Name,
baseConfig.ControllerType,
baseConfig.PlayerIndex);
defaultConfig.EnableDynamicGamepadSwap = true;
return defaultConfig;
}
private StandardControllerInputConfig ResolveControllerConfiguration(AssignedInputDevice assignedController, InputConfig baseConfig, IGamepad controllerGamepad)
{
if (controllerGamepad == null)
{
return null;
}
if (TryLoadAssignedProfile<StandardControllerInputConfig>(assignedController, ControllerString, controllerGamepad, baseConfig, out StandardControllerInputConfig profileConfig))
{
return profileConfig;
}
if (baseConfig is StandardControllerInputConfig controllerBaseConfig)
{
StandardControllerInputConfig clonedConfig = CloneConfig(controllerBaseConfig);
if (clonedConfig != null)
{
clonedConfig.Id = controllerGamepad.Id;
clonedConfig.Name = controllerGamepad.Name;
clonedConfig.PlayerIndex = baseConfig.PlayerIndex;
clonedConfig.EnableDynamicGamepadSwap = true;
return clonedConfig;
}
}
StandardControllerInputConfig defaultConfig = InputConfigDefaults.CreateDefaultControllerConfiguration(
controllerGamepad.Id,
controllerGamepad.Name,
baseConfig.ControllerType,
baseConfig.PlayerIndex,
controllerGamepad.Name?.Contains("Nintendo") == true);
defaultConfig.EnableDynamicGamepadSwap = true;
return defaultConfig;
}
private static T CloneConfig<T>(T config) where T : InputConfig
{
return JsonHelper.Deserialize(
JsonHelper.Serialize(config, _serializerContext.InputConfig),
_serializerContext.InputConfig) as T;
}
private static bool TryLoadAssignedProfile<T>(AssignedInputDevice assignedDevice, string profileDirectory, IGamepad gamepad, InputConfig baseConfig, out T config)
where T : InputConfig
{
config = null;
if (string.IsNullOrWhiteSpace(assignedDevice?.ProfileName))
{
return false;
}
string path = Path.Combine(AppDataManager.ProfilesDirPath, profileDirectory, assignedDevice.ProfileName + ".json");
if (!File.Exists(path))
{
return false;
}
try
{
config = JsonHelper.DeserializeFromFile(path, _serializerContext.InputConfig) as T;
}
catch (JsonException)
{
return false;
}
catch (InvalidOperationException)
{
return false;
}
if (config == null)
{
return false;
}
config.Id = gamepad.Id;
config.Name = gamepad.Name;
config.PlayerIndex = baseConfig.PlayerIndex;
config.EnableDynamicGamepadSwap = true;
return true;
}
private void UpdateDynamic()
{
GamepadStateSnapshot keyboardState = _keyboardGamepad?.GetMappedStateSnapshot() ?? default;
bool keyboardHasInput = _keyboardGamepad != null && HasInput(keyboardState);
bool keyboardNewInput = _keyboardGamepad != null && HasNewInput(keyboardState, _previousKeyboardState);
int controllerWithNewInput = -1;
int controllerWithHeldInput = -1;
// Note: dynamic swap is "last input wins", so we scan every assigned controller
// and promote whichever one most recently produced a meaningful state change.
for (int i = 0; i < _assignedControllerGamepads.Count; i++)
{
IGamepad controllerGamepad = _assignedControllerGamepads[i];
GamepadStateSnapshot controllerState = controllerGamepad?.GetMappedStateSnapshot() ?? default;
if (HasNewInput(controllerState, _previousControllerStates[i]))
{
controllerWithNewInput = i;
}
if (controllerWithHeldInput == -1 && HasInput(controllerState))
{
controllerWithHeldInput = i;
}
_previousControllerStates[i] = controllerState;
}
if (keyboardNewInput && controllerWithNewInput == -1)
{
_activeInputSource = DynamicInputSource.Keyboard;
}
else if (controllerWithNewInput != -1 && !keyboardNewInput)
{
_activeInputSource = DynamicInputSource.Controller;
_activeControllerIndex = controllerWithNewInput;
}
else if (_activeInputSource == DynamicInputSource.Keyboard && !keyboardHasInput && controllerWithHeldInput != -1)
{
_activeInputSource = DynamicInputSource.Controller;
_activeControllerIndex = controllerWithHeldInput;
}
else if (_activeInputSource == DynamicInputSource.Controller && controllerWithHeldInput == -1 && keyboardHasInput)
{
_activeInputSource = DynamicInputSource.Keyboard;
}
else if (_activeInputSource == DynamicInputSource.None)
{
_activeInputSource = _config switch
{
StandardKeyboardInputConfig when _keyboardGamepad != null => DynamicInputSource.Keyboard,
StandardControllerInputConfig when _assignedControllerGamepads.Count > 0 => DynamicInputSource.Controller,
_ when keyboardHasInput => DynamicInputSource.Keyboard,
_ when controllerWithHeldInput != -1 => DynamicInputSource.Controller,
_ when _keyboardGamepad != null => DynamicInputSource.Keyboard,
_ when _assignedControllerGamepads.Count > 0 => DynamicInputSource.Controller,
_ => DynamicInputSource.None,
};
if (_activeInputSource == DynamicInputSource.Controller)
{
_activeControllerIndex = controllerWithHeldInput != -1 ? controllerWithHeldInput : 0;
}
}
UpdateActiveGamepad();
State = _activeInputSource switch
{
DynamicInputSource.Keyboard => keyboardState,
DynamicInputSource.Controller when _activeControllerIndex >= 0 && _activeControllerIndex < _previousControllerStates.Count => _previousControllerStates[_activeControllerIndex],
_ => default,
};
if (_activeConfig is StandardControllerInputConfig controllerConfig && _controllerGamepad != null && _activeInputSource == DynamicInputSource.Controller)
{
UpdateControllerMotion(_controllerGamepad, controllerConfig);
}
else
{
_leftMotionInput = null;
_rightMotionInput = null;
}
_previousKeyboardState = keyboardState;
}
private void UpdateActiveGamepad()
{
(_gamepad, _activeConfig, GamepadDriver) = _activeInputSource switch
{
DynamicInputSource.Keyboard => (_keyboardGamepad, _keyboardConfig, _keyboardDriver),
DynamicInputSource.Controller =>
(
_activeControllerIndex >= 0 && _activeControllerIndex < _assignedControllerGamepads.Count
? _assignedControllerGamepads[_activeControllerIndex]
: _assignedControllerGamepads.FirstOrDefault(),
_activeControllerIndex >= 0 && _activeControllerIndex < _assignedControllerConfigs.Count
? _assignedControllerConfigs[_activeControllerIndex]
: _assignedControllerConfigs.FirstOrDefault(),
_controllerDriver
),
_ => ((IGamepad?)null, (InputConfig?)null, (IGamepadDriver?)null)
};
_controllerGamepad = _gamepad;
}
private void UpdateControllerMotion(IGamepad gamepad, StandardControllerInputConfig controllerConfig)
{
if (gamepad == null || controllerConfig?.Motion == null || !controllerConfig.Motion.EnableMotion)
{
_leftMotionInput = null;
_rightMotionInput = null;
return;
}
if (controllerConfig.Motion.MotionBackend == MotionInputBackendType.GamepadDriver)
{
if ((gamepad.Features & GamepadFeaturesFlag.Motion) != 0)
{
_leftMotionInput ??= new MotionInput();
_rightMotionInput ??= new MotionInput();
Vector3 accelerometer = gamepad.GetMotionData(MotionInputId.Accelerometer);
Vector3 gyroscope = gamepad.GetMotionData(MotionInputId.Gyroscope);
accelerometer = new Vector3(accelerometer.X, -accelerometer.Z, accelerometer.Y);
gyroscope = new Vector3(gyroscope.X, -gyroscope.Z, gyroscope.Y);
_leftMotionInput.Update(accelerometer, gyroscope, (ulong)PerformanceCounter.ElapsedNanoseconds / 1000, controllerConfig.Motion.Sensitivity, (float)controllerConfig.Motion.GyroDeadzone);
if (controllerConfig.ControllerType == ConfigControllerType.JoyconPair)
{
if (gamepad.Id == "JoyConPair")
{
Vector3 rightAccelerometer = gamepad.GetMotionData(MotionInputId.SecondAccelerometer);
Vector3 rightGyroscope = gamepad.GetMotionData(MotionInputId.SecondGyroscope);
rightAccelerometer = new Vector3(rightAccelerometer.X, -rightAccelerometer.Z, rightAccelerometer.Y);
rightGyroscope = new Vector3(rightGyroscope.X, -rightGyroscope.Z, rightGyroscope.Y);
_rightMotionInput.Update(rightAccelerometer, rightGyroscope, (ulong)PerformanceCounter.ElapsedNanoseconds / 1000, controllerConfig.Motion.Sensitivity, (float)controllerConfig.Motion.GyroDeadzone);
}
else
{
_rightMotionInput = _leftMotionInput;
}
}
}
else
{
_leftMotionInput = null;
_rightMotionInput = null;
}
}
else if (controllerConfig.Motion.MotionBackend == MotionInputBackendType.CemuHook && controllerConfig.Motion is CemuHookMotionConfigController cemuControllerConfig)
{
int clientId = (int)controllerConfig.PlayerIndex;
_cemuHookClient.RegisterClient(clientId, cemuControllerConfig.DsuServerHost, cemuControllerConfig.DsuServerPort);
_cemuHookClient.RequestData(clientId, cemuControllerConfig.Slot);
_cemuHookClient.TryGetData(clientId, cemuControllerConfig.Slot, out _leftMotionInput);
if (controllerConfig.ControllerType == ConfigControllerType.JoyconPair)
{
if (!cemuControllerConfig.MirrorInput)
{
_cemuHookClient.RequestData(clientId, cemuControllerConfig.AltSlot);
_cemuHookClient.TryGetData(clientId, cemuControllerConfig.AltSlot, out _rightMotionInput);
}
else
{
_rightMotionInput = _leftMotionInput;
}
}
}
}
private static bool HasInput(GamepadStateSnapshot state)
{
for (GamepadButtonInputId inputId = GamepadButtonInputId.A; inputId < GamepadButtonInputId.Count; inputId++)
{
if (state.IsPressed(inputId))
{
return true;
}
}
return StickIsActive(state.GetStick(StickInputId.Left)) || StickIsActive(state.GetStick(StickInputId.Right));
}
private static bool HasNewInput(GamepadStateSnapshot current, GamepadStateSnapshot previous)
{
for (GamepadButtonInputId inputId = GamepadButtonInputId.A; inputId < GamepadButtonInputId.Count; inputId++)
{
if (current.IsPressed(inputId) && !previous.IsPressed(inputId))
{
return true;
}
}
return StickBecameActive(current.GetStick(StickInputId.Left), previous.GetStick(StickInputId.Left)) ||
StickBecameActive(current.GetStick(StickInputId.Right), previous.GetStick(StickInputId.Right));
}
private static bool StickIsActive((float X, float Y) stick)
{
const float Threshold = 0.2f;
return MathF.Abs(stick.X) > Threshold || MathF.Abs(stick.Y) > Threshold;
}
private static bool StickBecameActive((float X, float Y) current, (float X, float Y) previous)
{
bool currentActive = StickIsActive(current);
bool previousActive = StickIsActive(previous);
return currentActive && (!previousActive || MathF.Abs(current.X - previous.X) > 0.1f || MathF.Abs(current.Y - previous.Y) > 0.1f);
}
private void DisposeOpenedGamepads()
{
if (!ReferenceEquals(_gamepad, _keyboardGamepad) && !_assignedControllerGamepads.Contains(_gamepad))
{
_gamepad?.Dispose();
}
_keyboardGamepad?.Dispose();
foreach (IGamepad controllerGamepad in _assignedControllerGamepads.Distinct())
{
controllerGamepad?.Dispose();
}
}
}

View File

@@ -37,6 +37,7 @@ namespace Ryujinx.Input.HLE
private List<InputConfig> _inputConfig;
private List<InputConfig> _requestedInputConfig;
private List<PlayerInputAssignment> _playerInputAssignments;
private bool _enableKeyboard;
private bool _enableMouse;
private Switch _device;
@@ -54,6 +55,7 @@ namespace Ryujinx.Input.HLE
_mouseDriver = mouseDriver;
_inputConfig = [];
_requestedInputConfig = [];
_playerInputAssignments = [];
_gamepadDriver.OnGamepadConnected += HandleOnGamepadConnected;
_gamepadDriver.OnGamepadDisconnected += HandleOnGamepadDisconnected;
@@ -78,52 +80,98 @@ namespace Ryujinx.Input.HLE
private void HandleOnGamepadDisconnected(string obj)
{
// Force input reload
List<InputConfig> requestedInputConfig;
List<PlayerInputAssignment> playerInputAssignments;
bool enableKeyboard;
bool enableMouse;
lock (_lock)
{
// Forcibly disconnect any controllers with this ID.
for (int i = 0; i < _controllers.Length; i++)
{
if (_controllers[i]?.Id == obj)
if (_controllers[i]?.HasAssignedControllerId(obj) == true)
{
_controllers[i]?.Dispose();
_controllers[i] = null;
}
}
ReloadConfiguration(_requestedInputConfig, _enableKeyboard, _enableMouse);
requestedInputConfig = _requestedInputConfig;
playerInputAssignments = _playerInputAssignments;
enableKeyboard = _enableKeyboard;
enableMouse = _enableMouse;
}
// Force input reload.
ReloadConfiguration(requestedInputConfig, playerInputAssignments, enableKeyboard, enableMouse);
}
private void HandleOnGamepadConnected(string _)
private void HandleOnGamepadConnected(string id)
{
lock (_lock)
{
for (int i = 0; i < _controllers.Length; i++)
{
if (_controllers[i] != null && PlayerHasAssignedControllerId((PlayerIndex)i, id))
{
_controllers[i]?.Dispose();
_controllers[i] = null;
}
}
}
// Force input reload
ReloadConfiguration(_requestedInputConfig, _enableKeyboard, _enableMouse);
ReloadConfiguration(_requestedInputConfig, _playerInputAssignments, _enableKeyboard, _enableMouse);
}
private bool PlayerHasAssignedControllerId(PlayerIndex playerIndex, string id)
{
if (string.IsNullOrEmpty(id))
{
return false;
}
InputConfig inputConfig = _requestedInputConfig.FirstOrDefault(config => (int)config.PlayerIndex == (int)playerIndex);
if (inputConfig == null)
{
return false;
}
PlayerInputAssignment playerInputAssignment = GetPlayerInputAssignment(inputConfig);
return playerInputAssignment.EnableDynamicInputSwap &&
playerInputAssignment.Devices.Any(device =>
device.Type == AssignedInputDeviceType.Controller &&
string.Equals(device.Id, id, StringComparison.Ordinal));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private bool DriverConfigurationUpdate(ref NpadController controller, InputConfig config)
private bool DriverConfigurationUpdate(ref NpadController controller, InputConfig config, PlayerInputAssignment playerInputAssignment)
{
IGamepadDriver targetDriver =
config is StandardKeyboardInputConfig
? _keyboardDriver
: _gamepadDriver;
Debug.Assert(_keyboardDriver != null, "Keyboard driver is not initialized!");
Debug.Assert(_gamepadDriver != null, "Gamepad driver is not initialized!");
Debug.Assert(targetDriver != null, "Unknown input configuration!");
if (controller.GamepadDriver != targetDriver || controller.Id != config.Id)
if (!controller.MatchesDriverConfiguration(config, playerInputAssignment))
{
return controller.UpdateDriverConfiguration(targetDriver, config);
return controller.UpdateDriverConfiguration(_keyboardDriver, _gamepadDriver, config, playerInputAssignment);
}
return controller.GamepadDriver != null;
return controller.IsAvailable;
}
public void ReloadConfiguration(List<InputConfig> inputConfig, bool enableKeyboard, bool enableMouse)
{
ReloadConfiguration(inputConfig, [], enableKeyboard, enableMouse);
}
public void ReloadConfiguration(List<InputConfig> inputConfig, List<PlayerInputAssignment> playerInputAssignments, bool enableKeyboard, bool enableMouse)
{
lock (_lock)
{
_requestedInputConfig = inputConfig?.ToList() ?? [];
_playerInputAssignments = playerInputAssignments?.ToList() ?? [];
NpadController[] oldControllers = _controllers.ToArray();
@@ -146,14 +194,17 @@ namespace Ryujinx.Input.HLE
}
InputConfig activeConfig = inputConfigEntry;
bool isValid = DriverConfigurationUpdate(ref controller, activeConfig);
PlayerInputAssignment playerInputAssignment = GetPlayerInputAssignment(inputConfigEntry);
bool isValid = DriverConfigurationUpdate(ref controller, activeConfig, playerInputAssignment);
if (!isValid &&
!playerInputAssignment.EnableDynamicInputSwap &&
inputConfigEntry is StandardControllerInputConfig &&
TryGetKeyboardFallback(inputConfigEntry, out StandardKeyboardInputConfig fallbackConfig))
{
activeConfig = fallbackConfig;
isValid = DriverConfigurationUpdate(ref controller, activeConfig);
isValid = DriverConfigurationUpdate(ref controller, activeConfig, playerInputAssignment);
}
if (!isValid)
@@ -184,6 +235,54 @@ namespace Ryujinx.Input.HLE
}
}
private PlayerInputAssignment GetPlayerInputAssignment(InputConfig inputConfig)
{
PlayerInputAssignment playerInputAssignment = _playerInputAssignments.FirstOrDefault(assignment => assignment.PlayerIndex == inputConfig.PlayerIndex);
if (playerInputAssignment != null)
{
PlayerInputAssignment normalizedAssignment = PlayerInputAssignmentHelper.Normalize(
playerInputAssignment,
PlayerInputAssignmentHelper.CreatePrimaryDevice(inputConfig));
if (normalizedAssignment.EnableDynamicInputSwap || normalizedAssignment.Devices.Count > 0)
{
return normalizedAssignment;
}
}
// Note: older configs only know about a single saved device per player,
// so we synthesize a routing entry here until the user saves explicit assignments.
playerInputAssignment = new PlayerInputAssignment
{
PlayerIndex = inputConfig.PlayerIndex,
EnableDynamicInputSwap = inputConfig.EnableDynamicGamepadSwap,
};
AssignedInputDevice primaryDevice = PlayerInputAssignmentHelper.CreatePrimaryDevice(inputConfig);
if (primaryDevice != null)
{
playerInputAssignment.Devices.Add(primaryDevice);
}
if (playerInputAssignment.EnableDynamicInputSwap && inputConfig is StandardControllerInputConfig)
{
string keyboardId = _keyboardDriver.GamepadsIds.IsEmpty ? null : _keyboardDriver.GamepadsIds[0];
if (!string.IsNullOrWhiteSpace(keyboardId))
{
playerInputAssignment.Devices.Add(new AssignedInputDevice
{
Type = AssignedInputDeviceType.Keyboard,
Id = keyboardId,
});
}
}
return playerInputAssignment;
}
private bool TryGetKeyboardFallback(InputConfig inputConfig, out StandardKeyboardInputConfig fallbackConfig)
{
fallbackConfig = null;
@@ -210,6 +309,8 @@ namespace Ryujinx.Input.HLE
inputConfig.ControllerType,
inputConfig.PlayerIndex);
fallbackConfig.EnableDynamicGamepadSwap = inputConfig.EnableDynamicGamepadSwap;
return true;
}
@@ -257,11 +358,16 @@ namespace Ryujinx.Input.HLE
}
public void Initialize(Switch device, List<InputConfig> inputConfig, bool enableKeyboard, bool enableMouse)
{
Initialize(device, inputConfig, [], enableKeyboard, enableMouse);
}
public void Initialize(Switch device, List<InputConfig> inputConfig, List<PlayerInputAssignment> playerInputAssignments, bool enableKeyboard, bool enableMouse)
{
_device = device;
_device.Configuration.RefreshInputConfig = RefreshInputConfigForHLE;
ReloadConfiguration(inputConfig, enableKeyboard, enableMouse);
ReloadConfiguration(inputConfig, playerInputAssignments, enableKeyboard, enableMouse);
}
public void Update(float aspectRatio = 1)
@@ -286,7 +392,7 @@ namespace Ryujinx.Input.HLE
// Do we allow input updates and is a controller connected?
if (_inputUpdateBlockCount == 0 && controller != null)
{
DriverConfigurationUpdate(ref controller, inputConfig);
DriverConfigurationUpdate(ref controller, inputConfig, GetPlayerInputAssignment(inputConfig));
controller.UpdateUserConfiguration(inputConfig);
controller.Update();
@@ -387,7 +493,9 @@ namespace Ryujinx.Input.HLE
{
lock (_lock)
{
return _inputConfig.FirstOrDefault(x => x.PlayerIndex == (Common.Configuration.Hid.PlayerIndex)index);
NpadController controller = _controllers[index];
return controller?.ActiveConfig ?? _inputConfig.FirstOrDefault(x => x.PlayerIndex == (Common.Configuration.Hid.PlayerIndex)index);
}
}

View File

@@ -1,3 +1,4 @@
using Ryujinx.Ava.Common.Locale;
using Ryujinx.Ava.Systems.AppLibrary;
using Ryujinx.Common.Logging;
using Ryujinx.Common.Utilities;
@@ -11,6 +12,7 @@ namespace Ryujinx.Ava.Common.Models
bool Untrimmable,
long PotentialSavingsB,
long CurrentSavingsB,
long OriginalSizeB,
int? PercentageProgress,
XCIFileTrimmer.OperationOutcome ProcessingOutcome)
{
@@ -25,31 +27,57 @@ namespace Ryujinx.Ava.Common.Models
trimmer.CanBeUntrimmed,
trimmer.DiskSpaceSavingsB,
trimmer.DiskSpaceSavedB,
applicationData.FileSize,
null,
XCIFileTrimmer.OperationOutcome.Undetermined
);
}
public bool IsFailed
public bool IsFailed =>
ProcessingOutcome is not XCIFileTrimmer.OperationOutcome.Undetermined
and not XCIFileTrimmer.OperationOutcome.Successful;
public string StatusText
{
get
{
return ProcessingOutcome is not XCIFileTrimmer.OperationOutcome.Undetermined and
not XCIFileTrimmer.OperationOutcome.Successful;
if (IsFailed)
return LocaleManager.Instance[LocaleKeys.XCITrimmer_FailedLabel];
return ProcessingOutcome switch
{
XCIFileTrimmer.OperationOutcome.Successful =>
CurrentSavingsB > 0
? LocaleManager.Instance[LocaleKeys.XCITrimmer_UntrimmedLabel]
: LocaleManager.Instance[LocaleKeys.XCITrimmer_TrimmedLabel],
XCIFileTrimmer.OperationOutcome.Undetermined =>
Trimmable && Untrimmable
? LocaleManager.Instance[LocaleKeys.XCITrimmer_PartialLabel]
: Trimmable
? LocaleManager.Instance[LocaleKeys.XCITrimmer_UntrimmedLabel]
: Untrimmable
? LocaleManager.Instance[LocaleKeys.XCITrimmer_TrimmedLabel]
: LocaleManager.Instance[LocaleKeys.XCITrimmer_UnknownLabel],
_ => LocaleManager.Instance[LocaleKeys.XCITrimmer_UnknownLabel]
};
}
}
public bool HasStatusDetail =>
ProcessingOutcome != XCIFileTrimmer.OperationOutcome.Undetermined;
public virtual bool Equals(XCITrimmerFileModel obj)
{
if (obj == null)
if (obj is null)
return false;
return this.Path == obj.Path;
}
public override int GetHashCode()
{
return this.Path.GetHashCode();
return Path == obj.Path;
}
public override int GetHashCode() => Path.GetHashCode();
}
}
}

View File

@@ -26,9 +26,9 @@ namespace Ryujinx.Ava.Common
internal class TrimmerWindow : Ryujinx.Common.Logging.XCIFileTrimmerLog
{
private readonly XciTrimmerViewModel _viewModel;
private readonly XCITrimmerViewModel _viewModel;
public TrimmerWindow(XciTrimmerViewModel viewModel)
public TrimmerWindow(XCITrimmerViewModel viewModel)
{
_viewModel = viewModel;
}

View File

@@ -468,11 +468,11 @@ namespace Ryujinx.Ava.Systems
if (ConfigurationState.Instance.System.UseInputGlobalConfig.Value && Program.UseExtraConfig)
{
NpadManager.Initialize(Device, ConfigurationState.InstanceExtra.Hid.InputConfig, ConfigurationState.Instance.Hid.EnableKeyboard, ConfigurationState.Instance.Hid.EnableMouse);
NpadManager.Initialize(Device, ConfigurationState.InstanceExtra.Hid.InputConfig, ConfigurationState.InstanceExtra.Hid.PlayerInputAssignments, ConfigurationState.Instance.Hid.EnableKeyboard, ConfigurationState.Instance.Hid.EnableMouse);
}
else
{
NpadManager.Initialize(Device, ConfigurationState.Instance.Hid.InputConfig, ConfigurationState.Instance.Hid.EnableKeyboard, ConfigurationState.Instance.Hid.EnableMouse);
NpadManager.Initialize(Device, ConfigurationState.Instance.Hid.InputConfig, ConfigurationState.Instance.Hid.PlayerInputAssignments, ConfigurationState.Instance.Hid.EnableKeyboard, ConfigurationState.Instance.Hid.EnableMouse);
}
TouchScreenManager.Initialize(Device);

View File

@@ -426,6 +426,16 @@ namespace Ryujinx.Ava.Systems.Configuration
/// </summary>
public List<InputConfig> InputConfig { get; set; }
/// <summary>
/// Player-level input routing assignments
/// </summary>
public List<PlayerInputAssignment> PlayerInputAssignments { get; set; }
/// <summary>
/// Whether to allow mapping the same input device to multiple players.
/// </summary>
public bool AllowDuplicateDeviceAssignment { get; set; }
/// <summary>
/// The speed of spectrum cycling for the Rainbow LED feature.
/// </summary>

View File

@@ -157,6 +157,8 @@ namespace Ryujinx.Ava.Systems.Configuration
Hid.DisableInputWhenOutOfFocus.Value = shouldLoadFromFile ? cff.DisableInputWhenOutOfFocus : Hid.DisableInputWhenOutOfFocus.Value; // Get from global config only
Hid.Hotkeys.Value = shouldLoadFromFile ? cff.Hotkeys : Hid.Hotkeys.Value; // Get from global config only
Hid.InputConfig.Value = cff.InputConfig ?? [] ;
Hid.PlayerInputAssignments.Value = cff.PlayerInputAssignments ?? [];
Hid.AllowDuplicateDeviceAssignment.Value = cff.AllowDuplicateDeviceAssignment;
Hid.RainbowSpeed.Value = cff.RainbowSpeed;
Multiplayer.LanInterfaceId.Value = cff.MultiplayerLanInterfaceId;

View File

@@ -191,6 +191,11 @@ namespace Ryujinx.Ava.Systems.Configuration
/// </summary>
public ReactiveObject<bool> IsAscendingOrder { get; private set; }
/// <summary>
/// Show Dynamic Input Swap first-use warning
/// </summary>
public ReactiveObject<bool> ShowDynamicInputSwapWarning { get; private set; }
public UISection()
{
GuiColumns = new Columns();
@@ -210,6 +215,8 @@ namespace Ryujinx.Ava.Systems.Configuration
LanguageCode = new ReactiveObject<string>();
ShowConsole = new ReactiveObject<bool>();
ShowConsole.Event += static (_, e) => ConsoleHelper.SetConsoleWindowState(e.NewValue);
ShowDynamicInputSwapWarning = new ReactiveObject<bool>();
ShowDynamicInputSwapWarning.Value = true;
}
}
@@ -513,6 +520,19 @@ namespace Ryujinx.Ava.Systems.Configuration
/// </summary>
public ReactiveObject<List<InputConfig>> InputConfig { get; private set; }
/// <summary>
/// Player-level input routing assignments.
/// NOTE: This keeps dynamic input swap and multi-device ownership attached to the player,
/// not to the currently edited keyboard/controller profile.
/// </summary>
public ReactiveObject<List<PlayerInputAssignment>> PlayerInputAssignments { get; private set; }
/// <summary>
/// Whether to allow mapping the same input device to multiple players.
/// This is a global setting shared across all players.
/// </summary>
public ReactiveObject<bool> AllowDuplicateDeviceAssignment { get; private set; }
/// <summary>
/// The speed of spectrum cycling for the Rainbow LED feature.
/// </summary>
@@ -525,6 +545,8 @@ namespace Ryujinx.Ava.Systems.Configuration
DisableInputWhenOutOfFocus = new ReactiveObject<bool>();
Hotkeys = new ReactiveObject<KeyboardHotkeys>();
InputConfig = new ReactiveObject<List<InputConfig>>();
PlayerInputAssignments = new ReactiveObject<List<PlayerInputAssignment>>();
AllowDuplicateDeviceAssignment = new ReactiveObject<bool>();
RainbowSpeed = new ReactiveObject<float>();
RainbowSpeed.Event += (_, args) => Rainbow.Speed = args.NewValue;
}

View File

@@ -143,6 +143,8 @@ namespace Ryujinx.Ava.Systems.Configuration
DisableInputWhenOutOfFocus = Hid.DisableInputWhenOutOfFocus,
Hotkeys = Hid.Hotkeys,
InputConfig = Hid.InputConfig,
PlayerInputAssignments = Hid.PlayerInputAssignments,
AllowDuplicateDeviceAssignment = Hid.AllowDuplicateDeviceAssignment,
RainbowSpeed = Hid.RainbowSpeed,
GraphicsBackend = Graphics.GraphicsBackend,
PreferredGpu = Graphics.PreferredGpu,
@@ -332,6 +334,22 @@ namespace Ryujinx.Ava.Systems.Configuration
},
}
];
Hid.PlayerInputAssignments.Value =
[
new PlayerInputAssignment
{
PlayerIndex = PlayerIndex.Player1,
EnableDynamicInputSwap = false,
Devices =
[
new AssignedInputDevice
{
Type = AssignedInputDeviceType.Keyboard,
Id = "0",
},
],
},
];
Debug.EnableGdbStub.Value = false;
Debug.GdbStubPort.Value = 55555;
Debug.DebuggerSuspendOnStart.Value = false;

View File

@@ -103,10 +103,10 @@
<MenuItem
Command="{Binding TrimXci}"
CommandParameter="{Binding}"
Header="{ext:Locale GameListContextMenuTrimXCI}"
Header="{ext:Locale GameListContextMenu_TrimXCIButton}"
IsEnabled="{Binding TrimXCIEnabled}"
Icon="{ext:Icon fa-solid fa-scissors}"
ToolTip.Tip="{ext:Locale GameListContextMenuTrimXCIToolTip}" />
IsVisible="{Binding IsXCIFile}"
Icon="{ext:Icon fa-solid fa-scissors}" />
<MenuItem Header="{ext:Locale GameListContextMenuCacheManagement}" Icon="{ext:Icon fa-solid fa-memory}">
<MenuItem
Command="{Binding PurgePtcCache}"

View File

@@ -18,6 +18,11 @@ using System.Threading.Tasks;
namespace Ryujinx.Ava.UI.Helpers
{
public class CheckBoxDialogResult
{
public bool IsChecked { get; set; }
}
public static class ContentDialogHelper
{
private static bool _isChoiceDialogOpen;
@@ -431,6 +436,67 @@ namespace Ryujinx.Ava.UI.Helpers
return response == UserResult.Yes;
}
internal static async Task<CheckBoxDialogResult> CreateCheckBoxDialog(string title, string primaryText, string checkBoxText, bool isCheckedDefault)
{
CheckBoxDialogResult result = new CheckBoxDialogResult { IsChecked = isCheckedDefault };
Grid content = new()
{
RowDefinitions = [new(), new(), new()],
ColumnDefinitions = [new(GridLength.Auto), new()],
MinHeight = 80,
};
content.Children.Add(new SymbolIcon
{
Symbol = (Symbol)Symbol.Important,
Margin = new Thickness(10),
FontSize = 40,
FlowDirection = FlowDirection.LeftToRight,
VerticalAlignment = VerticalAlignment.Center,
GridColumn = 0,
GridRow = 0,
GridRowSpan = 2
});
content.Children.Add(new TextBlock
{
Text = primaryText,
Margin = new Thickness(5),
TextWrapping = TextWrapping.Wrap,
MaxWidth = 450,
GridColumn = 1,
GridRow = 0
});
CheckBox checkBox = new()
{
Content = checkBoxText,
IsChecked = isCheckedDefault,
Margin = new Thickness(5),
GridColumn = 1,
GridRow = 1
};
checkBox.IsCheckedChanged += (s, e) =>
{
result.IsChecked = checkBox.IsChecked == true;
};
content.Children.Add(checkBox);
ContentDialog contentDialog = new()
{
Title = title,
PrimaryButtonText = LocaleManager.Instance[LocaleKeys.InputDialogOk],
Content = content,
};
await ShowAsync(contentDialog);
return result;
}
internal static async Task<UserResult> CreateUpdaterChoiceDialog(string title, string primary, string secondaryText, string changelogUrl)
{
if (_isChoiceDialogOpen)

View File

@@ -0,0 +1,32 @@
using Avalonia.Data.Converters;
using Ryujinx.Ava.UI.ViewModels.Input;
using System;
using System.Globalization;
namespace Ryujinx.Ava.UI.Helpers
{
public class ProfileNameLinkedConverter : IValueConverter
{
public static readonly ProfileNameLinkedConverter Instance = new();
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is not string profileName || string.IsNullOrWhiteSpace(profileName))
{
return false;
}
if (parameter is InputViewModel viewModel)
{
return viewModel.IsProfileNameLinked(profileName);
}
return false;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}

View File

@@ -12,32 +12,57 @@ namespace Ryujinx.Ava.UI.Helpers
internal class XCITrimmerFileSpaceSavingsConverter : IValueConverter
{
private const long _bytesPerMB = 1024 * 1024;
public static readonly XCITrimmerFileSpaceSavingsConverter Instance = new();
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is UnsetValueType)
{
if (value == null || value == AvaloniaProperty.UnsetValue)
return BindingOperations.DoNothing;
}
if (!targetType.IsAssignableFrom(typeof(string)))
{
return null;
}
if (value is not XCITrimmerFileModel app)
{
return null;
long originalSize = app.OriginalSizeB;
long currentSavings = app.CurrentSavingsB;
long potentialSavings = app.PotentialSavingsB;
if (originalSize <= 0)
{
return GetFormattedString(app, 0, 0);
}
long mbValue = 0;
double percentage = 0;
if (currentSavings > 0)
{
mbValue = (currentSavings / _bytesPerMB).CoerceAtLeast(0);
percentage = (currentSavings / (double)originalSize) * 100;
}
else if (potentialSavings > 0)
{
mbValue = (potentialSavings / _bytesPerMB).CoerceAtLeast(0);
percentage = (potentialSavings / (double)originalSize) * 100;
}
return GetFormattedString(app, mbValue, percentage);
}
private string GetFormattedString(XCITrimmerFileModel app, long mb, double percentage)
{
// Round percentage to 1 decimal place
double roundedPercentage = Math.Round(percentage, 1);
if (app.CurrentSavingsB < app.PotentialSavingsB)
{
return LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.TitleXCICanSaveLabel, ((app.PotentialSavingsB - app.CurrentSavingsB) / _bytesPerMB).CoerceAtLeast(0));
return LocaleManager.Instance.UpdateAndGetDynamicValue(
LocaleKeys.XCITrimmer_CalculatedSavingsLabel, mb, roundedPercentage);
}
else
{
return LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.TitleXCISavingLabel, (app.CurrentSavingsB / _bytesPerMB).CoerceAtLeast(0));
return LocaleManager.Instance.UpdateAndGetDynamicValue(
LocaleKeys.XCITrimmer_CalculatedSavingsLabel, mb, roundedPercentage);
}
}

View File

@@ -1,7 +1,7 @@
using Avalonia;
using Avalonia.Data;
using Avalonia.Data.Converters;
using Ryujinx.Ava.Common.Locale;
using FluentAvalonia.UI.Controls;
using Ryujinx.Ava.Common.Models;
using System;
using System.Globalization;
@@ -16,26 +16,30 @@ namespace Ryujinx.Ava.UI.Helpers
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is UnsetValueType)
{
return BindingOperations.DoNothing;
}
if (!targetType.IsAssignableFrom(typeof(string)))
{
return null;
}
if (value is not XCITrimmerFileModel app)
{
return null;
}
return default(Symbol);
bool isProcessing = app.PercentageProgress != null;
if (isProcessing)
return Symbol.Sync;
return app.PercentageProgress != null ? String.Empty :
app.ProcessingOutcome is not OperationOutcome.Successful and not OperationOutcome.Undetermined ? LocaleManager.Instance[LocaleKeys.TitleXCIStatusFailedLabel] :
app.Trimmable & app.Untrimmable ? LocaleManager.Instance[LocaleKeys.TitleXCIStatusPartialLabel] :
app.Trimmable ? LocaleManager.Instance[LocaleKeys.TitleXCIStatusTrimmableLabel] :
app.Untrimmable ? LocaleManager.Instance[LocaleKeys.TitleXCIStatusUntrimmableLabel] :
String.Empty;
if (app.ProcessingOutcome is not OperationOutcome.Successful
and not OperationOutcome.Undetermined)
return Symbol.ImportantFilled;
if (app.Trimmable && app.Untrimmable)
return Symbol.Repair;
if (app.Trimmable)
return Symbol.Clear;
if (app.Untrimmable)
return Symbol.Checkmark;
return Symbol.Help;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)

View File

@@ -9,17 +9,15 @@ namespace Ryujinx.Ava.UI.Helpers
{
public string LocalizedText => opOutcome switch
{
OperationOutcome.NoTrimNecessary => LocaleManager.Instance[LocaleKeys.TrimXCIFileNoTrimNecessary],
OperationOutcome.NoUntrimPossible => LocaleManager.Instance[LocaleKeys.TrimXCIFileNoUntrimPossible],
OperationOutcome.ReadOnlyFileCannotFix => LocaleManager.Instance[
LocaleKeys.TrimXCIFileReadOnlyFileCannotFix],
OperationOutcome.FreeSpaceCheckFailed => LocaleManager.Instance[
LocaleKeys.TrimXCIFileFreeSpaceCheckFailed],
OperationOutcome.InvalidXCIFile => LocaleManager.Instance[LocaleKeys.TrimXCIFileInvalidXCIFile],
OperationOutcome.FileIOWriteError => LocaleManager.Instance[LocaleKeys.TrimXCIFileFileIOWriteError],
OperationOutcome.FileSizeChanged => LocaleManager.Instance[LocaleKeys.TrimXCIFileFileSizeChanged],
OperationOutcome.Cancelled => LocaleManager.Instance[LocaleKeys.TrimXCIFileCancelled],
OperationOutcome.Undetermined => LocaleManager.Instance[LocaleKeys.TrimXCIFileFileUndertermined],
OperationOutcome.NoTrimNecessary => LocaleManager.Instance[LocaleKeys.Dialog_XCITrimmer_NoTrimNecessaryMessage],
OperationOutcome.NoUntrimPossible => LocaleManager.Instance[LocaleKeys.Dialog_XCITrimmer_NoUntrimPossibleMessage],
OperationOutcome.ReadOnlyFileCannotFix => LocaleManager.Instance[LocaleKeys.Dialog_XCITrimmer_ReadOnlyFileCannotFixMessage],
OperationOutcome.FreeSpaceCheckFailed => LocaleManager.Instance[LocaleKeys.Dialog_XCITrimmer_FreeSpaceCheckFailedMessage],
OperationOutcome.InvalidXCIFile => LocaleManager.Instance[LocaleKeys.Dialog_XCITrimmer_InvalidDataMessage],
OperationOutcome.FileIOWriteError => LocaleManager.Instance[LocaleKeys.Dialog_XCITrimmer_WriteErrorMessage],
OperationOutcome.FileSizeChanged => LocaleManager.Instance[LocaleKeys.Dialog_XCITrimmer_SizeChangedMessage],
OperationOutcome.Cancelled => LocaleManager.Instance[LocaleKeys.Dialog_XCITrimmer_TrimCancelledMessage],
OperationOutcome.Undetermined => LocaleManager.Instance[LocaleKeys.Dialog_XCITrimmer_NoOperationPerformedMessage],
_ => null
};
}

View File

@@ -0,0 +1,72 @@
using Ryujinx.Ava.UI.ViewModels;
using Ryujinx.Common.Configuration.Hid;
using System;
namespace Ryujinx.Ava.UI.Models.Input
{
public class PlayerInputDeviceAssignmentItem : BaseModel
{
public DeviceType DeviceType { get; init; }
public string Id { get; init; }
public string Name { get; init; }
public AssignedInputDeviceType AssignedType =>
DeviceType == DeviceType.Keyboard ? AssignedInputDeviceType.Keyboard : AssignedInputDeviceType.Controller;
public bool IsAssigned
{
get;
set
{
field = value;
OnPropertyChanged();
}
}
public bool HasBoundProfileName => !string.IsNullOrWhiteSpace(BoundProfileName);
public string BoundProfileName
{
get;
set
{
field = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasBoundProfileName));
}
}
public bool HasAssignedToPlayers => !string.IsNullOrWhiteSpace(AssignedToPlayers);
/// <summary>
/// Comma-separated list of player names (e.g. "Player 1, Player 3")
/// that have this device assigned. Empty if no other player uses it.
/// </summary>
public string AssignedToPlayers
{
get;
set
{
field = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasAssignedToPlayers));
}
}
/// <summary>
/// True when this device is assigned to another player and
/// AllowDuplicateDeviceAssignment is disabled, making it unclickable.
/// </summary>
public bool IsDisabledByOtherPlayer
{
get;
set
{
field = value;
OnPropertyChanged();
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -496,6 +496,8 @@ namespace Ryujinx.Ava.UI.ViewModels
public bool HasCompatibilityEntry => SelectedApplication.HasPlayabilityInfo;
public bool IsXCIFile => Path.GetExtension(SelectedApplication.Path)?.ToLower() == ".xci";
public bool HasDlc => ApplicationLibrary.HasDlcs(SelectedApplication.Id);
public bool OpenUserSaveDirectoryEnabled => SelectedApplication.HasControlHolder &&
@@ -790,7 +792,7 @@ namespace Ryujinx.Ava.UI.ViewModels
{
ApplicationSort.Favorite => LocaleManager.Instance[LocaleKeys.CommonFavorite],
ApplicationSort.TitleId => LocaleManager.Instance[LocaleKeys.DlcManagerTableHeadingTitleIdLabel],
ApplicationSort.Title => LocaleManager.Instance[LocaleKeys.GameListHeaderApplication],
ApplicationSort.Title => LocaleManager.Instance[LocaleKeys.Common_Sort_NameLabel],
ApplicationSort.Developer => LocaleManager.Instance[LocaleKeys.GameListSortDeveloper],
ApplicationSort.LastPlayed => LocaleManager.Instance[LocaleKeys.GameListSortLastPlayed],
ApplicationSort.TotalTimePlayed => LocaleManager.Instance[LocaleKeys.GameListSortTimePlayed],
@@ -1935,13 +1937,13 @@ namespace Ryujinx.Ava.UI.ViewModels
if (version != null)
{
LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBar_FirmwareVersion, version.VersionString);
LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBar_FirmwareVersionLabel, version.VersionString);
hasApplet = version.Major > 3;
}
else
{
LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBar_FirmwareVersion, "NaN");
LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBar_FirmwareVersionLabel, "NaN");
}
IsAppletMenuActive = hasApplet;
@@ -2317,7 +2319,7 @@ namespace Ryujinx.Ava.UI.ViewModels
if (notifyUser != null)
{
await ContentDialogHelper.CreateWarningDialog(
LocaleManager.Instance[LocaleKeys.TrimXCIFileFailedPrimaryText],
LocaleManager.Instance[LocaleKeys.Dialog_XCITrimmer_TrimFailedMessage],
notifyUser
);
}
@@ -2343,18 +2345,18 @@ namespace Ryujinx.Ava.UI.ViewModels
if (trimmer.CanBeTrimmed)
{
double savings = (double)trimmer.DiskSpaceSavingsB / 1024.0 / 1024.0;
double currentFileSize = (double)trimmer.FileSizeB / 1024.0 / 1024.0;
double cartDataSize = (double)trimmer.DataSizeB / 1024.0 / 1024.0;
int savings = (int)Math.Round((double)trimmer.DiskSpaceSavingsB / 1024.0 / 1024.0);
int currentFileSize = (int)Math.Round((double)trimmer.FileSizeB / 1024.0 / 1024.0);
int cartDataSize = (int)Math.Round((double)trimmer.DataSizeB / 1024.0 / 1024.0);
string secondaryText = LocaleManager.Instance.UpdateAndGetDynamicValue(
LocaleKeys.TrimXCIFileDialogSecondaryText, currentFileSize, cartDataSize, savings);
LocaleKeys.Dialog_XCITrimmer_SecondaryMessage, currentFileSize.ToString("0"), cartDataSize.ToString("0"), savings.ToString("0"));
UserResult result = await ContentDialogHelper.CreateConfirmationDialog(
LocaleManager.Instance[LocaleKeys.TrimXCIFileDialogPrimaryText],
LocaleManager.Instance[LocaleKeys.Dialog_XCITrimmer_PrimaryMessage],
secondaryText,
LocaleManager.Instance[LocaleKeys.Continue],
LocaleManager.Instance[LocaleKeys.Cancel],
LocaleManager.Instance[LocaleKeys.GameListContextMenuTrimXCI]
LocaleManager.Instance[LocaleKeys.GameListContextMenu_TrimXCIButton]
);
if (result == UserResult.Yes)
@@ -2364,8 +2366,8 @@ namespace Ryujinx.Ava.UI.ViewModels
Dispatcher.UIThread.Post(() =>
{
StatusBarProgressStatusText =
LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBarXCIFileTrimming,
Path.GetFileName(filename));
LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBar_TrimmingXCILabel,
Path.GetFileNameWithoutExtension(filename));
StatusBarProgressStatusVisible = true;
StatusBarProgressMaximum = 1;
StatusBarProgressValue = 0;

View File

@@ -16,7 +16,7 @@ using static Ryujinx.Common.Utilities.XCIFileTrimmer;
namespace Ryujinx.Ava.UI.ViewModels
{
public class XciTrimmerViewModel : BaseModel
public class XCITrimmerViewModel : BaseModel
{
private const long BytesPerMb = 1024 * 1024;
@@ -29,11 +29,11 @@ namespace Ryujinx.Ava.UI.ViewModels
public enum SortField
{
Name,
Saved
Savings,
Status
}
private const string _FileExtXCI = "XCI";
private const string _fileExtXCI = "XCI";
private readonly Ryujinx.Common.Logging.XCIFileTrimmerLog _logger;
private ApplicationLibrary ApplicationLibrary => _mainWindowViewModel.ApplicationLibrary;
private Optional<XCITrimmerFileModel> _processingApplication = null;
@@ -45,8 +45,11 @@ namespace Ryujinx.Ava.UI.ViewModels
private string _search;
private ProcessingMode _processingMode;
private SortField _sortField = SortField.Name;
private int _processingCurrent;
private int _processingTotal;
public XciTrimmerViewModel(MainWindowViewModel mainWindowViewModel)
public XCITrimmerViewModel(MainWindowViewModel mainWindowViewModel)
{
_logger = new XCITrimmerLog.TrimmerWindow(this);
_mainWindowViewModel = mainWindowViewModel;
@@ -56,7 +59,7 @@ namespace Ryujinx.Ava.UI.ViewModels
private void LoadXCIApplications()
{
IEnumerable<ApplicationData> apps = ApplicationLibrary.Applications.Items
.Where(app => app.FileExtension == _FileExtXCI);
.Where(app => app.FileExtension == _fileExtXCI);
foreach (ApplicationData xciApp in apps)
AddOrUpdateXCITrimmerFile(CreateXCITrimmerFile(xciApp.Path));
@@ -64,11 +67,9 @@ namespace Ryujinx.Ava.UI.ViewModels
ApplicationsChanged();
}
private XCITrimmerFileModel CreateXCITrimmerFile(
string path,
OperationOutcome operationOutcome = OperationOutcome.Undetermined)
private XCITrimmerFileModel CreateXCITrimmerFile(string path, OperationOutcome operationOutcome = OperationOutcome.Undetermined)
{
ApplicationData xciApp = ApplicationLibrary.Applications.Items.First(app => app.FileExtension == _FileExtXCI && app.Path == path);
ApplicationData xciApp = ApplicationLibrary.Applications.Items.First(app => app.FileExtension == _fileExtXCI && app.Path == path);
return XCITrimmerFileModel.FromApplicationData(xciApp, _logger) with { ProcessingOutcome = operationOutcome };
}
@@ -90,11 +91,15 @@ namespace Ryujinx.Ava.UI.ViewModels
SortAndFilter();
}
public bool AnySelected =>
_selectedXCIFiles.Count > 0;
private void SortingChanged()
{
OnPropertiesChanged(
nameof(IsSortedByName),
nameof(IsSortedBySaved),
nameof(IsSortedBySavings),
nameof(IsSortedByStatus),
nameof(SortingAscending),
nameof(SortingField),
nameof(SortingFieldName));
@@ -114,6 +119,7 @@ namespace Ryujinx.Ava.UI.ViewModels
nameof(Status),
nameof(PotentialSavings),
nameof(ActualSavings),
nameof(SavingsDifference),
nameof(CanTrim),
nameof(CanUntrim));
@@ -123,16 +129,30 @@ namespace Ryujinx.Ava.UI.ViewModels
private void SelectionChanged(bool displayedChanged = true)
{
OnPropertiesChanged(
nameof(Status),
nameof(CanTrim),
nameof(CanUntrim),
nameof(SelectedXCIFiles));
OnPropertyChanged(nameof(Status));
OnPropertyChanged(nameof(CanTrim));
OnPropertyChanged(nameof(CanUntrim));
OnPropertyChanged(nameof(SelectedXCIFiles));
OnPropertyChanged(nameof(AnySelected));
OnPropertyChanged(nameof(SelectToggleText));
if (displayedChanged)
OnPropertyChanged(nameof(SelectedDisplayedXCIFiles));
}
public void ToggleSelect()
{
if (AnySelected)
DeselectAll();
else
SelectAll();
}
public string SelectToggleText =>
AnySelected
? LocaleManager.Instance[LocaleKeys.XCITrimmer_ClearSelectionButton]
: LocaleManager.Instance[LocaleKeys.XCITrimmer_SelectAllButton];
private void ProcessingChanged()
{
OnPropertiesChanged(
@@ -167,6 +187,14 @@ namespace Ryujinx.Ava.UI.ViewModels
(processingMode == ProcessingMode.Trimming && xci.Trimmable)
)).ToList();
_processingTotal = toProcess.Count;
_processingCurrent = 0;
Dispatcher.UIThread.Post(() =>
{
OnPropertyChanged(nameof(Status));
});
List<XCITrimmerFileModel> viewsSaved = DisplayedXCIFiles.ToList();
Dispatcher.UIThread.Post(() =>
@@ -219,6 +247,12 @@ namespace Ryujinx.Ava.UI.ViewModels
ProcessingApplication = null;
});
}
_processingCurrent++;
Dispatcher.UIThread.Post(() =>
{
OnPropertyChanged(nameof(Status));
});
}
}
finally
@@ -226,9 +260,20 @@ namespace Ryujinx.Ava.UI.ViewModels
Dispatcher.UIThread.Post(() =>
{
_displayedXCIFiles.AddOrReplaceMatching(_allXCIFiles, viewsSaved);
_selectedXCIFiles.AddOrReplaceMatching(_allXCIFiles, toProcess);
Processing = false;
ApplicationsChanged();
_selectedXCIFiles.Clear();
foreach (var processed in toProcess)
{
var updated = _allXCIFiles.FirstOrDefault(x => x.Path == processed.Path);
if (updated != null)
_selectedXCIFiles.Add(updated);
}
SelectionChanged();
});
}
})
@@ -254,9 +299,9 @@ namespace Ryujinx.Ava.UI.ViewModels
private class CompareXCITrimmerFiles : IComparer<XCITrimmerFileModel>
{
private readonly XciTrimmerViewModel _viewModel;
private readonly XCITrimmerViewModel _viewModel;
public CompareXCITrimmerFiles(XciTrimmerViewModel ViewModel)
public CompareXCITrimmerFiles(XCITrimmerViewModel ViewModel)
{
_viewModel = ViewModel;
}
@@ -270,9 +315,13 @@ namespace Ryujinx.Ava.UI.ViewModels
case SortField.Name:
result = x.Name.CompareTo(y.Name);
break;
case SortField.Saved:
case SortField.Savings:
result = x.PotentialSavingsB.CompareTo(y.PotentialSavingsB);
break;
case SortField.Status:
result = x.CurrentSavingsB.CompareTo(y.CurrentSavingsB);
break;
}
if (!_viewModel.SortingAscending)
@@ -312,15 +361,16 @@ namespace Ryujinx.Ava.UI.ViewModels
}
}
public void SelectDisplayed()
public void SelectAll()
{
SelectedXCIFiles.Clear();
SelectedXCIFiles.AddRange(DisplayedXCIFiles);
SelectionChanged();
}
public void DeselectDisplayed()
public void DeselectAll()
{
SelectedXCIFiles.RemoveMany(DisplayedXCIFiles);
SelectedXCIFiles.Clear();
SelectionChanged();
}
@@ -426,16 +476,23 @@ namespace Ryujinx.Ava.UI.ViewModels
{
return _processingMode switch
{
ProcessingMode.Trimming => string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerTitleStatusTrimming], DisplayedXCIFiles.Count),
ProcessingMode.Untrimming => string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerTitleStatusUntrimming], DisplayedXCIFiles.Count),
ProcessingMode.Trimming => string.Format(
LocaleManager.Instance[LocaleKeys.XCITrimmer_StatusTrimmingLabel],
_processingCurrent,
_processingTotal),
ProcessingMode.Untrimming => string.Format(
LocaleManager.Instance[LocaleKeys.XCITrimmer_StatusUntrimmingLabel],
_processingCurrent,
_processingTotal),
_ => string.Empty
};
}
else
{
return string.IsNullOrEmpty(Search) ?
string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerTitleStatusCount], SelectedXCIFiles.Count, AllXCIFiles.Count) :
string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerTitleStatusCountWithFilter], SelectedXCIFiles.Count, AllXCIFiles.Count, DisplayedXCIFiles.Count);
string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmer_StatusCountLabel], SelectedXCIFiles.Count, AllXCIFiles.Count) :
string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmer_StatusCountWithFilterLabel], SelectedXCIFiles.Count, AllXCIFiles.Count, DisplayedXCIFiles.Count);
}
}
}
@@ -466,8 +523,9 @@ namespace Ryujinx.Ava.UI.ViewModels
{
return SortingField switch
{
SortField.Name => LocaleManager.Instance[LocaleKeys.XCITrimmerSortName],
SortField.Saved => LocaleManager.Instance[LocaleKeys.XCITrimmerSortSaved],
SortField.Name => LocaleManager.Instance[LocaleKeys.Common_Sort_NameLabel],
SortField.Savings => LocaleManager.Instance[LocaleKeys.Common_Sort_SavingsLabel],
SortField.Status => LocaleManager.Instance[LocaleKeys.Common_Sort_TrimStatusLabel],
_ => string.Empty,
};
}
@@ -488,11 +546,13 @@ namespace Ryujinx.Ava.UI.ViewModels
get => _sortField == SortField.Name;
}
public bool IsSortedBySaved
public bool IsSortedBySavings
{
get => _sortField == SortField.Saved;
get => _sortField == SortField.Savings;
}
public bool IsSortedByStatus => _sortField == SortField.Status;
public AvaloniaList<XCITrimmerFileModel> SelectedXCIFiles
{
get => _selectedXCIFiles;
@@ -517,7 +577,7 @@ namespace Ryujinx.Ava.UI.ViewModels
{
get
{
return string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerSavingsMb], AllXCIFiles.Sum(xci => xci.PotentialSavingsB / BytesPerMb));
return string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmer_MBLabel], AllXCIFiles.Sum(xci => xci.PotentialSavingsB / BytesPerMb));
}
}
@@ -525,7 +585,19 @@ namespace Ryujinx.Ava.UI.ViewModels
{
get
{
return string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerSavingsMb], AllXCIFiles.Sum(xci => xci.CurrentSavingsB / BytesPerMb));
return string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmer_MBLabel], AllXCIFiles.Sum(xci => xci.CurrentSavingsB / BytesPerMb));
}
}
public string SavingsDifference
{
get
{
long potentialSavings = AllXCIFiles.Sum(xci => xci.PotentialSavingsB);
long actualSavings = AllXCIFiles.Sum(xci => xci.CurrentSavingsB);
long differenceMb = (potentialSavings - actualSavings) / BytesPerMb;
return string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmer_MBLabel], differenceMb);
}
}

View File

@@ -63,7 +63,7 @@
MinHeight="29"
MaxHeight="29"
HorizontalAlignment="Stretch"
Watermark="{ext:Locale Search}"
Watermark="{ext:Locale Common_Search_SearchWatermark}"
Text="{Binding Search}" />
</Grid>
</Panel>

View File

@@ -46,7 +46,7 @@
MinHeight="27"
MaxHeight="27"
HorizontalAlignment="Stretch"
Watermark="{ext:Locale Search}"
Watermark="{ext:Locale Common_Search_SearchWatermark}"
Text="{Binding Search}" />
</Grid>
</Panel>

View File

@@ -0,0 +1,185 @@
<UserControl
x:Class="Ryujinx.Ava.UI.Views.Dialog.XCITrimmerView"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:ext="clr-namespace:Ryujinx.Ava.Common.Markup"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
xmlns:helpers="clr-namespace:Ryujinx.Ava.UI.Helpers"
xmlns:models="clr-namespace:Ryujinx.Ava.Common.Models"
Width="700"
Height="600"
x:DataType="viewModels:XCITrimmerViewModel"
Focusable="True"
mc:Ignorable="d">
<Grid Margin="25,10,25,0" RowDefinitions="Auto,Auto,*,Auto,Auto">
<Panel Margin="0,0,0,10" Grid.Row="0">
<TextBlock Text="{Binding Status}" />
</Panel>
<Grid Margin="0,0,0,10" Grid.Row="1" IsVisible="{Binding !Processing}" ColumnDefinitions="Auto,*">
<StackPanel Grid.Column="0" Orientation="Horizontal" HorizontalAlignment="Left" VerticalAlignment="Center">
<Button Margin="0,0,10,0" MinWidth="90" Click="ToggleSelect">
<TextBlock Text="{Binding SelectToggleText}" />
</Button>
</StackPanel>
<StackPanel Orientation="Horizontal" Grid.Column="1" HorizontalAlignment="Right">
<DropDownButton Width="170" HorizontalAlignment="Right" VerticalAlignment="Center" Content="{Binding SortingFieldName}">
<DropDownButton.Flyout>
<Flyout Placement="Bottom">
<StackPanel Margin="0" HorizontalAlignment="Stretch" Orientation="Vertical">
<StackPanel>
<RadioButton Checked="Sort_Checked" Content="{ext:Locale Common_Sort_NameLabel}" GroupName="Sort" IsChecked="{Binding IsSortedByName, Mode=OneTime}" Tag="Name" />
<RadioButton Checked="Sort_Checked" Content="{ext:Locale Common_Sort_SavingsLabel}" GroupName="Sort" IsChecked="{Binding IsSortedBySavings, Mode=OneTime}" Tag="Savings" />
<RadioButton Checked="Sort_Checked" Content="{ext:Locale Common_Sort_TrimStatusLabel}" GroupName="Sort" IsChecked="{Binding IsSortedByStatus, Mode=OneTime}" Tag="Status" />
</StackPanel>
<Border Width="60" Height="2" Margin="5" HorizontalAlignment="Stretch" BorderBrush="White" BorderThickness="0,1,0,0">
<Separator Height="0" HorizontalAlignment="Stretch" />
</Border>
<RadioButton Checked="Order_Checked" Content="{ext:Locale Common_Sort_OrderAscending}" GroupName="Order" IsChecked="{Binding SortingAscending, Mode=OneTime}" Tag="Ascending" />
<RadioButton Checked="Order_Checked" Content="{ext:Locale Common_Sort_OrderDescending}" GroupName="Order" IsChecked="{Binding !SortingAscending, Mode=OneTime}" Tag="Descending" />
</StackPanel>
</Flyout>
</DropDownButton.Flyout>
</DropDownButton>
<TextBox
Width="200"
MaxWidth="200"
Margin="5,0,0,0"
VerticalAlignment="Center"
HorizontalAlignment="Right"
Watermark="{ext:Locale Common_Search_SearchWatermark}"
Text="{Binding Search}" />
</StackPanel>
</Grid>
<Border
Grid.Row="2"
Margin="0,0,0,20"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
BorderBrush="{DynamicResource AppListHoverBackgroundColor}"
BorderThickness="1"
CornerRadius="5"
Padding="2.5">
<ListBox
AutoScrollToSelectedItem="{Binding Processing}"
SelectedItem="{Binding NullableProcessingApplication}"
SelectionMode="Multiple, Toggle"
Background="Transparent"
SelectionChanged="OnSelectionChanged"
SelectedItems="{Binding SelectedDisplayedXCIFiles, Mode=OneWay}"
ItemsSource="{Binding DisplayedXCIFiles}"
IsEnabled="{Binding !Processing}">
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Margin" Value="0" />
<Setter Property="Background" Value="Transparent" />
</Style>
</ListBox.Styles>
<ListBox.DataTemplates>
<DataTemplate DataType="models:XCITrimmerFileModel">
<Grid ColumnDefinitions="Auto,*,Auto">
<ui:SymbolIcon
Grid.Column="0"
Margin="10,0,20,0"
Width="15"
Height="15"
FontSize="15"
FlowDirection="LeftToRight"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Symbol="{Binding Converter={x:Static helpers:XCITrimmerFileStatusConverter.Instance}}">
<ToolTip.Tip>
<StackPanel>
<TextBlock
Text="{Binding StatusText}" />
<TextBlock
MaxLines="5"
MaxWidth="200"
TextWrapping="Wrap"
IsVisible="{Binding HasStatusDetail}"
Text="{Binding ., Converter={x:Static helpers:XCITrimmerFileStatusDetailConverter.Instance}}" />
</StackPanel>
</ToolTip.Tip>
</ui:SymbolIcon>
<TextBlock
Grid.Column="1"
Margin="0,0,10,0"
VerticalAlignment="Center"
MaxLines="2"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
Text="{Binding Name}" />
<Grid
Grid.Column="2"
MinWidth="120"
ColumnDefinitions="*">
<ProgressBar
Height="10"
Margin="10,0,10,0"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
CornerRadius="5"
IsVisible="{Binding $parent[UserControl].DataContext.Processing}"
Maximum="100"
Minimum="0"
Value="{Binding PercentageProgress}" />
<TextBlock
Margin="10,0,10,0"
FlowDirection="LeftToRight"
HorizontalAlignment="Right"
VerticalAlignment="Center"
MaxLines="1"
IsVisible="{Binding !$parent[UserControl].DataContext.Processing}"
Text="{Binding ., Converter={x:Static helpers:XCITrimmerFileSpaceSavingsConverter.Instance}}" />
</Grid>
</Grid>
</DataTemplate>
</ListBox.DataTemplates>
</ListBox>
</Border>
<StackPanel Grid.Row="3" Margin="0,0,0,20" Spacing="5" FlowDirection="LeftToRight" HorizontalAlignment="Center" VerticalAlignment="Center" Orientation="Horizontal">
<TextBlock Classes="h1" HorizontalAlignment="Center" VerticalAlignment="Center" MaxLines="1" Text="{ext:Locale XCITrimmer_SavedLabel}" />
<TextBlock Classes="h1" HorizontalAlignment="Center" VerticalAlignment="Center" FontWeight="Regular" MaxLines="1" Text="{Binding ActualSavings}" />
<TextBlock Classes="h1" HorizontalAlignment="Center" VerticalAlignment="Center" FontWeight="Regular" MaxLines="1" Text="•" />
<TextBlock Classes="h1" HorizontalAlignment="Center" VerticalAlignment="Center" MaxLines="1" Text="{ext:Locale XCITrimmer_RemainingLabel}" />
<TextBlock Classes="h1" HorizontalAlignment="Center" VerticalAlignment="Center" FontWeight="Regular" MaxLines="1" Text="{Binding SavingsDifference}" />
</StackPanel>
<Panel Grid.Row="4" Margin="0,10,0,0" HorizontalAlignment="Stretch">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Left">
<Button Name="TrimButton" MinWidth="90" Click="Trim" IsEnabled="{Binding CanTrim}">
<TextBlock Text="{ext:Locale XCITrimmer_TrimButton}" />
</Button>
<Button Name="UntrimButton" MinWidth="90" Click="Untrim" IsEnabled="{Binding CanUntrim}">
<TextBlock Text="{ext:Locale XCITrimmer_UntrimButton}" />
</Button>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<Button Name="CancellingButton" MinWidth="90" Click="Cancel" IsEnabled="False">
<Button.IsVisible>
<MultiBinding Converter="{x:Static BoolConverters.And}">
<Binding Path="Processing" />
<Binding Path="Cancel" />
</MultiBinding>
</Button.IsVisible>
<TextBlock Text="{ext:Locale InputDialogCancelling}" />
</Button>
<Button Name="CancelButton" MinWidth="90" Click="Cancel">
<Button.IsVisible>
<MultiBinding Converter="{x:Static BoolConverters.And}">
<Binding Path="Processing" />
<Binding Path="!Cancel" />
</MultiBinding>
</Button.IsVisible>
<TextBlock Text="{ext:Locale InputDialogCancel}" />
</Button>
<Button Name="CloseButton" MinWidth="90" Click="Close" IsVisible="{Binding !Processing}">
<TextBlock Text="{ext:Locale SettingsButtonClose}" />
</Button>
</StackPanel>
</Grid>
</Panel>
</Grid>
</UserControl>

View File

@@ -11,13 +11,19 @@ using System.Threading.Tasks;
namespace Ryujinx.Ava.UI.Views.Dialog
{
public partial class XciTrimmerView : RyujinxControl<XciTrimmerViewModel>
public partial class XCITrimmerView : RyujinxControl<XCITrimmerViewModel>
{
public XciTrimmerView()
public XCITrimmerView()
{
InitializeComponent();
}
private void ToggleSelect(object sender, RoutedEventArgs e)
{
if (DataContext is XCITrimmerViewModel vm)
vm.ToggleSelect();
}
public static async Task Show()
{
ContentDialog contentDialog = new()
@@ -25,11 +31,11 @@ namespace Ryujinx.Ava.UI.Views.Dialog
PrimaryButtonText = string.Empty,
SecondaryButtonText = string.Empty,
CloseButtonText = string.Empty,
Content = new XciTrimmerView
Content = new XCITrimmerView
{
ViewModel = new XciTrimmerViewModel(RyujinxApp.MainWindow.ViewModel)
ViewModel = new XCITrimmerViewModel(RyujinxApp.MainWindow.ViewModel)
},
Title = LocaleManager.Instance[LocaleKeys.XCITrimmerWindowTitle]
Title = LocaleManager.Instance[LocaleKeys.MenuBar_Actions_XCITrimmerButton]
};
Style bottomBorder = new(x => x.OfType<Grid>().Name("DialogSpace").Child().OfType<Border>());
@@ -63,7 +69,7 @@ namespace Ryujinx.Ava.UI.Views.Dialog
public void Sort_Checked(object sender, RoutedEventArgs args)
{
if (sender is RadioButton { Tag: string sortField })
ViewModel.SortingField = Enum.Parse<XciTrimmerViewModel.SortField>(sortField);
ViewModel.SortingField = Enum.Parse<XCITrimmerViewModel.SortField>(sortField);
}
public void Order_Checked(object sender, RoutedEventArgs args)

View File

@@ -1,311 +0,0 @@
<UserControl
x:Class="Ryujinx.Ava.UI.Views.Dialog.XciTrimmerView"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ext="clr-namespace:Ryujinx.Ava.Common.Markup"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
xmlns:helpers="clr-namespace:Ryujinx.Ava.UI.Helpers"
xmlns:models="clr-namespace:Ryujinx.Ava.Common.Models"
Width="700"
Height="600"
x:DataType="viewModels:XciTrimmerViewModel"
Focusable="True"
mc:Ignorable="d">
<Grid Margin="20 0 20 0" RowDefinitions="Auto,Auto,*,Auto,Auto">
<Panel
Margin="10 10 10 10"
Grid.Row="0">
<TextBlock Text="{Binding Status}" />
</Panel>
<Panel
Margin="0 0 10 10"
IsVisible="{Binding !Processing}"
Grid.Row="1">
<Grid ColumnDefinitions="Auto,*,Auto">
<StackPanel
Grid.Column="0"
Orientation="Horizontal">
<DropDownButton
Width="150"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Content="{Binding SortingFieldName}">
<DropDownButton.Flyout>
<Flyout Placement="Bottom">
<StackPanel
Margin="0"
HorizontalAlignment="Stretch"
Orientation="Vertical">
<StackPanel>
<RadioButton
Checked="Sort_Checked"
Content="{ext:Locale XCITrimmerSortName}"
GroupName="Sort"
IsChecked="{Binding IsSortedByName, Mode=OneTime}"
Tag="Name" />
<RadioButton
Checked="Sort_Checked"
Content="{ext:Locale XCITrimmerSortSaved}"
GroupName="Sort"
IsChecked="{Binding IsSortedBySaved, Mode=OneTime}"
Tag="Saved" />
</StackPanel>
<Border
Width="60"
Height="2"
Margin="5"
HorizontalAlignment="Stretch"
BorderBrush="White"
BorderThickness="0,1,0,0">
<Separator Height="0" HorizontalAlignment="Stretch" />
</Border>
<RadioButton
Checked="Order_Checked"
Content="{ext:Locale OrderAscending}"
GroupName="Order"
IsChecked="{Binding SortingAscending, Mode=OneTime}"
Tag="Ascending" />
<RadioButton
Checked="Order_Checked"
Content="{ext:Locale OrderDescending}"
GroupName="Order"
IsChecked="{Binding !SortingAscending, Mode=OneTime}"
Tag="Descending" />
</StackPanel>
</Flyout>
</DropDownButton.Flyout>
</DropDownButton>
</StackPanel>
<TextBox
Grid.Column="1"
MinHeight="29"
MaxHeight="29"
Margin="5 0 5 0"
HorizontalAlignment="Stretch"
Watermark="{ext:Locale Search}"
Text="{Binding Search}" />
<StackPanel
Grid.Column="2"
Orientation="Horizontal">
<Button
Name="SelectDisplayedButton"
MinWidth="90"
Margin="5"
Command="{Binding SelectDisplayed}">
<TextBlock Text="{ext:Locale XCITrimmerSelectDisplayed}" />
</Button>
<Button
Name="DeselectDisplayedButton"
MinWidth="90"
Margin="5"
Command="{Binding DeselectDisplayed}">
<TextBlock Text="{ext:Locale XCITrimmerDeselectDisplayed}" />
</Button>
</StackPanel>
</Grid>
</Panel>
<Border
Grid.Row="2"
Margin="0 0 0 10"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
BorderBrush="{DynamicResource AppListHoverBackgroundColor}"
BorderThickness="1"
CornerRadius="5"
Padding="2.5">
<ListBox
AutoScrollToSelectedItem="{Binding Processing}"
SelectedItem="{Binding NullableProcessingApplication}"
SelectionMode="Multiple, Toggle"
Background="Transparent"
SelectionChanged="OnSelectionChanged"
SelectedItems="{Binding SelectedDisplayedXCIFiles, Mode=OneWay}"
ItemsSource="{Binding DisplayedXCIFiles}"
IsEnabled="{Binding !Processing}">
<ListBox.DataTemplates>
<DataTemplate
DataType="models:XCITrimmerFileModel">
<Panel Margin="10">
<Grid ColumnDefinitions="65*,35*">
<TextBlock
Grid.Column="0"
Margin="10 0 10 0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
MaxLines="2"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
Text="{Binding Name}">
</TextBlock>
<Grid Grid.Column="1" ColumnDefinitions="45*,55*">
<ProgressBar
Height="10"
Margin="10 0 10 0"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
CornerRadius="5"
IsVisible="{Binding $parent[UserControl].((viewModels:XciTrimmerViewModel)DataContext).Processing}"
Maximum="100"
Minimum="0"
Value="{Binding PercentageProgress}" />
<TextBlock
Grid.Column="0"
Margin="10 0 10 0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
MaxLines="1"
Text="{Binding ., Converter={x:Static helpers:XCITrimmerFileStatusConverter.Instance}}">
<ToolTip.Tip>
<StackPanel
IsVisible="{Binding IsFailed}">
<TextBlock
Classes="h1"
Text="{ext:Locale XCITrimmerTitleStatusFailed}" />
<TextBlock
Text="{Binding ., Converter={x:Static helpers:XCITrimmerFileStatusDetailConverter.Instance}}"
MaxLines="5"
MaxWidth="200"
MaxHeight="100"
TextTrimming="None"
TextWrapping="Wrap"/>
</StackPanel>
</ToolTip.Tip>
</TextBlock>
<TextBlock
Grid.Column="1"
Margin="10 0 10 0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
MaxLines="1"
Text="{Binding ., Converter={x:Static helpers:XCITrimmerFileSpaceSavingsConverter.Instance}}">>
</TextBlock>
</Grid>
</Grid>
</Panel>
</DataTemplate>
</ListBox.DataTemplates>
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Background" Value="Transparent" />
</Style>
</ListBox.Styles>
</ListBox>
</Border>
<Border
Grid.Row="3"
Margin="0 0 0 10"
HorizontalAlignment="Stretch"
BorderBrush="{DynamicResource AppListHoverBackgroundColor}"
BorderThickness="1"
CornerRadius="5"
Padding="2.5">
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto">
<TextBlock
Grid.Column="0"
Grid.Row="0"
Classes="h1"
Margin="5"
HorizontalAlignment="Right"
VerticalAlignment="Center"
MaxLines="1"
Text="{ext:Locale XCITrimmerPotentialSavings}" />
<TextBlock
Grid.Column="0"
Grid.Row="1"
Classes="h1"
Margin="5"
HorizontalAlignment="Right"
VerticalAlignment="Center"
MaxLines="1"
Text="{ext:Locale XCITrimmerActualSavings}" />
<TextBlock
Grid.Column="1"
Grid.Row="0"
Margin="5"
HorizontalAlignment="Left"
VerticalAlignment="Center"
MaxLines="1"
Text="{Binding PotentialSavings}" />
<TextBlock
Grid.Column="1"
Grid.Row="1"
Margin="5"
HorizontalAlignment="Left"
VerticalAlignment="Center"
MaxLines="1"
Text="{Binding ActualSavings}" />
</Grid>
</Border>
<Panel
Grid.Row="4"
HorizontalAlignment="Stretch">
<Grid ColumnDefinitions="*,Auto">
<StackPanel
Grid.Column="0"
Orientation="Horizontal"
Spacing="10"
HorizontalAlignment="Left">
<Button
Name="TrimButton"
MinWidth="90"
Margin="5"
Click="Trim"
IsEnabled="{Binding CanTrim}">
<TextBlock Text="{ext:Locale XCITrimmerTrim}" />
</Button>
<Button
Name="UntrimButton"
MinWidth="90"
Margin="5"
Click="Untrim"
IsEnabled="{Binding CanUntrim}">
<TextBlock Text="{ext:Locale XCITrimmerUntrim}" />
</Button>
</StackPanel>
<StackPanel
Grid.Column="1"
Orientation="Horizontal"
Spacing="10"
HorizontalAlignment="Right">
<Button
Name="CancellingButton"
MinWidth="90"
Margin="5"
Click="Cancel"
IsEnabled="False">
<Button.IsVisible>
<MultiBinding Converter="{x:Static BoolConverters.And}">
<Binding Path="Processing" />
<Binding Path="Cancel" />
</MultiBinding>
</Button.IsVisible>
<TextBlock Text="{ext:Locale InputDialogCancelling}" />
</Button>
<Button
Name="CancelButton"
MinWidth="90"
Margin="5"
Click="Cancel">
<Button.IsVisible>
<MultiBinding Converter="{x:Static BoolConverters.And}">
<Binding Path="Processing" />
<Binding Path="!Cancel" />
</MultiBinding>
</Button.IsVisible>
<TextBlock Text="{ext:Locale InputDialogCancel}" />
</Button>
<Button
Name="CloseButton"
MinWidth="90"
Margin="5"
Click="Close"
IsVisible="{Binding !Processing}">
<TextBlock Text="{ext:Locale InputDialogClose}" />
</Button>
</StackPanel>
</Grid>
</Panel>
</Grid>
</UserControl>

View File

@@ -0,0 +1,89 @@
<UserControl
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:ext="clr-namespace:Ryujinx.Ava.Common.Markup"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:models="clr-namespace:Ryujinx.Ava.UI.Models.Input"
xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels.Input"
x:Class="Ryujinx.Ava.UI.Views.Input.AssignedDevicesInputView"
x:DataType="viewModels:InputViewModel"
x:CompileBindings="True"
mc:Ignorable="d">
<Grid Margin="10" RowDefinitions="Auto,Auto">
<Border
Grid.Row="0"
Padding="10,8"
BorderBrush="{DynamicResource ThemeControlBorderColor}"
BorderThickness="1"
CornerRadius="5"
HorizontalAlignment="Stretch">
<ItemsControl
ItemsSource="{Binding PlayerInputDevices}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="models:PlayerInputDeviceAssignmentItem">
<Border
Margin="0,3"
Padding="10,8"
CornerRadius="4"
Background="{DynamicResource ControlFillColorTertiary}"
BorderBrush="{DynamicResource ThemeControlBorderColor}"
BorderThickness="1"
PointerPressed="DeviceRow_OnPointerPressed"
Cursor="Hand"
IsEnabled="{Binding !IsDisabledByOtherPlayer}">
<Grid ColumnDefinitions="Auto,*,200">
<CheckBox
Grid.Column="0"
IsChecked="{Binding IsAssigned}"
Checked="AssignedDeviceCheckBox_OnCheckedChanged"
Unchecked="AssignedDeviceCheckBox_OnCheckedChanged"
VerticalAlignment="Center"
Margin="0"
Padding="0" />
<StackPanel
Grid.Column="1"
Margin="8,0,0,0"
VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" />
<TextBlock
Opacity="0.6"
FontSize="12"
MaxWidth="160"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap"
MaxLines="1"
IsVisible="{Binding HasBoundProfileName}">
<Run Text="{ext:Locale ControllerSettingsProfile}" />
<Run Text=":" />
<Run Text="{Binding BoundProfileName}" />
</TextBlock>
</StackPanel>
<TextBlock
Grid.Column="2"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Margin="10,0,0,0"
Opacity="0.6"
FontSize="12"
Text="{Binding AssignedToPlayers}"
TextWrapping="Wrap"
MaxWidth="200"
IsVisible="{Binding HasAssignedToPlayers}" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
<CheckBox
Grid.Row="1"
Margin="0,8,0,0"
IsChecked="{Binding AllowDuplicateDeviceAssignment}">
<TextBlock
VerticalAlignment="Center"
Text="{ext:Locale ControllerSettingsAllowDuplicateDeviceAssignment}" />
</CheckBox>
</Grid>
</UserControl>

View File

@@ -0,0 +1,97 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using FluentAvalonia.UI.Controls;
using Ryujinx.Ava.Common.Locale;
using Ryujinx.Ava.UI.Models.Input;
using Ryujinx.Ava.UI.ViewModels.Input;
using System.Linq;
using System.Threading.Tasks;
namespace Ryujinx.Ava.UI.Views.Input
{
public partial class AssignedDevicesInputView : UserControl
{
public AssignedDevicesInputView()
{
InitializeComponent();
}
public AssignedDevicesInputView(InputViewModel viewModel)
{
DataContext = viewModel;
InitializeComponent();
}
public static async Task Show(InputViewModel viewModel)
{
// Store original state to allow discarding changes
var originalAssignments = viewModel.PlayerInputDevices
.Select(item => new { item.Id, item.DeviceType, item.IsAssigned })
.ToList();
var originalAllowDuplicate = viewModel.AllowDuplicateDeviceAssignment;
AssignedDevicesInputView content = new(viewModel);
ContentDialog contentDialog = new()
{
Title = LocaleManager.Instance[LocaleKeys.ControllerSettingsAssignedInputDevices],
PrimaryButtonText = LocaleManager.Instance[LocaleKeys.ControllerSettingsSave],
SecondaryButtonText = string.Empty,
CloseButtonText = LocaleManager.Instance[LocaleKeys.ControllerSettingsClose],
Content = content,
};
ContentDialogResult result = await contentDialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
viewModel.Save();
}
else
{
// Discard changes by reverting to original state
foreach (var original in originalAssignments)
{
var item = viewModel.PlayerInputDevices.FirstOrDefault(d =>
d.Id == original.Id && d.DeviceType == original.DeviceType);
if (item != null && item.IsAssigned != original.IsAssigned)
{
// Use Toggle to revert, which will properly refresh state
viewModel.ToggleAssignedPlayerInputDevice(item, original.IsAssigned);
}
}
// Revert AllowDuplicateDeviceAssignment to original state
if (viewModel.AllowDuplicateDeviceAssignment != originalAllowDuplicate)
{
viewModel.AllowDuplicateDeviceAssignment = originalAllowDuplicate;
}
viewModel.RefreshModifiedState();
}
}
private void AssignedDeviceCheckBox_OnCheckedChanged(object sender, RoutedEventArgs e)
{
if (sender is CheckBox { DataContext: PlayerInputDeviceAssignmentItem item } checkBox)
{
_viewModel?.ToggleAssignedPlayerInputDevice(item, checkBox.IsChecked == true);
}
}
private void DeviceRow_OnPointerPressed(object sender, PointerPressedEventArgs e)
{
if (e.Source is Control control && control.FindAncestorOfType<CheckBox>() != null)
{
return;
}
if (sender is Border { DataContext: PlayerInputDeviceAssignmentItem item })
{
_viewModel?.ToggleAssignedPlayerInputDevice(item, !item.IsAssigned);
}
}
private InputViewModel _viewModel => DataContext as InputViewModel;
}
}

View File

@@ -88,7 +88,7 @@
Grid.Column="2"
Margin="2"
HorizontalAlignment="Stretch"
VerticalAlignment="Center" ColumnDefinitions="Auto,*,Auto,Auto,Auto">
VerticalAlignment="Center" ColumnDefinitions="Auto,*,Auto,Auto,Auto,Auto">
<TextBlock
Margin="5,0,10,0"
Width="90"
@@ -101,19 +101,46 @@
Name="ProfileBox"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
MaxHeight="32"
Padding="8,0,44,0"
IsEnabled="{Binding ShowSettings}"
SelectedItem="{Binding ChosenProfile, Mode=TwoWay}"
SelectionChanged="ComboBox_SelectionChanged"
ItemsSource="{Binding ProfilesList}"
Text="{Binding ProfileName, Mode=TwoWay}" />
Text="{Binding ProfileName, Mode=TwoWay}">
<ui:FAComboBox.Styles>
<Style Selector="TextBlock">
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
<Setter Property="TextWrapping" Value="NoWrap" />
<Setter Property="MaxLines" Value="1" />
<Setter Property="MaxWidth" Value="170" />
</Style>
<Style Selector="TextBox">
<Setter Property="TextWrapping" Value="NoWrap" />
<Setter Property="MaxLines" Value="1" />
<Setter Property="Padding" Value="0,0,36,0" />
</Style>
</ui:FAComboBox.Styles>
</ui:FAComboBox>
<ui:SymbolIcon
Grid.Column="1"
Symbol="Link"
FontSize="12"
Opacity="0.6"
IsVisible="{Binding IsProfileLinked}"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Margin="0,0,37,0" />
<Button
Grid.Column="2"
MinWidth="0"
Margin="5,0,0,0"
VerticalAlignment="Center"
ToolTip.Tip="{ext:Locale ControllerSettingsLoadProfileToolTip}"
Command="{Binding LoadProfileButton}">
ToolTip.Tip="{ext:Locale ControllerSettingsBindProfileToolTip}"
IsEnabled="{Binding CanBindSelectedProfile}"
Click="LinkProfileButton_OnClick">
<ui:SymbolIcon
Symbol="View"
Symbol="Link"
FontSize="15"
Height="20" />
</Button>
@@ -122,19 +149,34 @@
MinWidth="0"
Margin="5,0,0,0"
VerticalAlignment="Center"
ToolTip.Tip="{ext:Locale ControllerSettingsSaveProfileToolTip}"
Command="{Binding SaveProfile}">
ToolTip.Tip="{ext:Locale ControllerSettingsLoadProfileToolTip}"
IsEnabled="{Binding ShowSettings}"
Click="LoadProfileButton_OnClick">
<ui:SymbolIcon
Symbol="Save"
Symbol="OpenFolder"
FontSize="15"
Height="20" />
</Button>
<Button
Grid.Column="4"
MinWidth="0"
Margin="5,0,0,0"
VerticalAlignment="Center"
ToolTip.Tip="{ext:Locale ControllerSettingsSaveProfileToolTip}"
IsEnabled="{Binding CanDeleteOrSaveProfile}"
Command="{Binding SaveProfile}">
<ui:SymbolIcon
Symbol="Save"
FontSize="15"
Height="20" />
</Button>
<Button
Grid.Column="5"
MinWidth="0"
Margin="5,0,0,0"
VerticalAlignment="Center"
ToolTip.Tip="{ext:Locale ControllerSettingsRemoveProfileToolTip}"
IsEnabled="{Binding CanDeleteOrSaveProfile}"
Command="{Binding RemoveProfile}">
<ui:SymbolIcon
Symbol="Delete"
@@ -149,7 +191,7 @@
<Grid
Grid.Column="0"
Margin="2"
HorizontalAlignment="Stretch" ColumnDefinitions="Auto,*,Auto,Auto">
HorizontalAlignment="Stretch" ColumnDefinitions="Auto,*,Auto,Auto,Auto">
<TextBlock
Grid.Column="0"
Margin="5,0,10,0"
@@ -187,7 +229,21 @@
MinWidth="0"
Margin="5,0,0,0"
VerticalAlignment="Center"
ToolTip.Tip="{ext:Locale ControllerSettingsAssignedInputDevicesTooltip}"
IsEnabled="{Binding CanOpenAssignedDevices}"
Click="AssignedDevicesButton_OnClick">
<ui:SymbolIcon
Symbol="Settings"
FontSize="15"
Height="20"/>
</Button>
<Button
Grid.Column="4"
MinWidth="0"
Margin="5,0,0,0"
VerticalAlignment="Center"
ToolTip.Tip="{ext:Locale ControllerSettingsResetKeybindsToDefault}"
IsEnabled="{Binding ShowSettings}"
Click="ResetCurrentDeviceToDefaultsButton_OnClick">
<ui:SymbolIcon
Symbol="Undo"
@@ -210,6 +266,7 @@
<ComboBox
Grid.Column="1"
HorizontalAlignment="Stretch"
IsEnabled="{Binding ShowSettings}"
ItemsSource="{Binding Controllers}"
SelectedIndex="{Binding Controller}">
<ComboBox.ItemTemplate>

View File

@@ -1,4 +1,5 @@
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Avalonia.Interactivity;
using Avalonia;
using Avalonia.Layout;
@@ -45,12 +46,69 @@ namespace Ryujinx.Ava.UI.Views.Input
ViewModel = new InputViewModel(this, useGlobalConfig); // Create new Input Page with the selected input config scope.
InitializeComponent();
SetupProfileBoxItemTemplate();
if (VisualRoot is not null)
{
ViewModel.RetargetKeyboardDriver(this);
}
}
private void SetupProfileBoxItemTemplate()
{
var dataTemplate = new FuncDataTemplate<string>((profileName, scope) =>
{
var grid = new Grid
{
ColumnDefinitions = new ColumnDefinitions("*,Auto")
};
var textBlock = new TextBlock
{
Text = profileName,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis,
TextWrapping = TextWrapping.NoWrap,
MaxWidth = 170
};
Grid.SetColumn(textBlock, 0);
var linkIcon = new FluentAvalonia.UI.Controls.SymbolIcon
{
Symbol = FluentAvalonia.UI.Controls.Symbol.Link,
FontSize = 12,
Opacity = 0.6,
Margin = new Thickness(10, 0, 0, 0),
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
};
Grid.SetColumn(linkIcon, 1);
// Bind visibility to whether the profile is linked
linkIcon.Bind(
FluentAvalonia.UI.Controls.SymbolIcon.IsVisibleProperty,
new Avalonia.Data.Binding(".")
{
Converter = ProfileNameLinkedConverter.Instance,
ConverterParameter = ViewModel
});
grid.Children.Add(textBlock);
grid.Children.Add(linkIcon);
return grid;
});
ProfileBox.ItemTemplate = dataTemplate;
}
public void RefreshProfileBoxItemTemplate()
{
// Force the ComboBox to re-render its items
var itemsSource = ProfileBox.ItemsSource;
ProfileBox.ItemsSource = null;
ProfileBox.ItemsSource = itemsSource;
}
private async void PlayerIndexBox_OnSelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (PlayerIndexBox != null)
@@ -102,8 +160,15 @@ namespace Ryujinx.Ava.UI.Views.Input
if (sender is FAComboBox faComboBox)
{
faComboBox.IsDropDownOpen = false;
ViewModel.RefreshModifiedState();
}
ViewModel.RefreshModifiedState();
}
private async void AssignedDevicesButton_OnClick(object sender, RoutedEventArgs e)
{
await AssignedDevicesInputView.Show(ViewModel);
ViewModel.RefreshModifiedState();
}
private async void ResetCurrentDeviceToDefaultsButton_OnClick(object sender, RoutedEventArgs e)
@@ -155,6 +220,17 @@ namespace Ryujinx.Ava.UI.Views.Input
}
}
private void LinkProfileButton_OnClick(object sender, RoutedEventArgs e)
{
ViewModel?.LinkCurrentProfileToCurrentDevice();
RefreshProfileBoxItemTemplate();
}
private void LoadProfileButton_OnClick(object sender, RoutedEventArgs e)
{
ViewModel?.LoadProfile();
}
public void Dispose()
{
ViewModel.Dispose();

View File

@@ -163,7 +163,7 @@
IsEnabled="{Binding HasSkylander}" />
<MenuItem
Command="{Binding SimulateWakeUpMessage}"
Header="{ext:Locale MenuBarOptionsSimulateWakeUpMessage}"
Header="{ext:Locale MenuBar_Actions_SimulateWakeUpMessageButton}"
Icon="{ext:Icon fa-solid fa-sun}"
InputGesture="Ctrl + M" />
<Separator />
@@ -216,7 +216,7 @@
<MenuItem Header="{ext:Locale MenuBar_Actions_ToolsLabel}" Icon="{ext:Icon fa-solid fa-toolbox}">
<MenuItem
Name="MiiAppletMenuItem" Header="{ext:Locale MenuBar_Actions_MiiEditorButton}" Icon="{ext:Icon fa-solid fa-face-grin-wide}" />
<MenuItem Name="XciTrimmerMenuItem" Header="{ext:Locale MenuBar_Actions_XCITrimmerButton}" Icon="{ext:Icon fa-solid fa-scissors}" />
<MenuItem Name="XCITrimmerMenuItem" Header="{ext:Locale MenuBar_Actions_XCITrimmerButton}" Icon="{ext:Icon fa-solid fa-scissors}" />
</MenuItem>
</MenuItem>
<MenuItem VerticalAlignment="Center" Header="{ext:Locale MenuBarView}">

View File

@@ -43,7 +43,7 @@ namespace Ryujinx.Ava.UI.Views.Main
ResumeEmulationMenuItem.Command = Commands.Create(() => ViewModel.AppHost?.Resume());
StopEmulationMenuItem.Command = Commands.Create(() => ViewModel.AppHost?.ShowExitPrompt().OrCompleted());
RestartEmulationMenuItem.Command = Commands.Create(() => ViewModel.RestartEmulation());
XciTrimmerMenuItem.Command = Commands.Create(XciTrimmerView.Show);
XCITrimmerMenuItem.Command = Commands.Create(XCITrimmerView.Show);
AboutWindowMenuItem.Command = Commands.Create(AboutView.Show);
CompatibilityListMenuItem.Command = Commands.Create(() => CompatibilityListWindow.Show());
LdnGameListMenuItem.Command = Commands.Create(() => LdnGamesListWindow.Show());

View File

@@ -50,33 +50,37 @@
VerticalAlignment="Center"
IsVisible="{Binding EnableNonGameRunningControls}"
Text="{ext:Locale StatusBarGamesLoaded}" />
<TextBlock
Name="StatusBarProgressStatus"
Grid.Column="2"
MinWidth="200"
Margin="10,0,5,0"
VerticalAlignment="Center"
IsVisible="{Binding StatusBarProgressStatusVisible}"
Text="{Binding StatusBarProgressStatusText}" />
<ProgressBar
Name="LoadProgressBar"
Grid.Column="3"
MinWidth="200"
Height="6"
VerticalAlignment="Center"
Margin="0, 0, 5, 0"
Foreground="{DynamicResource SystemAccentColorLight2}"
IsVisible="{Binding StatusBarVisible}"
Maximum="{Binding StatusBarProgressMaximum}"
Value="{Binding StatusBarProgressValue}" />
<controls:MiniVerticalSeparator Grid.Column="4" IsVisible="{Binding ShowTotalTimePlayed}" />
<controls:MiniVerticalSeparator Grid.Column="2" IsVisible="{Binding ShowTotalTimePlayed}" />
<TextBlock
Grid.Column="5"
Grid.Column="3"
Margin="5,0,5,0"
VerticalAlignment="Center"
IsVisible="{Binding ShowTotalTimePlayed}"
Text="{ext:Locale GameListLabelTotalTimePlayed}">
</TextBlock>
<controls:MiniVerticalSeparator Grid.Column="4" IsVisible="{Binding StatusBarVisible}" />
<controls:MiniVerticalSeparator Grid.Column="4" IsVisible="{Binding StatusBarProgressStatusVisible}" />
<TextBlock
Name="StatusBarProgressStatus"
Grid.Column="5"
Margin="5,0,0,0"
VerticalAlignment="Center"
IsVisible="{Binding StatusBarProgressStatusVisible}"
Text="{Binding StatusBarProgressStatusText}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap"
MaxWidth="1000" />
<ProgressBar
Name="LoadProgressBar"
Grid.Column="6"
MinWidth="100"
Height="6"
VerticalAlignment="Center"
Margin="5,0,5,0"
Foreground="{DynamicResource SystemAccentColorLight2}"
IsVisible="{Binding StatusBarVisible}"
Maximum="{Binding StatusBarProgressMaximum}"
Value="{Binding StatusBarProgressValue}" />
</Grid>
</StackPanel>
<StackPanel
@@ -329,7 +333,7 @@
Margin="5, 0, 0, 0"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Text="{ext:Locale StatusBar_FirmwareVersion}" />
Text="{ext:Locale StatusBar_FirmwareVersionLabel}" />
</StackPanel>
</Grid>
</UserControl>

View File

@@ -95,7 +95,7 @@
Tag="Favorite" />
<RadioButton
Checked="Sort_Checked"
Content="{ext:Locale GameListHeaderApplication}"
Content="{ext:Locale Common_Sort_NameLabel}"
GroupName="Sort"
IsChecked="{Binding IsSortedByTitle, Mode=OneTime}"
Tag="Title" />
@@ -153,13 +153,13 @@
</Border>
<RadioButton
Checked="Order_Checked"
Content="{ext:Locale OrderAscending}"
Content="{ext:Locale Common_Sort_OrderAscending}"
GroupName="Order"
IsChecked="{Binding IsAscending, Mode=OneTime}"
Tag="Ascending" />
<RadioButton
Checked="Order_Checked"
Content="{ext:Locale OrderDescending}"
Content="{ext:Locale Common_Sort_OrderDescending}"
GroupName="Order"
IsChecked="{Binding !IsAscending, Mode=OneTime}"
Tag="Descending" />

View File

@@ -41,6 +41,13 @@
<StackPanel
Orientation="Horizontal"
Spacing="10">
<CheckBox
ToolTip.Tip="{ext:Locale DockModeToggleTooltip}"
MinWidth="0"
IsChecked="{Binding EnableDockedMode}">
<TextBlock
Text="{ext:Locale SettingsTabInputEnableDockedMode}" />
</CheckBox>
<CheckBox
ToolTip.Tip="{ext:Locale UseGlobalInputTooltip}"
MinWidth="0"
@@ -49,11 +56,11 @@
Text="{ext:Locale SettingsTabInputUseGlobalInput}" />
</CheckBox>
<CheckBox
ToolTip.Tip="{ext:Locale DockModeToggleTooltip}"
MinWidth="0"
IsChecked="{Binding EnableDockedMode}">
ToolTip.Tip="{ext:Locale ControllerSettingsDynamicInputSwapTooltip}"
IsChecked="{Binding ElementName=InputView, Path=ViewModel.EnableDynamicGamepadSwap, Mode=TwoWay}"
IsEnabled="{Binding ElementName=InputView, Path=ViewModel.ShowSettings}">
<TextBlock
Text="{ext:Locale SettingsTabInputEnableDockedMode}" />
Text="{ext:Locale ControllerSettingsDynamicInputSwap}" />
</CheckBox>
<CheckBox
ToolTip.Tip="{ext:Locale DirectKeyboardTooltip}"

View File

@@ -33,7 +33,7 @@
HorizontalContentAlignment="Left"
MinWidth="100">
<ComboBoxItem
Content="{ext:Locale Name}" />
Content="{ext:Locale Common_Sort_NameLabel}" />
<ComboBoxItem
Content="{ext:Locale Size}" />
<ComboBox.Styles>
@@ -46,9 +46,9 @@
HorizontalContentAlignment="Left"
MinWidth="150">
<ComboBoxItem
Content="{ext:Locale OrderAscending}" />
Content="{ext:Locale Common_Sort_OrderAscending}" />
<ComboBoxItem
Content="{ext:Locale OrderDescending}" />
Content="{ext:Locale Common_Sort_OrderDescending}" />
<ComboBox.Styles>
<Style Selector="ContentControl#ContentPresenter">
<Setter Property="HorizontalAlignment" Value="Left" />
@@ -60,7 +60,7 @@
Grid.Column="1"
HorizontalAlignment="Stretch"
Margin="10,0, 0, 0" ColumnDefinitions="Auto,*">
<TextBlock Text="{ext:Locale Search}" VerticalAlignment="Center" />
<TextBlock Text="{ext:Locale Common_Search_SearchWatermark}" VerticalAlignment="Center" />
<TextBox
Margin="10,0,0,0"
Grid.Column="1"