Initial mod browser + RmlUi CSS variables

This commit is contained in:
Luke Street
2026-08-31 20:40:30 -06:00
parent a5eca78939
commit 548b98072d
49 changed files with 3765 additions and 956 deletions
+1 -1
View File
@@ -304,7 +304,7 @@ include(cmake/GameABIConfig.cmake)
find_package(Threads REQUIRED)
set(GAME_COMPILE_DEFS DUSK_BUILDING_GAME=1)
set(GAME_LIBS aurora::core aurora::gx aurora::gd aurora::si aurora::vi aurora::pad aurora::mtx aurora::os aurora::dvd
aurora::card borealis::cli borealis::crash borealis::data borealis::disc borealis::discord borealis::file_select borealis::io borealis::log borealis::presentation borealis::sentry borealis::update freeverb cxxopts::cxxopts absl::flat_hash_map nlohmann_json::nlohmann_json TracyClient fmt::fmt
aurora::card borealis::cli borealis::crash borealis::data borealis::disc borealis::discord borealis::file_select borealis::http borealis::io borealis::log borealis::presentation borealis::sentry borealis::update freeverb cxxopts::cxxopts absl::flat_hash_map nlohmann_json::nlohmann_json TracyClient fmt::fmt
Threads::Threads zstd::libzstd dusklight_game_headers)
if (DUSK_HAS_FUNCHOOK)
list(APPEND GAME_LIBS funchook-static)
+8
View File
@@ -1482,6 +1482,8 @@ set(DUSK_FILES
src/dusk/mods/loader/native_module.hpp
src/dusk/mods/loader/prepatch.cpp
src/dusk/mods/loader/prepatch.hpp
src/dusk/mods/catalog.cpp
src/dusk/mods/catalog.hpp
src/dusk/mods/item.hpp
src/dusk/mods/item_checks.cpp
src/dusk/mods/item_gives.cpp
@@ -1563,8 +1565,14 @@ set(DUSK_FILES
src/dusk/ui/list.hpp
src/dusk/ui/menu_bar.cpp
src/dusk/ui/menu_bar.hpp
src/dusk/ui/mod_browser.cpp
src/dusk/ui/mod_browser.hpp
src/dusk/ui/mod_texture_provider.cpp
src/dusk/ui/mod_texture_provider.hpp
src/dusk/ui/remote_texture_provider.cpp
src/dusk/ui/remote_texture_provider.hpp
src/dusk/ui/runtime_image.cpp
src/dusk/ui/runtime_image.hpp
src/dusk/ui/mod_window.cpp
src/dusk/ui/mod_window.hpp
src/dusk/ui/modal.cpp
+38 -36
View File
@@ -2,19 +2,20 @@ window.logs content {
flex-flow: column;
}
window.logs .log-toolbar {
window.logs log-toolbar {
display: flex;
flex-flow: row;
flex: 0 0 64dp;
height: 64dp;
flex: 0 0 var(--toolbar-height);
height: var(--toolbar-height);
align-items: center;
gap: 8dp;
gap: var(--space-sm);
padding-right: 72dp;
background-color: rgba(217, 217, 217, 10%);
border-bottom: 2dp #92875B;
font-family: "Fira Sans Condensed";
background-color: rgba(var(--color-neutral-rgb), 10%);
border-bottom-width: 2dp;
border-bottom-color: var(--color-border);
font-family: var(--font-family-heading);
font-weight: bold;
font-size: 18dp;
font-size: var(--font-size-xl);
}
window.logs > close {
@@ -22,76 +23,77 @@ window.logs > close {
right: 8dp;
}
window.logs .log-title {
window.logs log-title {
align-self: stretch;
flex: 0 0 auto;
padding: 0 24dp;
line-height: 64dp;
padding: 0 var(--space-xl);
line-height: var(--toolbar-height);
text-transform: uppercase;
border-bottom: 4dp #C2A42D;
border-bottom-width: 4dp;
border-bottom-color: var(--color-accent);
font-effect: glow(0dp 4dp 0dp 4dp black);
}
window.logs .log-title-mod {
window.logs log-title-mod {
flex: 0 1 auto;
min-width: 0;
white-space: nowrap;
overflow: hidden;
font-family: "Fira Sans";
font-family: var(--font-family-body);
font-weight: normal;
font-size: 15dp;
color: rgba(224, 219, 200, 55%);
font-size: var(--font-size-base);
color: rgba(var(--color-text-rgb), 55%);
}
.log-toolbar-spacer {
log-toolbar-spacer {
flex: 1 1 0;
}
.log-toolbar button {
log-toolbar button {
flex: 0 0 auto;
font-family: "Fira Sans";
font-family: var(--font-family-body);
font-weight: normal;
font-size: 15dp;
padding: 5dp 12dp;
font-size: var(--font-size-base);
padding: 5dp var(--space-md);
}
window.logs content pane.log-view {
flex: 1 1 0;
padding: 12dp 16dp;
padding: var(--space-md) var(--space-lg);
padding-bottom: 0dp;
gap: 0dp;
}
.log-lines {
log-lines {
display: block;
}
.log-line {
log-line {
display: block;
font-family: "Noto Mono";
font-size: 13dp;
font-family: var(--font-family-monospace);
font-size: var(--font-size-xs);
line-height: 1.5;
word-break: break-word;
white-space: pre-wrap;
}
.log-line .log-time {
color: rgba(224, 219, 200, 45%);
log-line log-time {
color: rgba(var(--color-text-rgb), 45%);
}
.log-line .log-mod {
color: rgba(194, 164, 45, 80%);
log-line log-mod {
color: rgba(var(--color-accent-rgb), 80%);
}
.log-line.lvl-trace,
.log-line.lvl-debug {
log-line.lvl-trace,
log-line.lvl-debug {
opacity: 0.55;
}
.log-line.lvl-warn .log-msg {
color: #ffa826;
log-line.lvl-warn log-msg {
color: var(--color-warning);
}
.log-line.lvl-error .log-msg {
color: #cc4444;
log-line.lvl-error log-msg {
color: var(--color-error);
}
+801
View File
@@ -0,0 +1,801 @@
window.mod-browser,
window.mod-browser-detail,
window.screenshot-viewer {
--color-official-badge: #46edd5;
--color-catalog-warning-background-rgb: 180, 107, 42;
--color-catalog-warning-text: #e8cba7;
--color-catalog-missing: #e8b177;
background-color: rgba(var(--color-surface-rgb), 96%);
}
window.mod-browser > content {
flex-flow: row;
}
catalog-filters {
display: flex;
flex-flow: column;
flex: 0 0 264dp;
min-width: 0;
padding: var(--space-xl) 18dp;
gap: var(--space-sm);
border-right-width: 1dp;
border-right-color: var(--color-border);
background-color: rgba(var(--color-control-rgb), 45%);
}
catalog-filters h1,
catalog-results header h1 {
margin: 0;
font-family: var(--font-family-heading);
font-size: var(--font-size-5xl);
font-weight: bold;
}
catalog-filters h1 {
padding: 0 var(--space-sm) var(--space-sm) var(--space-sm);
}
catalog-filters h2 {
margin: var(--space-md) var(--space-sm) 0 var(--space-sm);
font-family: var(--font-family-heading);
font-size: var(--font-size-xs);
font-weight: bold;
text-transform: uppercase;
opacity: 0.42;
}
catalog-filters select-button {
padding: var(--space-sm) 10dp;
border-radius: var(--radius-panel);
}
catalog-filters select-button key {
font-size: var(--font-size-xs);
}
catalog-filters select-button value,
catalog-filters select-button input {
font-size: var(--font-size-sm);
}
catalog-filters button.catalog-library-link {
padding: var(--space-sm) 10dp;
border-radius: var(--radius-panel);
text-align: left;
font-size: var(--font-size-sm);
}
catalog-results {
display: flex;
flex-flow: column;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
padding: var(--space-xl);
gap: var(--space-lg);
}
catalog-results header {
display: block;
flex: 0 0 auto;
padding-right: var(--space-2xl);
}
catalog-results header catalog-results-summary {
display: block;
margin-top: var(--space-2xs);
font-size: var(--font-size-xs);
opacity: 0.5;
}
catalog-viewport {
display: block;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow: hidden auto;
}
catalog-grid {
display: flex;
flex-flow: row wrap;
min-width: 0;
gap: 14dp;
padding: var(--space-2xs);
}
button.catalog-card {
display: flex;
flex-flow: column;
flex: 0 0 48%;
min-width: 260dp;
height: 310dp;
padding: 0;
overflow: hidden;
text-align: left;
border-radius: 12dp;
}
catalog-card-art {
display: block;
position: relative;
flex: 0 0 118dp;
min-height: 118dp;
background-color: rgba(var(--color-border-rgb), 12%);
}
catalog-card-art-shadow {
display: block;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
pointer-events: none;
decorator: linear-gradient(180deg, rgba(var(--color-surface-rgb), 0%) 45%, rgba(var(--color-surface-rgb), 85%));
}
catalog-card-body {
display: flex;
flex-flow: column;
flex: 1 1 auto;
min-height: 0;
padding: var(--space-xl) var(--space-lg) var(--space-md) var(--space-lg);
gap: 7dp;
}
catalog-card-kicker {
display: flex;
align-items: center;
gap: 6dp;
font-size: var(--font-size-sm);
}
catalog-official-badge {
padding: var(--space-2xs) 5dp;
border-radius: var(--radius-small);
background-color: rgba(var(--color-accent-rgb), 28%);
color: var(--color-official-badge);
font-style: normal;
font-size: 9dp;
}
catalog-card-kicker > catalog-card-category {
font-family: var(--font-family-heading);
font-size: var(--font-size-sm);
font-weight: bold;
text-transform: uppercase;
color: var(--color-accent);
}
catalog-card-updated {
margin-left: auto;
font-weight: normal;
text-transform: none;
opacity: 0.45;
}
catalog-card-identity {
display: block;
}
catalog-card-icon {
display: block;
position: absolute;
left: 14dp;
bottom: -18dp;
z-index: 1;
width: 56dp;
height: 56dp;
border-radius: var(--radius-panel);
overflow: hidden;
background-color: rgba(var(--color-border-rgb), 18%);
box-shadow: rgba(0, 0, 0, 60%) 0 6dp 16dp;
}
catalog-card-icon-image {
display: block;
width: 100%;
height: 100%;
}
catalog-card-title {
display: block;
font-family: var(--font-family-body);
font-size: var(--font-size-3xl);
font-weight: bold;
color: var(--color-neutral);
line-height: 1.5;
}
catalog-card-author {
display: block;
font-size: var(--font-size-3xs);
opacity: 0.55;
}
catalog-card-summary {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
font-size: var(--font-size-base);
line-height: 1.35;
color: rgba(var(--color-text-rgb), 68%);
}
catalog-card-meta {
display: flex;
align-items: center;
gap: 10dp;
font-size: var(--font-size-3xs);
opacity: 0.52;
}
catalog-card-stat {
display: flex;
align-items: center;
gap: 3dp;
}
catalog-card-meta icon {
flex: 0 0 13dp;
font-size: var(--font-size-sm);
line-height: 1;
}
catalog-card-size {
margin-left: auto;
font-size: var(--font-size-sm);
}
catalog-card-size.installed {
color: var(--color-success);
}
catalog-pagination {
display: flex;
flex-flow: row;
align-items: center;
flex: 0 0 auto;
gap: var(--space-sm);
}
catalog-pagination-label {
flex: 1 1 auto;
text-align: center;
font-size: var(--font-size-xs);
opacity: 0.5;
}
catalog-pagination button {
font-size: var(--font-size-sm);
padding: 6dp var(--space-md);
}
catalog-results-status,
catalog-detail-status {
display: flex;
flex-flow: column;
align-items: center;
justify-content: center;
height: 100%;
gap: var(--space-sm);
text-align: center;
}
catalog-status-title {
display: block;
font-family: var(--font-family-heading);
font-size: 25dp;
font-weight: bold;
}
catalog-status-message {
display: block;
opacity: 0.58;
}
catalog-results-status button,
catalog-detail-status button {
margin-top: var(--space-sm);
font-size: var(--font-size-base);
}
window.mod-browser-detail > content {
display: block;
}
detail-scroll {
display: flex;
flex-flow: column;
width: 100%;
height: 100%;
min-width: 0;
overflow: hidden auto;
padding-bottom: var(--space-2xl);
}
catalog-detail-hero {
display: flex;
position: relative;
flex-flow: column;
justify-content: space-between;
flex: 0 0 220dp;
min-height: 220dp;
padding: 18dp var(--space-xl) 20dp var(--space-xl);
background-color: rgba(var(--color-control-rgb), 75%);
}
catalog-detail-hero-shadow {
display: block;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
pointer-events: none;
decorator: linear-gradient(180deg, rgba(var(--color-surface-rgb), 25%) 40%, rgba(var(--color-surface-rgb), 92%));
}
catalog-detail-actions {
display: flex;
position: relative;
z-index: 1;
flex-flow: row;
gap: var(--space-sm);
}
catalog-detail-actions button,
catalog-source-actions button {
font-size: var(--font-size-sm);
padding: 7dp var(--space-md);
--button-background: rgba(var(--color-control-rgb), 75%);
--button-background-hover: rgba(var(--color-control-rgb), 90%);
--button-background-selected: rgba(var(--color-control-rgb), 90%);
--button-background-active: rgba(var(--color-surface-rgb), 90%);
}
.catalog-icon-action {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.catalog-icon-action icon {
flex: 0 0 18dp;
font-size: var(--font-size-xl);
line-height: 1;
}
catalog-detail-identity {
display: flex;
position: relative;
z-index: 1;
align-items: center;
gap: 14dp;
}
catalog-detail-icon {
display: block;
flex: 0 0 70dp;
width: 70dp;
height: 70dp;
border-radius: var(--radius-panel);
overflow: hidden;
background-color: rgba(var(--color-border-rgb), 25%);
box-shadow: rgba(0, 0, 0, 65%) 0 8dp 20dp;
}
catalog-detail-icon-image {
display: block;
width: 100%;
height: 100%;
}
catalog-detail-heading {
display: block;
min-width: 0;
}
catalog-detail-category {
display: block;
font-family: var(--font-family-heading);
font-size: var(--font-size-sm);
font-weight: bold;
text-transform: uppercase;
color: var(--color-accent);
}
catalog-detail-identity h1 {
display: block;
margin: 1dp 0;
font-family: var(--font-family-body);
font-size: var(--font-size-5xl);
font-weight: bold;
color: var(--color-neutral);
}
catalog-detail-identity h1 small {
margin-left: 9dp;
font-family: var(--font-family-body);
font-size: var(--font-size-xs);
font-weight: normal;
opacity: 0.55;
}
catalog-detail-identity p {
display: block;
margin: 0;
font-size: var(--font-size-xs);
opacity: 0.72;
}
catalog-detail-stats {
display: flex;
flex-flow: row;
align-items: center;
gap: 28dp;
padding: 13dp 28dp;
border-top-width: 1dp;
border-top-color: rgba(var(--color-border-rgb), 30%);
border-bottom-width: 1dp;
border-bottom-color: rgba(var(--color-border-rgb), 30%);
font-size: var(--font-size-2xs);
color: rgba(var(--color-text-rgb), 58%);
}
catalog-detail-stat {
display: flex;
align-items: center;
gap: 5dp;
}
catalog-detail-stats icon {
flex: 0 0 17dp;
font-size: var(--font-size-lg);
line-height: 1;
}
catalog-stat-value {
color: var(--color-text);
font-weight: bold;
}
/*catalog-native-warning {
display: flex;
align-items: center;
gap: 10dp;
margin: 18dp var(--space-xl) 0 var(--space-xl);
padding: 11dp 14dp;
border-radius: var(--radius-panel);
background-color: rgba(var(--color-catalog-warning-background-rgb), 18%);
box-shadow: rgba(206, 137, 67, 45%) 0 0 0 1dp;
color: var(--color-catalog-warning-text);
}
catalog-native-warning icon {
font-size: var(--font-size-4xl);
}
catalog-warning-copy {
display: flex;
flex-flow: column;
font-size: var(--font-size-2xs);
}
catalog-warning-title {
display: block;
font-size: var(--font-size-xs);
font-weight: bold;
}
catalog-warning-message {
display: block;
font-size: var(--font-size-3xs);
}*/
catalog-detail-body {
display: flex;
flex-flow: row;
align-items: flex-start;
gap: 28dp;
padding: var(--space-xl);
}
catalog-detail-body main {
display: flex;
flex-flow: column;
flex: 1 1 auto;
min-width: 0;
gap: 28dp;
}
catalog-detail-body section {
display: block;
}
catalog-detail-body section.catalog-scroll-anchor {
focus: auto;
}
catalog-detail-body section.catalog-scroll-anchor:focus-visible {
border-radius: var(--radius-control);
box-shadow: rgba(194, 164, 45, 45%) 0 0 0 1dp;
}
catalog-detail-body h2,
catalog-detail-body h3 {
display: block;
margin: 0 0 10dp 0;
font-family: var(--font-family-heading);
font-size: 21dp;
font-weight: bold;
}
catalog-detail-body h2 small {
margin-left: 7dp;
font-family: var(--font-family-body);
font-size: var(--font-size-3xs);
font-weight: normal;
opacity: 0.5;
}
catalog-fragment {
display: block;
font-size: var(--font-size-md);
line-height: 1.55;
color: var(--color-neutral);
}
catalog-fragment p,
catalog-fragment ul,
catalog-fragment ol {
display: block;
margin: 0 0 10dp 0;
}
catalog-fragment ul,
catalog-fragment ol {
padding-left: var(--space-xl);
}
catalog-fragment li {
display: block;
position: relative;
margin: 3dp 0;
}
catalog-list-marker {
display: block;
position: absolute;
right: 100%;
width: var(--space-xl);
padding-right: var(--space-sm);
text-align: right;
}
catalog-fragment h1,
catalog-fragment h2,
catalog-fragment h3,
catalog-fragment h4,
catalog-fragment h5,
catalog-fragment h6 {
display: block;
margin: 15dp 0 5dp 0;
font-family: var(--font-family-heading);
font-weight: bold;
font-size: var(--font-size-xl);
color: var(--color-neutral);
}
catalog-fragment h1 {
font-size: var(--font-size-5xl);
}
catalog-fragment h2 {
font-size: var(--font-size-4xl);
}
catalog-fragment h3 {
font-size: var(--font-size-2xl);
}
window.screenshot-viewer > close {
display: none;
}
catalog-gallery {
display: flex;
flex-flow: row;
height: 210dp;
gap: var(--space-sm);
}
button.catalog-screenshot {
flex: 1 1 0;
height: 100%;
min-width: 0;
padding: 0;
border-radius: var(--radius-panel);
--button-background: rgba(var(--color-border-rgb), 14%);
font-size: var(--font-size-5xl);
}
button.catalog-screenshot.primary {
flex: 2 1 0;
}
catalog-dependencies {
display: flex;
flex-flow: column;
gap: 7dp;
font-size: var(--font-size-xs);
}
catalog-dependency {
display: flex;
flex-flow: column;
padding: 9dp 11dp;
border-radius: var(--radius-control);
background-color: rgba(var(--color-border-rgb), 9%);
}
catalog-dependency-status {
display: block;
font-size: var(--font-size-3xs);
opacity: 0.56;
}
catalog-dependency.missing {
color: var(--color-catalog-missing);
}
catalog-detail-body aside {
display: flex;
flex-flow: column;
flex: 0 0 264dp;
min-width: 0;
padding: var(--space-lg);
gap: var(--space-md);
border-radius: 9dp;
background-color: rgba(var(--color-control-rgb), 42%);
box-shadow: rgba(146, 135, 91, 24%) 0 0 0 1dp;
}
catalog-detail-body dl {
display: flex;
flex-flow: row wrap;
margin: 0;
font-size: var(--font-size-2xs);
}
catalog-detail-body dt {
flex: 0 0 42%;
padding: 5dp 0;
opacity: 0.5;
}
catalog-detail-body dd {
flex: 1 1 52%;
margin: 0;
padding: 5dp 0;
text-align: right;
}
catalog-source-actions {
display: flex;
flex-flow: column;
}
window.screenshot-viewer > content {
flex-flow: column;
padding: 18dp;
gap: var(--space-md);
}
catalog-screenshot-full {
display: block;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
background-color: rgba(var(--color-black-rgb), 45%);
}
catalog-screenshot-actions {
display: flex;
flex-flow: row;
justify-content: center;
flex: 0 0 auto;
gap: var(--space-sm);
}
catalog-screenshot-actions button {
font-size: var(--font-size-sm);
padding: 7dp 13dp;
}
@media (max-height: 640dp) {
catalog-filters {
flex-basis: 232dp;
padding: 15dp var(--space-md);
gap: 5dp;
}
catalog-filters h1,
catalog-results header h1 {
font-size: 23dp;
}
catalog-filters h2 {
margin-top: 6dp;
}
catalog-results {
padding: 15dp;
gap: 10dp;
}
button.catalog-card {
min-width: 220dp;
height: 254dp;
}
catalog-card-art {
flex-basis: 78dp;
min-height: 78dp;
}
catalog-card-body {
padding: 18dp 10dp var(--space-sm) 10dp;
}
catalog-card-icon {
left: 10dp;
bottom: -14dp;
width: 40dp;
height: 40dp;
}
catalog-card-kicker,
catalog-card-kicker > catalog-card-category {
font-size: var(--font-size-3xs);
}
catalog-card-title {
font-size: var(--font-size-md);
}
catalog-card-summary {
font-size: var(--font-size-3xs);
}
catalog-detail-hero {
flex-basis: 160dp;
min-height: 160dp;
padding: var(--space-md) 18dp;
}
catalog-detail-icon {
flex-basis: 56dp;
width: 56dp;
height: 56dp;
}
catalog-detail-category {
font-size: var(--font-size-2xs);
}
catalog-detail-identity h1 {
font-size: var(--font-size-3xl);
}
catalog-detail-body {
padding: 18dp;
gap: 18dp;
}
catalog-gallery {
height: 160dp;
}
}
+73 -67
View File
@@ -1,8 +1,8 @@
window.mods content pane.mod-list {
flex: 0 0 360dp;
padding: 16dp;
padding: var(--space-lg);
padding-bottom: 0dp;
gap: 4dp;
gap: var(--space-xs);
}
@media (max-height: 640dp) {
@@ -12,29 +12,36 @@ window.mods content pane.mod-list {
}
window.mods content pane.mod-detail {
gap: 12dp;
gap: var(--space-md);
}
.mod-info-row {
window.mods content pane.mod-list button.mod-browser-entry {
margin-bottom: var(--space-sm);
text-align: left;
font-family: var(--font-family-heading);
font-weight: bold;
}
mod-info-row {
display: flex;
align-items: center;
gap: 12dp;
padding: 4dp 0;
gap: var(--space-md);
padding: var(--space-xs) 0;
}
.mod-info-label {
font-family: "Fira Sans Condensed";
mod-info-label {
font-family: var(--font-family-heading);
font-weight: bold;
opacity: 0.55;
flex: 0 0 auto;
}
.mod-info-value {
mod-info-value {
flex: 1 1 0;
}
.mod-path {
font-size: 14dp;
font-size: var(--font-size-sm);
word-break: break-all;
opacity: 0.7;
}
@@ -42,10 +49,10 @@ window.mods content pane.mod-detail {
mod-entry {
display: flex;
flex-flow: row;
gap: 12dp;
gap: var(--space-md);
padding: 10dp;
border-radius: 10dp;
decorator: vertical-gradient(#c2a42d00 #c2a42d00);
decorator: vertical-gradient(rgba(var(--color-accent-rgb), 0%) rgba(var(--color-accent-rgb), 0%));
transition: decorator 0.1s linear-in-out;
cursor: pointer;
focus: auto;
@@ -57,43 +64,43 @@ mod-entry.current {
mod-entry:hover,
mod-entry:focus-visible {
decorator: vertical-gradient(#c2a42d00 #c2a42d26);
decorator: vertical-gradient(rgba(var(--color-accent-rgb), 0) rgba(var(--color-accent-rgb), 38));
}
mod-entry:selected {
decorator: vertical-gradient(#c2a42d10 #c2a42d40);
decorator: vertical-gradient(rgba(var(--color-accent-rgb), 16) rgba(var(--color-accent-rgb), 64));
}
mod-entry .mod-icon {
flex: 0 0 auto;
width: 56dp;
height: 56dp;
border-radius: 8dp;
border-radius: var(--radius-panel);
}
mod-entry icon.mod-icon {
font-size: 36dp;
background-color: rgba(17, 16, 10, 20%);
color: rgba(224, 219, 200, 45%);
background-color: rgba(var(--color-control-rgb), 20%);
color: rgba(var(--color-text-rgb), 45%);
decorator: text("" center center);
}
mod-entry .mod-entry-info {
mod-entry mod-entry-info {
display: flex;
flex-flow: column;
flex: 1 1 0;
min-width: 0;
gap: 2dp;
gap: var(--space-2xs);
}
mod-entry .mod-entry-name {
mod-entry mod-entry-name {
display: flex;
flex-flow: row;
align-items: baseline;
gap: 6dp;
}
mod-entry .mod-entry-name-text {
mod-entry mod-entry-name-text {
flex: 0 1 auto;
min-width: 0;
font-weight: bold;
@@ -101,47 +108,47 @@ mod-entry .mod-entry-name-text {
overflow: hidden;
}
mod-entry .mod-entry-version {
mod-entry mod-entry-version {
flex: 0 0 auto;
font-size: 13dp;
color: rgba(224, 219, 200, 50%);
font-size: var(--font-size-xs);
color: rgba(var(--color-text-rgb), 50%);
}
mod-entry .mod-entry-status.active {
color: #44cc55;
mod-entry mod-entry-status.active {
color: var(--color-success);
}
mod-entry .mod-entry-status.failed {
color: #cc4444;
mod-entry mod-entry-status.failed {
color: var(--color-error);
}
mod-entry .mod-entry-network {
mod-entry mod-entry-network {
margin-left: 6dp;
padding: 1dp 5dp;
border-radius: 5dp;
background-color: rgba(67, 151, 219, 20%);
color: #6fb7ef;
background-color: rgba(var(--color-info-rgb), 20%);
color: var(--color-info);
}
mod-entry .mod-entry-desc {
font-size: 14dp;
mod-entry mod-entry-desc {
font-size: var(--font-size-sm);
line-height: 1.3;
color: rgba(224, 219, 200, 65%);
color: rgba(var(--color-text-rgb), 65%);
max-height: 2.6em;
overflow: hidden;
white-space: pre-wrap;
}
mod-entry .mod-entry-sub {
font-size: 13dp;
color: rgba(224, 219, 200, 50%);
mod-entry mod-entry-sub {
font-size: var(--font-size-xs);
color: rgba(var(--color-text-rgb), 50%);
}
mod-entry.inactive .mod-icon {
filter: grayscale(1);
}
mod-entry.inactive .mod-entry-info {
mod-entry.inactive mod-entry-info {
opacity: 0.5;
}
@@ -155,19 +162,19 @@ mod-header.has-banner {
margin: -24dp -24dp 0dp -24dp;
}
mod-header .mod-actions {
mod-header mod-actions {
position: absolute;
top: 24dp;
left: 24dp;
display: flex;
flex-flow: row;
gap: 8dp;
gap: var(--space-sm);
}
mod-header .mod-actions button {
font-size: 16dp;
mod-header mod-actions button {
font-size: var(--font-size-md);
padding: 6dp 14dp;
background-color: rgba(21, 22, 16, 80%);
background-color: rgba(var(--color-surface-rgb), 80%);
box-shadow: rgba(146, 135, 91, 60%) 0 0 0 1dp;
}
@@ -177,56 +184,55 @@ mod-header.no-banner {
align-items: center;
}
mod-header.no-banner .mod-actions {
mod-header.no-banner mod-actions {
position: static;
}
window.mods .mod-title {
window.mods mod-title {
display: block;
font-size: 28dp;
font-size: var(--font-size-5xl);
font-weight: bold;
}
window.mods .mod-title .mod-title-version {
window.mods mod-title mod-title-version {
font-weight: normal;
font-size: 16dp;
color: rgba(224, 219, 200, 55%);
font-size: var(--font-size-md);
color: rgba(var(--color-text-rgb), 55%);
}
window.mods .mod-author {
window.mods mod-author {
display: block;
font-size: 15dp;
color: rgba(224, 219, 200, 55%);
font-size: var(--font-size-base);
color: rgba(var(--color-text-rgb), 55%);
}
window.mods .mod-restart-note {
font-size: 15dp;
color: #ffa826;
window.mods mod-restart-note {
font-size: var(--font-size-base);
color: var(--color-warning);
opacity: 0.85;
}
window.mods .mod-description {
window.mods mod-description {
line-height: 1.5;
}
.status-badge {
font-size: 14dp;
status-badge {
font-size: var(--font-size-sm);
opacity: 0.7;
}
.status-badge.active,
.mod-info-label.active {
color: #44cc55;
status-badge.active,
mod-info-label.active {
color: var(--color-success);
opacity: 1;
}
.status-badge.failed,
.mod-info-label.failed {
color: #cc4444;
status-badge.failed,
mod-info-label.failed {
color: var(--color-error);
opacity: 1;
}
.status-badge.network {
color: #6fb7ef;
status-badge.network {
color: var(--color-info);
opacity: 1;
}
+40 -44
View File
@@ -8,10 +8,10 @@ body {
height: 100%;
margin: 0;
padding: 0;
font-family: "Fira Sans";
font-family: var(--font-family-body);
font-weight: normal;
font-size: 20dp;
color: #E0DBC8;
font-size: var(--font-size-2xl);
color: var(--color-text);
display: flex;
flex-direction: column;
justify-content: flex-end;
@@ -24,16 +24,22 @@ fps,
pipeline-progress,
toast {
position: absolute;
border: 1dp #92875B;
background-color: rgba(21, 22, 16, 80%);
border-width: 1dp;
background-color: rgba(var(--color-surface-rgb), 80%);
}
fps,
pipeline-progress {
border-color: var(--color-border);
}
toast {
border-color: var(--toast-border-color);
top: 40dp;
right: 40dp;
display: flex;
flex-flow: column;
border-radius: 14dp;
border-radius: var(--radius-window);
overflow: hidden;
backdrop-filter: blur(5dp);
box-shadow: 0 0 15dp 3dp;
@@ -41,8 +47,8 @@ toast {
transform: scale(0.9);
transform-origin: center;
transition: filter transform 0.2s cubic-in-out;
padding: 18dp 24dp;
gap: 8dp;
padding: 18dp var(--space-xl);
gap: var(--space-sm);
}
toast[open] {
@@ -67,14 +73,14 @@ toast heading {
display: flex;
gap: 18dp;
align-items: center;
font-family: "Fira Sans Condensed";
font-size: 18dp;
font-family: var(--font-family-heading);
font-size: var(--font-size-xl);
font-weight: bold;
text-transform: uppercase;
color: #92875B;
color: var(--toast-heading-color);
}
toast heading > span {
toast heading > toast-title {
flex: 1 0 auto;
}
@@ -82,13 +88,13 @@ toast heading > row {
flex: 1 0 auto;
display: flex;
align-items: center;
gap: 4dp;
gap: var(--space-xs);
}
toast message {
display: flex;
flex-flow: column;
gap: 8dp;
gap: var(--space-sm);
}
toast message row {
@@ -108,7 +114,7 @@ progress {
}
progress fill {
background-color: rgba(194, 164, 45, 80%);
background-color: rgba(var(--color-accent-rgb), 80%);
}
pipeline-progress {
@@ -119,8 +125,8 @@ pipeline-progress {
z-index: 100;
min-width: 260dp;
max-width: 90%;
padding: 10dp 16dp 12dp;
border-radius: 7dp;
padding: 10dp var(--space-lg) var(--space-md);
border-radius: var(--radius-control);
overflow: hidden;
filter: opacity(0);
transition: filter 0.2s linear-in-out;
@@ -134,8 +140,8 @@ pipeline-progress[open] {
pipeline-status {
display: flex;
align-items: center;
gap: 8dp;
font-size: 18dp;
gap: var(--space-sm);
font-size: var(--font-size-xl);
font-weight: normal;
white-space: nowrap;
}
@@ -145,26 +151,16 @@ icon.pipeline-spinner {
height: 1.2em;
line-height: 1.2em;
font-size: 1.2em;
color: #C2A42D;
color: var(--color-accent);
text-align: center;
transform-origin: center;
animation: 1s linear infinite pipeline-spinner-spin;
}
toast.achievement {
border: 1dp #C2A42D;
}
toast.achievement heading {
color: #C2A42D;
}
toast.achievement,
toast.warning {
border: 1dp #C2A42D;
}
toast.warning heading {
color: #C2A42D;
--toast-border-color: var(--color-accent);
--toast-heading-color: var(--color-accent);
}
toast.controller-warning {
@@ -181,8 +177,8 @@ toast.controller-warning[open] {
transform: translateX(-50%) scale(1);
}
toast.controller-warning heading {
color: #C2A42D;
toast.controller-warning {
--toast-heading-color: var(--color-accent);
}
toast.menu-notification {
@@ -209,7 +205,7 @@ toast.menu-notification message row {
}
icon {
font-family: "Material Symbols Rounded";
font-family: var(--font-family-icons);
font-weight: normal;
display: inline-block;
vertical-align: middle;
@@ -246,10 +242,10 @@ icon.warning {
fps {
display: none;
z-index: 99;
font-size: 18dp;
font-size: var(--font-size-xl);
font-weight: bold;
padding: 9dp 12dp;
border-radius: 7dp;
padding: 9dp var(--space-md);
border-radius: var(--radius-control);
pointer-events: none;
white-space: nowrap;
}
@@ -260,12 +256,12 @@ speedrun-timer {
bottom: 0;
right: 0;
z-index: 99;
background-color: rgba(0, 0, 0, 65%);
padding: 2dp 4dp;
background-color: rgba(var(--color-black-rgb), 65%);
padding: var(--space-2xs) var(--space-xs);
pointer-events: none;
font-family: "Noto Mono";
font-size: 16dp;
color: #ffffff;
font-family: var(--font-family-monospace);
font-size: var(--font-size-md);
color: var(--color-white);
white-space: nowrap;
}
+35 -31
View File
@@ -3,6 +3,8 @@
}
body {
--button-background: rgba(var(--color-control-rgb), 35%);
width: 100%;
height: 100%;
z-index: 10;
@@ -12,12 +14,13 @@ popover {
position: absolute;
display: flex;
flex-flow: column;
font-family: "Fira Sans";
font-size: 14dp;
color: #E0DBC8;
border-radius: 14dp;
border: 2dp #92875B;
background-color: rgba(21, 22, 16, 96%);
font-family: var(--font-family-body);
font-size: var(--font-size-sm);
color: var(--color-text);
border-radius: var(--radius-window);
border-width: 2dp;
border-color: var(--color-border);
background-color: rgba(var(--color-surface-rgb), 96%);
backdrop-filter: blur(5dp);
box-shadow: 0 6dp 24dp 2dp rgba(0, 0, 0, 55%);
filter: opacity(0);
@@ -43,7 +46,7 @@ color-sv {
position: relative;
width: 240dp;
height: 150dp;
border-radius: 8dp;
border-radius: var(--radius-panel);
box-shadow: rgba(146, 135, 91, 50%) 0 0 0 1dp;
drag: drag;
focus: auto;
@@ -55,7 +58,7 @@ color-alpha {
position: relative;
width: 240dp;
height: 14dp;
border-radius: 7dp;
border-radius: var(--radius-control);
box-shadow: rgba(146, 135, 91, 50%) 0 0 0 1dp;
drag: drag;
focus: auto;
@@ -82,20 +85,21 @@ color-cursor {
position: absolute;
width: 14dp;
height: 14dp;
border-radius: 7dp;
border: 2dp #ffffff;
border-radius: var(--radius-control);
border-width: 2dp;
border-color: var(--color-white);
box-shadow: 0 0 4dp 1dp rgba(0, 0, 0, 70%);
pointer-events: none;
}
color-heading {
display: block;
margin-top: 2dp;
font-family: "Fira Sans Condensed";
margin-top: var(--space-2xs);
font-family: var(--font-family-heading);
font-weight: bold;
font-size: 13dp;
font-size: var(--font-size-xs);
text-transform: uppercase;
color: rgba(224, 219, 200, 55%);
color: rgba(var(--color-text-rgb), 55%);
}
color-presets {
@@ -114,7 +118,7 @@ button.color-swatch-button {
width: 25dp;
height: 25dp;
padding: 0;
border-radius: 7dp;
border-radius: var(--radius-control);
}
color-chip {
@@ -129,56 +133,56 @@ color-chip {
button.color-swatch-button color-chip {
width: 25dp;
height: 25dp;
border-radius: 7dp;
border-radius: var(--radius-control);
}
color-chip.empty,
color-swatch.empty {
background-color: rgba(224, 219, 200, 12%);
decorator: linear-gradient(135deg, rgba(224, 219, 200, 0) 45%,
rgba(194, 164, 45, 70%) 48%, rgba(194, 164, 45, 70%) 52%,
rgba(224, 219, 200, 0) 55%);
background-color: rgba(var(--color-text-rgb), 12%);
decorator: linear-gradient(135deg, rgba(var(--color-text-rgb), 0) 45%,
rgba(var(--color-accent-rgb), 70%) 48%, rgba(var(--color-accent-rgb), 70%) 52%,
rgba(var(--color-text-rgb), 0) 55%);
}
color-footer {
display: flex;
align-items: center;
gap: 6dp;
padding-top: 2dp;
padding-top: var(--space-2xs);
}
button {
background-color: rgba(17, 16, 10, 35%);
padding: 4dp 8dp;
border-radius: 8dp;
background-color: var(--button-background);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-panel);
box-shadow: rgba(146, 135, 91, 30%) 0 0 0 1dp;
color: #E0DBC8;
color: var(--button-color);
cursor: pointer;
focus: auto;
}
button:hover,
button:focus-visible {
background-color: rgba(204, 184, 119, 20%);
background-color: var(--button-background-hover);
box-shadow: #C2A42D 0 0 0 2dp;
}
button:active {
background-color: rgba(204, 184, 119, 40%);
background-color: var(--button-background-active);
}
color-value {
display: block;
flex: 1 1 auto;
text-align: right;
font-family: "Noto Mono";
font-size: 12dp;
color: #FFFFFF;
font-family: var(--font-family-monospace);
font-size: var(--font-size-2xs);
color: var(--color-white);
cursor: pointer;
focus: auto;
}
color-value:hover,
color-value:focus-visible {
color: #C2A42D;
color: var(--color-accent);
}
+9 -8
View File
@@ -8,10 +8,10 @@ body {
height: 100%;
margin: 0;
padding: 0;
font-family: "Fira Sans Condensed";
font-family: var(--font-family-heading);
font-weight: bold;
font-size: 18dp;
color: #E0DBC8;
font-size: var(--font-size-xl);
color: var(--color-text);
}
button {
@@ -23,11 +23,12 @@ popup {
width: 100%;
display: flex;
align-items: stretch;
height: 64dp;
background-color: rgba(21, 22, 16, 80%);
border-bottom: 2dp #92875B;
height: var(--toolbar-height);
background-color: rgba(var(--color-surface-rgb), 80%);
border-bottom-width: 2dp;
border-bottom-color: var(--color-border);
backdrop-filter: blur(5dp);
transform: translateY(-64dp);
transform: translateY(var(--toolbar-hidden-offset));
transition: transform 0.2s cubic-in-out;
}
@@ -41,5 +42,5 @@ popup tab-bar {
popup tab-bar tab {
opacity: 0.35;
color: #E0DBC8;
color: var(--color-text);
}
+87 -88
View File
@@ -1,32 +1,38 @@
*, *:before, *:after {
box-sizing: border-box;
}
body {
--color-prelaunch-accent: #FEE685;
--color-prelaunch-muted: #A6A09B;
--color-ready: #D8F999;
--menu-button-decorator: horizontal-gradient(rgba(var(--color-black-rgb), 0%) rgba(var(--color-black-rgb), 0%));
--menu-button-decorator-hover: horizontal-gradient(#FEE685FF #FEE68500);
width: 100%;
height: 100%;
font-family: "Fira Sans";
font-family: var(--font-family-body);
font-weight: normal;
font-size: 20dp;
color: #FFFFFF;
font-size: var(--font-size-2xl);
color: var(--color-white);
filter: opacity(0);
transition: filter 1s 0.2s linear-in-out;
z-index: -1;
}
.gradient {
body.mirrored {
--menu-button-decorator-hover: horizontal-gradient(#FEE68500 #FEE685FF);
}
prelaunch-gradient {
position: absolute;
width: 100%;
height: 100%;
/* The color gradient from the Figma bands really badly. A fully black gradient does as well, but not as badly. */
decorator: horizontal-gradient(#000000FF #00000000);
decorator: horizontal-gradient(rgba(var(--color-black-rgb), 100%) rgba(var(--color-black-rgb), 0%));
}
body.mirrored .gradient {
decorator: horizontal-gradient(#00000000 #000000FF);
body.mirrored prelaunch-gradient {
decorator: horizontal-gradient(rgba(var(--color-black-rgb), 0%) rgba(var(--color-black-rgb), 100%));
}
.background {
prelaunch-background {
position: absolute;
width: 100%;
height: 100%;
@@ -39,11 +45,11 @@ body[open] {
filter: opacity(1);
}
body[open] .background {
body[open] prelaunch-background {
opacity: 1;
}
body.disc-ready .background {
body.disc-ready prelaunch-background {
opacity: 0;
}
@@ -84,7 +90,7 @@ hero {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4dp;
gap: var(--space-xs);
}
body.mirrored hero {
@@ -96,8 +102,8 @@ hero img {
}
eyebrow {
font-family: "Alegreya SC";
font-size: 32dp;
font-family: var(--font-family-display);
font-size: var(--font-size-6xl);
}
@media (min-width: 1216dp) {
@@ -107,36 +113,36 @@ eyebrow {
}
}
eyebrow span {
eyebrow studio-name {
font-weight: bold;
}
#menu-list {
menu-list {
display: flex;
flex-direction: column;
gap: 12dp;
gap: var(--space-md);
align-items: flex-start;
}
#menu-list button {
width: 428dp;
height: 54dp;
padding: 8dp 16dp;
border-radius: 8dp;
padding: var(--space-sm) var(--space-lg);
border-radius: var(--radius-panel);
text-align: left;
text-transform: uppercase;
font-family: "Fira Sans Condensed";
font-size: 32dp;
font-family: var(--font-family-heading);
font-size: var(--font-size-6xl);
font-weight: normal;
cursor: pointer;
/* Define a fully transparent gradient as the default state, otherwise a white flash occurs */
decorator: horizontal-gradient(#00000000 #00000000);
decorator: var(--menu-button-decorator);
}
#menu-list button:disabled {
opacity: 0.75;
cursor: default;
decorator: horizontal-gradient(#00000000 #00000000);
decorator: var(--menu-button-decorator);
}
#menu-list button.anim-done {
@@ -157,7 +163,7 @@ eyebrow span {
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 8dp;
border-radius: var(--radius-panel);
pointer-events: none;
z-index: 0;
}
@@ -169,7 +175,7 @@ eyebrow span {
left: 0;
width: 100%;
height: 100%;
padding: 8dp 16dp;
padding: var(--space-sm) var(--space-lg);
opacity: 0;
text-overflow: ellipsis;
white-space: nowrap;
@@ -189,8 +195,8 @@ eyebrow span {
height: 54dp;
align-items: center;
justify-content: center;
color: #FFFFFF;
font-family: "Material Symbols Rounded";
color: var(--color-white);
font-family: var(--font-family-icons);
font-weight: normal;
font-size: 30dp;
z-index: 1;
@@ -213,8 +219,8 @@ eyebrow span {
#menu-list button:hover,
#menu-list button:focus-visible {
color: black;
decorator: horizontal-gradient(#FEE685FF #FEE68500);
color: var(--color-black);
decorator: var(--menu-button-decorator-hover);
}
body.mirrored #menu-list {
@@ -225,11 +231,6 @@ body.mirrored #menu-list button {
text-align: right;
}
body.mirrored #menu-list button:hover,
body.mirrored #menu-list button:focus-visible {
decorator: horizontal-gradient(#FEE68500 #FEE685FF);
}
disc-info {
position: absolute;
left: 96dp;
@@ -237,8 +238,8 @@ disc-info {
bottom: 72dp;
display: flex;
flex-direction: column;
gap: 12dp;
font-size: 24dp;
gap: var(--space-md);
font-size: var(--font-size-4xl);
font-effect: glow(0dp 4dp 0dp 4dp black);
text-align: left;
}
@@ -256,9 +257,9 @@ version-info {
bottom: 72dp;
display: flex;
flex-direction: column;
gap: 12dp;
gap: var(--space-md);
text-align: right;
font-size: 24dp;
font-size: var(--font-size-4xl);
font-effect: glow(0dp 4dp 0dp 4dp black);
text-align: right;
}
@@ -272,11 +273,11 @@ body.mirrored version-info {
#disc-status {
display: flex;
align-items: center;
gap: 8dp;
gap: var(--space-sm);
}
#disc-status[status=good] {
color: #D8F999;
color: var(--color-ready);
}
#disc-status[status=bad] {
@@ -284,7 +285,7 @@ body.mirrored version-info {
}
#disc-status[status=verifying] {
color: #FFFFFF;
color: var(--color-white);
}
#disc-status[status=mismatch] {
@@ -292,20 +293,20 @@ body.mirrored version-info {
}
#disc-status[status=unknown] {
color: rgba(224, 219, 200, 65%);
color: rgba(var(--color-text-rgb), 65%);
}
#disc-status[status=pending] {
color: #FEE685;
color: var(--color-prelaunch-accent);
}
#disc-status icon {
display: none;
width: 24dp;
height: 24dp;
font-family: "Material Symbols Rounded";
font-family: var(--font-family-icons);
font-weight: normal;
font-size: 24dp;
font-size: var(--font-size-4xl);
}
#disc-status[status] icon {
@@ -337,24 +338,24 @@ body.mirrored version-info {
}
#disc-version {
font-size: 20dp;
font-size: var(--font-size-2xl);
}
.update {
update-status {
display: none;
color: #A6A09B;
color: var(--color-prelaunch-muted);
align-items: center;
justify-content: flex-end;
gap: 8dp;
font-size: 20dp;
gap: var(--space-sm);
font-size: var(--font-size-2xl);
}
.update[state=checking],
.update[state=failed] {
update-status[state=checking],
update-status[state=failed] {
display: block;
}
.update[state=available] {
update-status[state=available] {
display: flex;
}
@@ -364,33 +365,33 @@ body.mirrored version-info {
padding: 0dp;
border-width: 0dp;
background-color: transparent;
color: #D8F999;
color: var(--color-ready);
cursor: pointer;
text-transform: uppercase;
font-weight: bold;
decorator: horizontal-gradient(#00000000 #00000000);
decorator: var(--menu-button-decorator);
}
.update[state=available] #update-download {
update-status[state=available] #update-download {
display: flex;
align-items: center;
gap: 2dp;
gap: var(--space-2xs);
}
#update-download icon {
display: block;
width: 18dp;
height: 18dp;
font-family: "Material Symbols Rounded";
font-family: var(--font-family-icons);
font-weight: normal;
decorator: text("" center center);
}
.detail {
color: #A6A09B;
disc-version {
color: var(--color-prelaunch-muted);
}
body.mirrored .update {
body.mirrored update-status {
justify-content: flex-start;
}
@@ -436,12 +437,20 @@ body.animate-in .intro-item {
/* Mobile layout */
@media (max-height: 640dp) {
.gradient {
decorator: horizontal-gradient(#00000000 #000000FF);
body {
--menu-button-decorator-hover: horizontal-gradient(#FEE68500 #FEE685FF);
}
body.mirrored .gradient {
decorator: horizontal-gradient(#000000FF #00000000);
body.mirrored {
--menu-button-decorator-hover: horizontal-gradient(#FEE685FF #FEE68500);
}
prelaunch-gradient {
decorator: horizontal-gradient(rgba(var(--color-black-rgb), 0%) rgba(var(--color-black-rgb), 100%));
}
body.mirrored prelaunch-gradient {
decorator: horizontal-gradient(rgba(var(--color-black-rgb), 100%) rgba(var(--color-black-rgb), 0%));
}
menu {
@@ -453,7 +462,7 @@ body.animate-in .intro-item {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 16dp;
gap: var(--space-lg);
}
body.mirrored menu {
@@ -466,7 +475,7 @@ body.animate-in .intro-item {
flex: 1 1 0;
min-width: 0;
max-width: 48%;
margin-left: 32dp;
margin-left: var(--space-2xl);
}
body.mirrored hero {
@@ -490,11 +499,6 @@ body.animate-in .intro-item {
text-align: right;
}
#menu-list button:hover,
#menu-list button:focus-visible {
decorator: horizontal-gradient(#FEE68500 #FEE685FF);
}
body.mirrored #menu-list {
align-items: flex-start;
}
@@ -503,11 +507,6 @@ body.animate-in .intro-item {
text-align: left;
}
body.mirrored #menu-list button:hover,
body.mirrored #menu-list button:focus-visible {
decorator: horizontal-gradient(#FEE685FF #FEE68500);
}
eyebrow {
display: none;
}
@@ -518,8 +517,8 @@ body.animate-in .intro-item {
bottom: 32dp;
top: auto;
text-align: right;
font-size: 16dp;
gap: 8dp;
font-size: var(--font-size-md);
gap: var(--space-sm);
}
#disc-status {
@@ -527,11 +526,11 @@ body.animate-in .intro-item {
}
#disc-status icon {
font-size: 20dp;
font-size: var(--font-size-2xl);
}
#disc-version {
font-size: 16dp;
font-size: var(--font-size-md);
}
version-info {
@@ -540,12 +539,12 @@ body.animate-in .intro-item {
bottom: auto;
top: 32dp;
text-align: right;
font-size: 16dp;
gap: 8dp;
font-size: var(--font-size-md);
gap: var(--space-sm);
}
.update {
font-size: 16dp;
update-status {
font-size: var(--font-size-md);
}
body.mirrored disc-info {
+16 -15
View File
@@ -18,17 +18,18 @@ tab-bar scrollbarhorizontal sliderbar {
tab-bar tab {
flex: 0 0 auto;
padding: 0 24dp;
line-height: 64dp;
padding: 0 var(--space-xl);
line-height: var(--toolbar-height);
white-space: nowrap;
decorator: vertical-gradient(#c2a42d00 #c2a42d00);
decorator: vertical-gradient(rgba(var(--color-accent-rgb), 0%) rgba(var(--color-accent-rgb), 0%));
transition: decorator 0.1s linear-in-out, opacity 0.1s linear-in-out;
cursor: pointer;
}
tab-bar tab:selected {
opacity: 1;
border-bottom: 4dp #C2A42D;
border-bottom-width: 4dp;
border-bottom-color: var(--color-accent);
font-effect: glow(0dp 4dp 0dp 4dp black);
}
@@ -36,17 +37,17 @@ tab-bar tab:focus-visible,
tab-bar tab:hover {
opacity: 1;
font-effect: glow(0dp 4dp 0dp 4dp black);
decorator: vertical-gradient(#c2a42d00 #c2a42d26);
decorator: vertical-gradient(rgba(var(--color-accent-rgb), 0) rgba(var(--color-accent-rgb), 38));
}
tab-bar tab:active {
decorator: vertical-gradient(#c2a42d10 #c2a42d40);
decorator: vertical-gradient(rgba(var(--color-accent-rgb), 16) rgba(var(--color-accent-rgb), 64));
}
tab-bar[closable] tab-end-spacer {
display: block;
flex: 0 0 64dp;
width: 64dp;
flex: 0 0 var(--toolbar-height);
width: var(--toolbar-height);
pointer-events: none;
}
@@ -59,10 +60,10 @@ window > close {
z-index: 1;
width: 48dp;
height: 48dp;
font-family: "Material Symbols Rounded";
font-family: var(--font-family-icons);
font-weight: normal;
font-size: 24dp;
color: rgba(224, 219, 200, 70%);
font-size: var(--font-size-4xl);
color: rgba(var(--color-text-rgb), 70%);
backdrop-filter: blur(2dp);
border-radius: 6dp;
decorator: text("" center center);
@@ -74,8 +75,8 @@ tab-bar[closable] close:hover,
tab-bar[closable] close:focus-visible,
window > close:hover,
window > close:focus-visible {
color: #fff;
background-color: rgba(194, 164, 45, 24%);
color: var(--color-white);
background-color: rgba(var(--color-accent-rgb), 24%);
}
window > close {
@@ -85,6 +86,6 @@ window > close {
tab-bar[closable] close:active,
window > close:active {
color: #fff;
background-color: rgba(194, 164, 45, 40%);
color: var(--color-white);
background-color: rgba(var(--color-accent-rgb), 40%);
}
+72
View File
@@ -0,0 +1,72 @@
*, *:before, *:after {
box-sizing: border-box;
}
body {
--font-family-body: Fira Sans;
--font-family-heading: Fira Sans Condensed;
--font-family-monospace: Noto Mono;
--font-family-icons: Material Symbols Rounded;
--font-family-display: Alegreya SC;
--font-size-3xs: 11dp;
--font-size-2xs: 12dp;
--font-size-xs: 13dp;
--font-size-sm: 14dp;
--font-size-base: 15dp;
--font-size-md: 16dp;
--font-size-lg: 17dp;
--font-size-xl: 18dp;
--font-size-2xl: 20dp;
--font-size-3xl: 22dp;
--font-size-4xl: 24dp;
--font-size-5xl: 28dp;
--font-size-6xl: 32dp;
--space-2xs: 2dp;
--space-xs: 4dp;
--space-sm: 8dp;
--space-md: 12dp;
--space-lg: 16dp;
--space-xl: 24dp;
--space-2xl: 32dp;
--radius-small: 4dp;
--radius-control: 7dp;
--radius-panel: 8dp;
--radius-window: 14dp;
--toolbar-height: 64dp;
--toolbar-hidden-offset: -64dp;
--color-text-rgb: 224, 219, 200;
--color-text: rgb(var(--color-text-rgb));
--color-accent-rgb: 194, 164, 45;
--color-accent: rgb(var(--color-accent-rgb));
--color-border-rgb: 146, 135, 91;
--color-border: rgb(var(--color-border-rgb));
--color-surface-rgb: 21, 22, 16;
--color-interactive-rgb: 204, 184, 119;
--color-control-rgb: 17, 16, 10;
--color-neutral-rgb: 217, 217, 217;
--color-neutral: rgb(var(--color-neutral-rgb));
--color-white-rgb: 255, 255, 255;
--color-white: rgb(var(--color-white-rgb));
--color-black-rgb: 0, 0, 0;
--color-black: rgb(var(--color-black-rgb));
--color-success: #44cc55;
--color-info-rgb: 111, 183, 239;
--color-info: rgb(var(--color-info-rgb));
--color-warning: #ffa826;
--color-error: #cc4444;
--button-color: var(--color-text);
--button-background: rgba(var(--color-control-rgb), 20%);
--button-background-hover: rgba(var(--color-interactive-rgb), 20%);
--button-background-selected: rgba(var(--color-interactive-rgb), 40%);
--button-background-active: rgba(var(--color-interactive-rgb), 40%);
--toast-border-color: var(--color-border);
--toast-heading-color: var(--color-border);
}
+45 -41
View File
@@ -1,16 +1,18 @@
*, *:before, *:after {
box-sizing: border-box;
}
body {
--button-color: rgba(248, 244, 232, 90%);
--button-background: rgba(22, 24, 28, 48%);
--button-background-active: rgba(63, 78, 90, 68%);
--button-border-color: rgba(var(--color-white-rgb), 22%);
--button-border-color-active: rgba(var(--color-white-rgb), 48%);
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
font-family: "Fira Sans Condensed";
font-family: var(--font-family-heading);
font-weight: bold;
color: rgba(248, 244, 232, 90%);
color: var(--button-color);
z-index: 1;
filter: opacity(0);
transition: filter 0.2s linear-in-out;
@@ -30,9 +32,10 @@ button {
justify-content: center;
decorator: none;
padding: 0;
border: 1dp rgba(255, 255, 255, 22%);
background-color: rgba(22, 24, 28, 48%);
color: rgba(248, 244, 232, 90%);
border-width: 1dp;
border-color: var(--button-border-color);
background-color: var(--button-background);
color: var(--button-color);
text-align: center;
/* backdrop-filter: blur(7dp); */
/* box-shadow: 0 6dp 18dp rgba(0, 0, 0, 28%); */
@@ -43,8 +46,8 @@ button {
button.pressed,
button.active {
background-color: rgba(63, 78, 90, 68%);
border-color: rgba(255, 255, 255, 48%);
background-color: var(--button-background-active);
border-color: var(--button-border-color-active);
filter: brightness(1.18);
}
@@ -68,9 +71,9 @@ button icon {
button icon glyph {
display: block;
font-family: "Material Symbols Rounded";
font-family: var(--font-family-icons);
font-weight: normal;
font-size: 24dp;
font-size: var(--font-size-4xl);
line-height: 1;
}
@@ -92,9 +95,9 @@ button icon glyph {
position: absolute;
}
.trigger-l.active {
background-color: rgba(57, 116, 133, 74%);
border-color: rgba(128, 222, 234, 72%);
.trigger-l {
--button-background-active: rgba(57, 116, 133, 74%);
--button-border-color-active: rgba(128, 222, 234, 72%);
}
.trigger,
@@ -103,12 +106,14 @@ button icon glyph {
}
.trigger {
font-size: 22dp;
font-size: var(--font-size-3xl);
}
.button-z {
background-color: rgba(118, 79, 158, 58%);
border-color: rgba(203, 170, 255, 36%);
--button-background: rgba(118, 79, 158, 58%);
--button-background-active: rgba(139, 91, 187, 82%);
--button-border-color: rgba(203, 170, 255, 36%);
--button-border-color-active: rgba(220, 194, 255, 70%);
}
.midna-icon {
@@ -121,7 +126,7 @@ button icon glyph {
.button-z.has-icon span,
.face.has-item span {
position: absolute;
font-size: 13dp;
font-size: var(--font-size-xs);
line-height: 1;
}
@@ -130,18 +135,14 @@ button icon glyph {
bottom: 7dp;
}
.button-z.pressed {
background-color: rgba(139, 91, 187, 82%);
border-color: rgba(220, 194, 255, 70%);
}
action-bar {
position: absolute;
display: flex;
align-items: center;
border: 1dp rgba(255, 255, 255, 22%);
border-width: 1dp;
border-color: var(--button-border-color);
border-radius: 23dp;
background-color: rgba(22, 24, 28, 48%);
background-color: var(--button-background);
/* backdrop-filter: blur(7dp); */
/* box-shadow: 0 -6dp 18dp rgba(0, 0, 0, 28%); */
overflow: hidden;
@@ -175,7 +176,7 @@ action-bar:hidden separator {
}
.utility.pressed {
background-color: rgba(63, 78, 90, 68%);
background-color: var(--button-background-active);
}
.utility.pressed,
@@ -185,7 +186,7 @@ action-bar:hidden separator {
.skip {
z-index: 1;
border-color: rgba(255, 255, 255, 36%);
border-color: rgba(var(--color-white-rgb), 36%);
}
separator {
@@ -193,7 +194,7 @@ separator {
flex: 0 0 1dp;
width: 1dp;
height: 24dp;
background-color: rgba(255, 255, 255, 18%);
background-color: rgba(var(--color-white-rgb), 18%);
opacity: 1;
transition: opacity 0.2s linear-in-out;
}
@@ -201,7 +202,7 @@ separator {
.face {
position: absolute;
border-radius: 29dp;
font-size: 24dp;
font-size: var(--font-size-4xl);
overflow: visible;
}
@@ -219,10 +220,10 @@ separator {
min-width: 17dp;
height: 15dp;
padding: 1dp 3dp;
border-radius: 7dp;
background-color: rgba(0, 0, 0, 52%);
color: rgba(255, 255, 255, 92%);
font-size: 12dp;
border-radius: var(--radius-control);
background-color: rgba(var(--color-black-rgb), 52%);
color: rgba(var(--color-white-rgb), 92%);
font-size: var(--font-size-2xs);
line-height: 13dp;
text-align: center;
}
@@ -233,9 +234,9 @@ separator {
bottom: -5dp;
width: 34dp;
height: 8dp;
padding: 2dp;
padding: var(--space-2xs);
border: 1dp rgba(42, 32, 18, 82%);
border-radius: 4dp;
border-radius: var(--radius-small);
background-color: rgba(18, 14, 10, 70%);
/* box-shadow: 0 2dp 6dp rgba(0, 0, 0, 35%); */
}
@@ -251,7 +252,7 @@ oil-fill {
.face.has-item span {
right: 6dp;
bottom: 6dp;
color: rgba(255, 255, 255, 88%);
color: rgba(var(--color-white-rgb), 88%);
}
.face.a {
@@ -307,7 +308,8 @@ touch-stick {
height: 124dp;
border-radius: 62dp;
background-color: rgba(18, 20, 24, 35%);
border: 1dp rgba(255, 255, 255, 20%);
border-width: 1dp;
border-color: rgba(var(--color-white-rgb), 20%);
/* backdrop-filter: blur(7dp); */
/* box-shadow: 0 8dp 24dp rgba(0, 0, 0, 24%); */
opacity: 0;
@@ -326,7 +328,8 @@ stick-ring {
width: 88dp;
height: 88dp;
border-radius: 44dp;
border: 1dp rgba(255, 255, 255, 18%);
border-width: 1dp;
border-color: rgba(var(--color-white-rgb), 18%);
}
stick-knob {
@@ -335,5 +338,6 @@ stick-knob {
height: 48dp;
border-radius: 24dp;
background-color: rgba(238, 236, 226, 55%);
border: 1dp rgba(255, 255, 255, 45%);
border-width: 1dp;
border-color: rgba(var(--color-white-rgb), 45%);
}
+27 -15
View File
@@ -1,4 +1,7 @@
body.touch-editor {
--color-editor-accent-rgb: 255, 232, 128;
--color-editor-highlight-rgb: 255, 244, 190;
background-color: rgba(4, 6, 8, 34%);
z-index: 8;
}
@@ -14,7 +17,7 @@ body.touch-editor .control:hover,
body.touch-editor action-bar:hover,
body.touch-editor .control.editor-selected,
body.touch-editor action-bar.editor-selected {
border-color: rgba(255, 232, 128, 80%);
border-color: rgba(var(--color-editor-accent-rgb), 80%);
filter: brightness(1.15);
}
@@ -27,8 +30,9 @@ selection-frame {
display: none;
position: absolute;
z-index: 20;
border: 2dp rgba(255, 232, 128, 88%);
background-color: rgba(255, 232, 128, 7%);
border-width: 2dp;
border-color: rgba(var(--color-editor-accent-rgb), 88%);
background-color: rgba(var(--color-editor-accent-rgb), 7%);
pointer-events: none;
}
@@ -41,7 +45,8 @@ resize-handle {
position: absolute;
width: 22dp;
height: 22dp;
border: 2dp rgba(255, 244, 190, 96%);
border-width: 2dp;
border-color: rgba(var(--color-editor-highlight-rgb), 96%);
border-radius: 11dp;
background-color: rgba(34, 37, 42, 86%);
pointer-events: auto;
@@ -90,6 +95,12 @@ resize-handle.corner.bottom {
}
editor-toolbar {
--button-background: rgba(17, 19, 24, 88%);
--button-background-hover: rgba(78, 85, 96, 92%);
--button-border-color: rgba(var(--color-white-rgb), 26%);
--button-border-color-hover: rgba(var(--color-editor-highlight-rgb), 92%);
--button-color: rgba(255, 250, 232, 94%);
display: flex;
position: absolute;
left: 24dp;
@@ -98,7 +109,7 @@ editor-toolbar {
z-index: 30;
height: 48dp;
margin-top: -24dp;
gap: 8dp;
gap: var(--space-sm);
justify-content: center;
pointer-events: auto;
}
@@ -108,12 +119,13 @@ editor-toolbar button.editor-command {
min-width: 96dp;
height: 48dp;
padding: 0 14dp;
border-radius: 8dp;
border: 1dp rgba(255, 255, 255, 26%);
background-color: rgba(17, 19, 24, 88%);
color: rgba(255, 250, 232, 94%);
font-family: "Fira Sans";
font-size: 18dp;
border-radius: var(--radius-panel);
border-width: 1dp;
border-color: var(--button-border-color);
background-color: var(--button-background);
color: var(--button-color);
font-family: var(--font-family-body);
font-size: var(--font-size-xl);
line-height: 48dp;
opacity: 1;
cursor: pointer;
@@ -127,12 +139,12 @@ editor-toolbar button.editor-command span {
}
editor-toolbar button.editor-command.primary {
border-color: rgba(255, 232, 128, 70%);
background-color: rgba(96, 82, 38, 90%);
--button-border-color: rgba(var(--color-editor-accent-rgb), 70%);
--button-background: rgba(96, 82, 38, 90%);
}
editor-toolbar button.editor-command:hover,
editor-toolbar button.editor-command:focus-visible {
border-color: rgba(255, 244, 190, 92%);
background-color: rgba(78, 85, 96, 92%);
border-color: var(--button-border-color-hover);
background-color: var(--button-background-hover);
}
+31 -34
View File
@@ -1,90 +1,87 @@
*, *:before, *:after {
box-sizing: border-box;
}
body {
overflow: visible;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
font-family: "Fira Sans Condensed";
font-size: 24dp;
color: #FFFFFF;
font-family: var(--font-family-heading);
font-size: var(--font-size-4xl);
color: var(--color-white);
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: stretch;
}
.tuner-root {
tuner-root {
width: 100%;
min-height: 45%;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: stretch;
decorator: vertical-gradient(#00000000 #151610F2);
decorator: vertical-gradient(rgba(var(--color-black-rgb), 0%) rgba(var(--color-surface-rgb), 242));
filter: opacity(0);
transition: filter 0.2s linear-in-out;
}
.tuner-root[open] {
tuner-root[open] {
filter: opacity(1);
}
.tuner {
graphics-tuner {
width: 100%;
max-width: 1216dp;
margin-left: auto;
margin-right: auto;
display: flex;
flex-direction: column;
gap: 24dp;
gap: var(--space-xl);
padding: 48dp 64dp;
}
@media (max-height: 800dp) {
.tuner-root {
tuner-root {
min-height: 38%;
}
.tuner {
gap: 16dp;
padding: 32dp 48dp;
graphics-tuner {
gap: var(--space-lg);
padding: var(--space-2xl) 48dp;
}
}
.header {
tuner-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 24dp;
gap: var(--space-xl);
}
.carousel-container {
carousel-container {
flex: 1 1 auto;
display: flex;
justify-content: flex-end;
min-width: 0;
}
.description {
font-size: 18dp;
tuner-description {
font-size: var(--font-size-xl);
line-height: 22dp;
color: rgba(255, 255, 255, 50%);
color: rgba(var(--color-white-rgb), 50%);
}
.divider {
tuner-divider {
margin: 1dp 0;
border-top: 1dp rgba(217, 217, 217, 50%);
border-top-width: 1dp;
border-top-color: rgba(var(--color-neutral-rgb), 50%);
}
.footer {
tuner-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 24dp;
gap: var(--space-xl);
}
footer-button {
@@ -94,12 +91,12 @@ footer-button {
border: 0;
padding: 0;
background-color: transparent;
font-family: "Fira Sans Condensed";
font-family: var(--font-family-heading);
font-weight: bold;
font-size: 20dp;
font-size: var(--font-size-2xl);
line-height: 24dp;
text-transform: uppercase;
color: #FFFFFF;
color: var(--color-white);
opacity: 1;
cursor: pointer;
}
@@ -112,20 +109,20 @@ footer-button.reset {
text-align: right;
}
.stepped-carousel {
stepped-carousel {
display: flex;
align-items: center;
justify-content: center;
gap: 16dp;
gap: var(--space-lg);
width: auto;
min-width: 246dp;
padding: 0;
background-color: transparent;
font-family: "Fira Sans Condensed";
font-family: var(--font-family-heading);
font-weight: bold;
}
.stepped-carousel-value {
stepped-carousel-value {
line-height: 29dp;
min-width: 166dp;
text-align: center;
@@ -142,6 +139,6 @@ footer-button.reset {
background-color: transparent;
opacity: 1;
cursor: pointer;
font-family: "Material Symbols Rounded";
font-family: var(--font-family-icons);
font-weight: normal;
}
+148 -146
View File
@@ -1,17 +1,13 @@
*, *:before, *:after {
box-sizing: border-box;
}
body {
display: flex;
width: 100%;
height: 100%;
padding: 64dp;
font-family: "Fira Sans";
font-family: var(--font-family-body);
font-weight: normal;
font-style: normal;
font-size: 15dp;
color: #E0DBC8;
font-size: var(--font-size-base);
color: var(--color-text);
}
b {
@@ -27,12 +23,13 @@ window {
max-width: 1088dp;
max-height: 768dp;
margin: auto;
border-radius: 14dp;
border-radius: var(--radius-window);
overflow: hidden;
border: 2dp #92875B;
border-width: 2dp;
border-color: var(--color-border);
backdrop-filter: blur(5dp);
box-shadow: 0 0 25dp 5dp;
background-color: rgba(21, 22, 16, 90%);
background-color: rgba(var(--color-surface-rgb), 90%);
filter: opacity(0);
transform: scale(0.9);
transform-origin: center;
@@ -61,7 +58,7 @@ window[open] {
@media (max-height: 640dp) {
body {
padding: 16dp;
padding: var(--space-lg);
}
window {
box-shadow: none;
@@ -70,7 +67,7 @@ window[open] {
@media (max-width: 768dp) {
body {
padding: 16dp;
padding: var(--space-lg);
}
window.modal {
width: 100%;
@@ -79,13 +76,14 @@ window[open] {
}
window tab-bar {
flex: 0 0 64dp;
height: 64dp;
background-color: rgba(217, 217, 217, 10%);
font-family: "Fira Sans Condensed";
flex: 0 0 var(--toolbar-height);
height: var(--toolbar-height);
background-color: rgba(var(--color-neutral-rgb), 10%);
font-family: var(--font-family-heading);
font-weight: bold;
font-size: 18dp;
border-bottom: 2dp #92875B;
font-size: var(--font-size-xl);
border-bottom-width: 2dp;
border-bottom-color: var(--color-border);
}
window tab-bar tab {
@@ -106,14 +104,15 @@ window content pane {
flex: 1 1 0;
min-width: 0;
min-height: 0;
padding: 24dp;
gap: 8dp;
padding: var(--space-xl);
gap: var(--space-sm);
overflow: hidden auto;
font-size: 20dp;
font-size: var(--font-size-2xl);
}
window content pane:not(:last-of-type) {
border-right: 1dp #92875B;
border-right-width: 1dp;
border-right-color: var(--color-border);
}
window content pane > * {
@@ -129,7 +128,7 @@ ui-list {
}
window content pane > ui-list,
.modal-content pane > ui-list {
modal-content pane > ui-list {
flex: 1 1 0;
min-width: 0;
min-height: 0;
@@ -146,7 +145,7 @@ ui-list-viewport {
ui-list-content {
display: flex;
flex-flow: column;
gap: 8dp;
gap: var(--space-sm);
min-width: 0;
}
@@ -156,7 +155,7 @@ ui-list-content > button.ui-list-row {
ui-list-empty {
display: block;
padding: 16dp;
padding: var(--space-lg);
text-align: center;
opacity: 0.45;
}
@@ -176,33 +175,33 @@ window content pane > ui-list:last-child {
window content pane > ui-list ui-list-content,
window content pane > ui-list ui-list-empty {
padding-left: 24dp;
padding-right: 24dp;
padding-left: var(--space-xl);
padding-right: var(--space-xl);
}
window content pane > ui-list:first-child ui-list-content,
window content pane > ui-list:first-child ui-list-empty {
padding-top: 24dp;
padding-top: var(--space-xl);
}
window content pane > ui-list:last-child ui-list-content,
window content pane > ui-list:last-child ui-list-empty {
padding-bottom: 24dp;
padding-bottom: var(--space-xl);
}
window content pane:last-of-type > div {
line-height: 1.625;
}
.data-folder-current {
data-folder-current {
display: block;
font-size: 16dp;
color: rgba(224, 219, 200, 65%);
font-size: var(--font-size-md);
color: rgba(var(--color-text-rgb), 65%);
}
scrollbarvertical {
width: 8dp;
margin: 4dp 4dp 4dp 0;
margin: var(--space-xs) var(--space-xs) var(--space-xs) 0;
}
scrollbarvertical sliderarrowdec,
@@ -218,14 +217,14 @@ scrollbarvertical slidertrack {
scrollbarvertical sliderbar {
width: 8dp;
min-height: 24dp;
background-color: rgba(224, 219, 200, 45%);
background-color: rgba(var(--color-text-rgb), 45%);
border-radius: 2dp;
transition: background-color 0.2s cubic-in-out;
}
scrollbarvertical sliderbar:hover,
scrollbarvertical sliderbar:active {
background-color: rgba(194, 164, 45, 80%);
background-color: rgba(var(--color-accent-rgb), 80%);
}
scrollbarhorizontal {
@@ -244,26 +243,27 @@ scrollbarhorizontal sliderbar {
height: 0;
}
.section-heading {
font-family: "Fira Sans Condensed";
section-heading {
font-family: var(--font-family-heading);
font-weight: bold;
text-transform: uppercase;
font-size: 22dp;
font-size: var(--font-size-3xl);
opacity: 0.25;
}
.section-heading:not(:first-of-type) {
padding-top: 12dp;
section-heading:not(:first-of-type) {
padding-top: var(--space-md);
}
button {
text-align: center;
background-color: rgba(17, 16, 10, 20%);
color: var(--button-color);
background-color: var(--button-background);
opacity: 0.9;
padding: 8dp 16dp;
border-radius: 14dp;
padding: var(--space-sm) var(--space-lg);
border-radius: var(--radius-window);
box-shadow: rgba(146, 135, 91, 25%) 0 0 0 1dp;
font-size: 20dp;
font-size: var(--font-size-2xl);
transition: background-color 0.1s linear-in-out, opacity 0.1s linear-in-out;
cursor: pointer;
focus: auto;
@@ -271,18 +271,18 @@ button {
button:not(:disabled):hover,
button:not(:disabled):focus-visible {
background-color: rgba(204, 184, 119, 20%);
background-color: var(--button-background-hover);
box-shadow: #C2A42D 0 0 0 2dp;
}
button:not(:disabled):selected {
opacity: 1;
background-color: rgba(204, 184, 119, 40%);
background-color: var(--button-background-selected);
}
button:not(:disabled):active {
opacity: 1;
background-color: rgba(204, 184, 119, 40%);
background-color: var(--button-background-active);
box-shadow: #C2A42D 0 0 0 2dp;
}
@@ -299,11 +299,12 @@ button.modal-btn {
select-button {
display: flex;
align-items: center;
gap: 8dp;
background-color: rgba(17, 16, 10, 20%);
gap: var(--space-sm);
color: var(--button-color);
background-color: var(--button-background);
opacity: 0.9;
padding: 8dp 16dp;
border-radius: 14dp;
padding: var(--space-sm) var(--space-lg);
border-radius: var(--radius-window);
box-shadow: rgba(146, 135, 91, 25%) 0 0 0 1dp;
transition: background-color 0.1s linear-in-out, opacity 0.1s linear-in-out;
cursor: pointer;
@@ -312,18 +313,18 @@ select-button {
select-button:not(:disabled):hover,
select-button:not(:disabled):focus-visible {
background-color: rgba(204, 184, 119, 20%);
background-color: var(--button-background-hover);
box-shadow: #C2A42D 0 0 0 2dp;
}
select-button:not(:disabled):selected {
opacity: 1;
background-color: rgba(204, 184, 119, 40%);
background-color: var(--button-background-selected);
}
select-button:not(:disabled):active {
opacity: 1;
background-color: rgba(204, 184, 119, 40%);
background-color: var(--button-background-active);
box-shadow: #C2A42D 0 0 0 2dp;
}
@@ -333,9 +334,9 @@ select-button:disabled {
}
select-button key {
font-family: "Fira Sans Condensed";
font-family: var(--font-family-heading);
font-weight: bold;
font-size: 18dp;
font-size: var(--font-size-xl);
text-transform: uppercase;
flex: 0 1 auto;
}
@@ -343,7 +344,7 @@ select-button key {
select-button value {
flex: 1 1 auto;
text-align: right;
font-size: 20dp;
font-size: var(--font-size-2xl);
}
select-button value.modified {
@@ -352,7 +353,7 @@ select-button value.modified {
select-button input {
text-align: right;
font-size: 20dp;
font-size: var(--font-size-2xl);
}
select-button.group-button icon {
@@ -360,7 +361,7 @@ select-button.group-button icon {
margin-left: auto;
width: 24dp;
height: 24dp;
font-size: 24dp;
font-size: var(--font-size-4xl);
color: inherit;
decorator: text("" center center);
}
@@ -371,8 +372,8 @@ select-button.group-button value {
select-button.color-input value {
min-width: 0;
font-family: "Noto Mono";
font-size: 15dp;
font-family: var(--font-family-monospace);
font-size: var(--font-size-base);
}
select-button.color-input color-swatch {
@@ -380,19 +381,19 @@ select-button.color-input color-swatch {
flex: 0 0 48dp;
width: 48dp;
height: 24dp;
border-radius: 7dp;
border-radius: var(--radius-control);
box-shadow: rgba(255, 255, 255, 45%) 0 0 0 1dp;
}
select-button.color-input color-swatch.empty {
background-color: rgba(224, 219, 200, 12%);
decorator: linear-gradient(135deg, rgba(224, 219, 200, 0) 45%, rgba(194, 164, 45, 70%) 48%, rgba(194, 164, 45, 70%) 52%, rgba(224, 219, 200, 0) 55%);
background-color: rgba(var(--color-text-rgb), 12%);
decorator: linear-gradient(135deg, rgba(var(--color-text-rgb), 0) 45%, rgba(var(--color-accent-rgb), 70%) 48%, rgba(var(--color-accent-rgb), 70%) 52%, rgba(var(--color-text-rgb), 0) 55%);
}
icon {
width: 1em;
height: 1em;
font-family: "Material Symbols Rounded";
font-family: var(--font-family-icons);
font-weight: normal;
display: inline-block;
vertical-align: middle;
@@ -418,73 +419,74 @@ icon.question-mark {
decorator: text("" center center);
}
.achievement-total {
achievement-total {
position: absolute;
top: 0;
right: 64dp;
height: 64dp;
line-height: 64dp;
font-family: "Fira Sans Condensed";
right: var(--toolbar-height);
height: var(--toolbar-height);
line-height: var(--toolbar-height);
font-family: var(--font-family-heading);
font-weight: bold;
font-size: 16dp;
color: rgba(224, 219, 200, 55%);
font-size: var(--font-size-md);
color: rgba(var(--color-text-rgb), 55%);
pointer-events: none;
}
.achievement-row {
achievement-row {
display: flex;
align-items: flex-start;
gap: 10dp;
padding: 12dp 0;
border-bottom: 1dp rgba(146, 135, 91, 30%);
padding: var(--space-md) 0;
border-bottom-width: 1dp;
border-bottom-color: rgba(var(--color-border-rgb), 30%);
}
.achievement-info {
achievement-info {
display: block;
flex: 1 1 0;
min-width: 0;
}
.achievement-header {
achievement-header {
display: flex;
align-items: center;
}
.achievement-name {
achievement-name {
flex: 1;
font-weight: bold;
}
.achievement-name.unlocked {
color: #ffa826;
achievement-name.unlocked {
color: var(--color-warning);
}
.achievement-badge {
font-size: 14dp;
achievement-badge {
font-size: var(--font-size-sm);
opacity: 0.7;
}
.achievement-badge.unlocked {
color: #44cc55;
achievement-badge.unlocked {
color: var(--color-success);
opacity: 1;
}
.achievement-badge.locked {
color: #cc4444;
achievement-badge.locked {
color: var(--color-error);
opacity: 1;
}
.achievement-desc {
display: block;
color: rgba(224, 219, 200, 55%);
font-size: 16dp;
margin: 4dp 0 0 0;
color: rgba(var(--color-text-rgb), 55%);
font-size: var(--font-size-md);
margin: var(--space-xs) 0 0 0;
}
.achievement-progress {
achievement-progress {
display: block;
font-size: 13dp;
color: rgba(224, 219, 200, 45%);
font-size: var(--font-size-xs);
color: rgba(var(--color-text-rgb), 45%);
}
progress {
@@ -492,12 +494,12 @@ progress {
width: 100%;
height: 6dp;
border-radius: 3dp;
background-color: rgba(255, 255, 255, 10%);
margin: 6dp 0 2dp 0;
background-color: rgba(var(--color-white-rgb), 10%);
margin: 6dp 0 var(--space-2xs) 0;
}
progress fill {
background-color: rgba(194, 164, 45, 80%);
background-color: rgba(var(--color-accent-rgb), 80%);
border-radius: 3dp;
}
@@ -512,12 +514,12 @@ progress.progress-ongoing fill {
button.achievement-clear {
flex: 0 0 auto;
align-self: center;
font-size: 14dp;
padding: 2dp 8dp;
font-size: var(--font-size-sm);
padding: var(--space-2xs) var(--space-sm);
opacity: 0.45;
}
.preset-grid {
preset-grid {
display: flex;
flex-direction: row;
gap: 20dp;
@@ -526,24 +528,24 @@ button.achievement-clear {
width: 100%;
}
.preset-col {
preset-option {
display: flex;
flex-flow: column;
gap: 12dp;
gap: var(--space-md);
flex: 1 1 0;
}
.preset-desc {
preset-description {
display: block;
font-size: 16dp;
font-size: var(--font-size-md);
text-align: center;
}
.modal-dialog {
modal-dialog {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 24dp;
padding: var(--space-xl);
gap: 20dp;
flex: 0 1 auto;
min-height: 0;
@@ -556,52 +558,52 @@ window.modal.danger {
border: 2dp #852221;
}
.modal-header {
modal-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
flex: 0 0 auto;
gap: 16dp;
gap: var(--space-lg);
}
.modal-header icon {
font-size: 24dp;
color: #92875B;
modal-header icon {
font-size: var(--font-size-4xl);
color: var(--color-border);
}
.modal-title {
modal-title {
display: block;
font-family: "Fira Sans Condensed";
font-family: var(--font-family-heading);
font-weight: bold;
text-transform: uppercase;
font-size: 18dp;
color: #92875B;
font-size: var(--font-size-xl);
color: var(--color-border);
flex: 1 1 auto;
}
window.modal.danger .modal-title,
window.modal.danger .modal-header icon {
window.modal.danger modal-title,
window.modal.danger modal-header icon {
color: #B3261E;
}
.modal-body {
modal-body {
display: block;
width: 100%;
flex: 0 0 auto;
min-width: 0;
font-size: 20dp;
color: #FFFFFF;
font-size: var(--font-size-2xl);
color: var(--color-white);
font-weight: normal;
}
.modal-body span.tip {
font-size: 14dp;
color: #92875B;
modal-body modal-tip {
font-size: var(--font-size-sm);
color: var(--color-border);
}
.modal-content {
modal-content {
display: none;
width: 100%;
flex: 1 1 auto;
@@ -609,87 +611,87 @@ window.modal.danger .modal-header icon {
overflow: hidden;
}
.modal-content.active {
modal-content.active {
display: flex;
flex-direction: column;
}
.modal-content pane {
modal-content pane {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
width: 100%;
gap: 8dp;
gap: var(--space-sm);
overflow: hidden auto;
}
.modal-content pane > * {
modal-content pane > * {
flex: 0 0 auto;
}
.verification-progress {
verification-progress {
display: flex;
flex-direction: column;
gap: 10dp;
width: 100%;
}
.verification-file {
verification-file {
display: block;
font-size: 17dp;
color: #FFFFFF;
font-size: var(--font-size-lg);
color: var(--color-white);
}
progress.verification-progress-bar {
height: 8dp;
margin: 2dp 0 0 0;
margin: var(--space-2xs) 0 0 0;
}
.verification-detail {
verification-detail {
display: block;
font-size: 14dp;
color: rgba(224, 219, 200, 65%);
font-size: var(--font-size-sm);
color: rgba(var(--color-text-rgb), 65%);
}
.modal-actions {
modal-actions {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: stretch;
gap: 12dp;
gap: var(--space-md);
width: 100%;
flex: 0 0 auto;
padding-top: 4dp;
padding-top: var(--space-xs);
}
.modal-actions-vertical {
modal-actions.vertical {
flex-direction: column;
align-items: stretch;
}
.modal-actions-vertical button.modal-btn {
modal-actions.vertical button.modal-btn {
flex: 0 0 auto;
width: 100%;
}
@media (max-height: 640dp) {
.modal-dialog {
padding: 16dp;
gap: 12dp;
modal-dialog {
padding: var(--space-lg);
gap: var(--space-md);
}
.modal-body {
font-size: 17dp;
modal-body {
font-size: var(--font-size-lg);
}
}
@media (max-width: 640dp) {
.modal-actions {
modal-actions {
flex-direction: column;
}
.modal-actions button.modal-btn {
modal-actions button.modal-btn {
flex: 0 0 auto;
width: 100%;
}
+456
View File
@@ -0,0 +1,456 @@
#include "catalog.hpp"
#include "dusk/app_info.hpp"
#include "fmt/format.h"
#include "nlohmann/json.hpp"
#include <algorithm>
#include <chrono>
#include <limits>
#include <stdexcept>
#include <string_view>
#if defined(__APPLE__)
#include <TargetConditionals.h>
#endif
namespace dusk::mods::catalog {
namespace {
using json = nlohmann::json;
using namespace std::chrono_literals;
constexpr std::string_view catalogUrl =
"https://staging.twilitrealm.workers.dev/api/v1/games/dusklight/mods";
std::string_view sort_value(Sort sort) noexcept {
switch (sort) {
case Sort::Endorsements:
return "endorsements";
case Sort::Updated:
return "updated";
case Sort::Newest:
return "newest";
case Sort::Name:
return "name";
case Sort::Downloads:
default:
return "downloads";
}
}
std::string_view catalog_platform() noexcept {
#if defined(_WIN32) && defined(_M_ARM64)
return "windows-arm64";
#elif defined(_WIN32) && defined(_M_X64)
return "windows-amd64";
#elif defined(__ANDROID__) && defined(__aarch64__)
return "android-aarch64";
#elif defined(__APPLE__) && TARGET_OS_IOS
return "ios-arm64";
#elif defined(__APPLE__) && !TARGET_OS_TV && defined(__aarch64__)
return "macos-arm64";
#elif defined(__APPLE__) && !TARGET_OS_TV && defined(__x86_64__)
return "macos-x86_64";
#elif defined(__linux__) && defined(__aarch64__)
return "linux-aarch64";
#elif defined(__linux__) && defined(__x86_64__)
return "linux-x86_64";
#else
// The catalog rejects platforms outside its published package matrix.
return {};
#endif
}
std::string url_encode(std::string_view value) {
constexpr char hex[] = "0123456789ABCDEF";
std::string encoded;
encoded.reserve(value.size());
for (const unsigned char c : value) {
const bool unreserved = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '-' || c == '.' || c == '_' ||
c == '~';
if (unreserved) {
encoded.push_back(static_cast<char>(c));
} else {
encoded.push_back('%');
encoded.push_back(hex[c >> 4]);
encoded.push_back(hex[c & 0x0f]);
}
}
return encoded;
}
void append_query(std::string& url, std::string_view name, std::string_view value) {
url.push_back(url.find('?') == std::string::npos ? '?' : '&');
url.append(name);
url.push_back('=');
url.append(url_encode(value));
}
std::string make_url(const Query& query) {
std::string url{catalogUrl};
if (!query.search.empty()) {
append_query(url, "q", query.search);
}
if (!query.category.empty()) {
append_query(url, "category", query.category);
}
append_query(url, "sort", sort_value(query.sort));
append_query(url, "page", fmt::format("{}", std::max(query.page, 1)));
if (query.thisDevice) {
const auto platform = catalog_platform();
if (!platform.empty()) {
append_query(url, "platform", platform);
}
}
return url;
}
std::string make_detail_url(std::string_view id) {
return fmt::format("{}/{}", catalogUrl, url_encode(id));
}
const json& required_field(const json& object, const char* name) {
if (!object.is_object()) {
throw std::runtime_error{"expected an object"};
}
const auto iter = object.find(name);
if (iter == object.end()) {
throw std::runtime_error{fmt::format("missing field '{}'", name)};
}
return *iter;
}
std::string required_string(const json& object, const char* name) {
const auto& value = required_field(object, name);
if (!value.is_string()) {
throw std::runtime_error{fmt::format("field '{}' is not a string", name)};
}
return value.get<std::string>();
}
bool required_bool(const json& object, const char* name) {
const auto& value = required_field(object, name);
if (!value.is_boolean()) {
throw std::runtime_error{fmt::format("field '{}' is not a boolean", name)};
}
return value.get<bool>();
}
std::uint64_t required_count(const json& object, const char* name) {
const auto& value = required_field(object, name);
if (value.is_number_unsigned()) {
return value.get<std::uint64_t>();
}
if (value.is_number_integer()) {
const auto count = value.get<std::int64_t>();
if (count >= 0) {
return static_cast<std::uint64_t>(count);
}
}
throw std::runtime_error{fmt::format("field '{}' is not a non-negative integer", name)};
}
int required_int(const json& object, const char* name) {
const std::uint64_t value = required_count(object, name);
if (value > static_cast<std::uint64_t>(std::numeric_limits<int>::max())) {
throw std::runtime_error{fmt::format("field '{}' is too large", name)};
}
return static_cast<int>(value);
}
std::optional<std::string> optional_string(const json& object, const char* name) {
const auto& value = required_field(object, name);
if (value.is_null()) {
return std::nullopt;
}
if (!value.is_string()) {
throw std::runtime_error{fmt::format("field '{}' is not a string or null", name)};
}
return value.get<std::string>();
}
std::uint16_t required_u16(const json& object, const char* name) {
const auto value = required_count(object, name);
if (value > std::numeric_limits<std::uint16_t>::max()) {
throw std::runtime_error{fmt::format("field '{}' is too large", name)};
}
return static_cast<std::uint16_t>(value);
}
Image parse_image(const json& value) {
const auto width = required_count(value, "width");
const auto height = required_count(value, "height");
if (width > std::numeric_limits<std::uint32_t>::max() ||
height > std::numeric_limits<std::uint32_t>::max())
{
throw std::runtime_error{"image dimensions are too large"};
}
Image image{
.width = static_cast<std::uint32_t>(width),
.height = static_cast<std::uint32_t>(height),
};
const auto& sources = required_field(value, "sources");
if (!sources.is_array()) {
throw std::runtime_error{"field 'sources' is not an array"};
}
image.sources.reserve(sources.size());
for (const auto& source : sources) {
const auto sourceWidth = required_count(source, "width");
if (sourceWidth > std::numeric_limits<std::uint32_t>::max()) {
throw std::runtime_error{"image source width is too large"};
}
image.sources.push_back({
.width = static_cast<std::uint32_t>(sourceWidth),
.pngUrl = required_string(source, "png_url"),
});
}
if (image.sources.empty()) {
throw std::runtime_error{"image has no sources"};
}
return image;
}
Category parse_category(const json& value) {
return {
.slug = required_string(value, "slug"),
.name = required_string(value, "name"),
.modCount = required_count(value, "mod_count"),
};
}
Category parse_mod_category(const json& value) {
return {
.slug = required_string(value, "slug"),
.name = required_string(value, "name"),
};
}
Tag parse_tag(const json& value) {
return {
.slug = required_string(value, "slug"),
.name = required_string(value, "name"),
};
}
Author parse_author(const json& value) {
return {
.name = required_string(value, "name"),
.handle = required_string(value, "handle"),
.official = required_bool(value, "official"),
};
}
Mod parse_mod(const json& value) {
Mod mod{
.id = required_string(value, "id"),
.name = required_string(value, "name"),
.version = required_string(value, "version"),
.author = parse_author(required_field(value, "author")),
.summary = required_string(value, "summary"),
.downloads = required_count(value, "downloads"),
.endorsements = required_count(value, "endorsements"),
.publishedAt = required_string(value, "published_at"),
.updatedAt = required_string(value, "updated_at"),
.packageSize = required_count(value, "package_size"),
.containsNativeCode = required_bool(value, "contains_native_code"),
};
const auto& category = required_field(value, "category");
if (!category.is_null()) {
mod.category = parse_mod_category(category);
}
const auto& tags = required_field(value, "tags");
if (!tags.is_array()) {
throw std::runtime_error{"field 'tags' is not an array"};
}
mod.tags.reserve(tags.size());
for (const auto& tag : tags) {
mod.tags.push_back(parse_tag(tag));
}
const auto& platforms = required_field(value, "supported_platforms");
if (!platforms.is_array()) {
throw std::runtime_error{"field 'supported_platforms' is not an array"};
}
mod.supportedPlatforms.reserve(platforms.size());
for (const auto& platform : platforms) {
if (!platform.is_string()) {
throw std::runtime_error{"supported platform is not a string"};
}
mod.supportedPlatforms.push_back(platform.get<std::string>());
}
const auto& icon = required_field(value, "icon");
if (!icon.is_null()) {
mod.icon = parse_image(icon);
}
const auto& banner = required_field(value, "banner");
if (!banner.is_null()) {
mod.banner = parse_image(banner);
}
return mod;
}
Detail parse_detail(std::string_view body) {
const json root = json::parse(body);
Detail detail{
.mod = parse_mod(root),
.slug = required_string(root, "slug"),
.siteUrl = required_string(root, "site_url"),
.sourceUrl = optional_string(root, "source_url"),
.license = optional_string(root, "license"),
.descriptionHtml = required_string(root, "description_html"),
.changelogHtml = required_string(root, "changelog_html"),
.packageSha256 = required_string(root, "package_sha256"),
};
const auto& modAbi = required_field(root, "mod_abi");
if (!modAbi.is_null()) {
const auto value = required_count(root, "mod_abi");
if (value > std::numeric_limits<std::uint32_t>::max()) {
throw std::runtime_error{"field 'mod_abi' is too large"};
}
detail.modAbi = static_cast<std::uint32_t>(value);
}
const auto& banner = required_field(root, "banner");
if (!banner.is_null()) {
detail.banner = parse_image(banner);
}
const auto& screenshots = required_field(root, "screenshots");
if (!screenshots.is_array()) {
throw std::runtime_error{"field 'screenshots' is not an array"};
}
detail.screenshots.reserve(screenshots.size());
for (const auto& screenshot : screenshots) {
detail.screenshots.push_back({
.altText = required_string(screenshot, "alt_text"),
.image = parse_image(required_field(screenshot, "image")),
});
}
const auto& imports = required_field(root, "service_imports");
if (!imports.is_array()) {
throw std::runtime_error{"field 'service_imports' is not an array"};
}
detail.serviceImports.reserve(imports.size());
for (const auto& import : imports) {
detail.serviceImports.push_back({
.id = required_string(import, "id"),
.major = required_u16(import, "major"),
.minMinor = required_u16(import, "min_minor"),
.optional = required_bool(import, "optional"),
});
}
return detail;
}
Page parse_page(std::string_view body) {
const json root = json::parse(body);
const auto& game = required_field(root, "game");
if (required_string(game, "id") != "dusklight") {
throw std::runtime_error{"catalog response is for a different game"};
}
Page page;
const auto& categories = required_field(root, "categories");
if (!categories.is_array()) {
throw std::runtime_error{"field 'categories' is not an array"};
}
page.categories.reserve(categories.size());
for (const auto& category : categories) {
page.categories.push_back(parse_category(category));
}
const auto& mods = required_field(root, "mods");
if (!mods.is_array()) {
throw std::runtime_error{"field 'mods' is not an array"};
}
page.mods.reserve(mods.size());
for (const auto& mod : mods) {
page.mods.push_back(parse_mod(mod));
}
const auto& pagination = required_field(root, "pagination");
page.pagination = {
.page = required_int(pagination, "page"),
.pageSize = required_int(pagination, "page_size"),
.pageCount = required_int(pagination, "page_count"),
.total = required_count(pagination, "total"),
};
return page;
}
std::string api_error(const borealis::http::Response& response) {
try {
const auto body = json::parse(response.body);
const auto& error = required_field(body, "error");
return required_string(error, "message");
} catch (...) {
return fmt::format("The catalog returned HTTP {}.", response.statusCode);
}
}
FetchResult finish_request(borealis::http::Result result) {
if (result.error != borealis::http::Error::None) {
return {.error = result.message.empty() ? "The catalog request failed." :
std::move(result.message)};
}
if (result.response.statusCode != 200) {
return {.error = api_error(result.response)};
}
try {
return {.page = parse_page(result.response.body)};
} catch (const std::exception& exception) {
return {.error = fmt::format("The catalog response was invalid: {}", exception.what())};
} catch (...) {
return {.error = "The catalog response was invalid."};
}
}
DetailFetchResult finish_detail_request(borealis::http::Result result) {
if (result.error != borealis::http::Error::None) {
return {.error =
result.message.empty() ? "The mod request failed." : std::move(result.message)};
}
if (result.response.statusCode != 200) {
return {.error = api_error(result.response)};
}
try {
return {.detail = parse_detail(result.response.body)};
} catch (const std::exception& exception) {
return {.error = fmt::format("The mod response was invalid: {}", exception.what())};
} catch (...) {
return {.error = "The mod response was invalid."};
}
}
borealis::http::Request make_request(std::string url) {
return {
.url = std::move(url),
.headers =
{
{.name = "User-Agent", .value = borealis::user_agent(dusk::AppInfo)},
{.name = "Accept", .value = "application/json"},
},
.connectTimeout = 10s,
.idleTimeout = 10s,
.totalTimeout = 20s,
};
}
} // namespace
borealis::Task<FetchResult> fetch_page(Query query) {
return borealis::http::start(make_request(make_url(query))).map(finish_request);
}
borealis::Task<DetailFetchResult> fetch_detail(std::string id) {
return borealis::http::start(make_request(make_detail_url(id))).map(finish_detail_request);
}
} // namespace dusk::mods::catalog
+131
View File
@@ -0,0 +1,131 @@
#pragma once
#include <borealis/http.hpp>
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
namespace dusk::mods::catalog {
enum class Sort {
Downloads,
Endorsements,
Updated,
Newest,
Name,
};
struct Query {
std::string search;
std::string category;
Sort sort = Sort::Downloads;
int page = 1;
bool thisDevice = true;
};
struct Category {
std::string slug;
std::string name;
std::uint64_t modCount = 0;
};
struct Tag {
std::string slug;
std::string name;
};
struct Author {
std::string name;
std::string handle;
bool official = false;
};
struct ImageSource {
std::uint32_t width = 0;
std::string pngUrl;
};
struct Image {
std::uint32_t width = 0;
std::uint32_t height = 0;
std::vector<ImageSource> sources;
};
struct Mod {
std::string id;
std::string name;
std::string version;
Author author;
std::string summary;
std::optional<Category> category;
std::vector<Tag> tags;
std::uint64_t downloads = 0;
std::uint64_t endorsements = 0;
std::string publishedAt;
std::string updatedAt;
std::uint64_t packageSize = 0;
bool containsNativeCode = false;
std::vector<std::string> supportedPlatforms;
std::optional<Image> icon;
std::optional<Image> banner;
};
struct Screenshot {
std::string altText;
Image image;
};
struct ServiceImport {
std::string id;
std::uint16_t major = 0;
std::uint16_t minMinor = 0;
bool optional = false;
};
struct Detail {
Mod mod;
std::string slug;
std::string siteUrl;
std::optional<std::string> sourceUrl;
std::optional<std::string> license;
std::string descriptionHtml;
std::string changelogHtml;
std::string packageSha256;
std::optional<std::uint32_t> modAbi;
std::optional<Image> banner;
std::vector<Screenshot> screenshots;
std::vector<ServiceImport> serviceImports;
};
struct Pagination {
int page = 1;
int pageSize = 0;
int pageCount = 0;
std::uint64_t total = 0;
};
struct Page {
std::vector<Category> categories;
std::vector<Mod> mods;
Pagination pagination;
};
struct FetchResult {
std::optional<Page> page;
std::string error;
};
struct DetailFetchResult {
std::optional<Detail> detail;
std::string error;
};
/** Fetches one filtered page from the configured Dusklight catalog. */
borealis::Task<FetchResult> fetch_page(Query query);
/** Fetches the full catalog record for one mod. */
borealis::Task<DetailFetchResult> fetch_detail(std::string id);
} // namespace dusk::mods::catalog
+13 -10
View File
@@ -114,9 +114,10 @@ struct UiSlot {
std::string styleId;
// Cached rendered values for element setters. These make the natural "set every update"
// style cheap when the displayed value has not changed.
std::string elementRml;
std::string elementValue;
float elementFloat = 0.0f;
bool hasElementValue = false;
bool elementValueIsRml = false;
};
SlotMap<UiSlot> s_slots;
@@ -550,7 +551,7 @@ ModResult ui_pane_add_text(LoadedMod& mod, uint64_t pane, const char* text, uint
auto* elem = slot->pane->add_text(text);
if (outElem != nullptr) {
auto& elemSlot = alloc_slot(mod, UiSlotKind::Text, *outElem);
elemSlot.elementRml = ui::escape(text);
elemSlot.elementValue = text;
elemSlot.hasElementValue = true;
track_element(*outElem, elemSlot, *elem);
}
@@ -565,8 +566,9 @@ ModResult ui_pane_add_rml(LoadedMod& mod, uint64_t pane, const char* rml, uint64
auto* elem = slot->pane->add_rml(rml);
if (outElem != nullptr) {
auto& elemSlot = alloc_slot(mod, UiSlotKind::Text, *outElem);
elemSlot.elementRml = rml;
elemSlot.elementValue = rml;
elemSlot.hasElementValue = true;
elemSlot.elementValueIsRml = true;
track_element(*outElem, elemSlot, *elem);
}
return MOD_OK;
@@ -797,13 +799,13 @@ ModResult ui_elem_set_text(LoadedMod& mod, uint64_t elem, const char* text) {
if (slot == nullptr) {
return MOD_INVALID_ARGUMENT;
}
const std::string rml = ui::escape(text);
if (slot->hasElementValue && slot->elementRml == rml) {
if (slot->hasElementValue && !slot->elementValueIsRml && slot->elementValue == text) {
return MOD_OK;
}
slot->elementRml = rml;
slot->elementValue = text;
slot->hasElementValue = true;
slot->element->SetInnerRML(slot->elementRml);
slot->elementValueIsRml = false;
ui::set_text_content(slot->element, slot->elementValue);
return MOD_OK;
}
@@ -812,11 +814,12 @@ ModResult ui_elem_set_rml(LoadedMod& mod, uint64_t elem, const char* rml) {
if (slot == nullptr) {
return MOD_INVALID_ARGUMENT;
}
if (slot->hasElementValue && slot->elementRml == rml) {
if (slot->hasElementValue && slot->elementValueIsRml && slot->elementValue == rml) {
return MOD_OK;
}
slot->elementRml = rml;
slot->elementValue = rml;
slot->hasElementValue = true;
slot->elementValueIsRml = true;
slot->element->SetInnerRML(rml);
return MOD_OK;
}
@@ -940,7 +943,7 @@ ModResult ui_dialog_push(LoadedMod& mod, const UiDialogDesc& desc, uint64_t& out
default:
break;
}
props.title = ui::escape(desc.title);
props.title = desc.title;
props.bodyRml = desc.body_rml;
props.icon = desc.icon != nullptr ? desc.icon : defaultIcon;
props.onDismiss = [modPtr = &mod, handle, fn = desc.on_dismiss, userData = desc.user_data](
+31 -42
View File
@@ -16,47 +16,39 @@ struct CategoryInfo {
};
constexpr CategoryInfo kCategories[] = {
{AchievementCategory::Challenge, "Challenge"},
{AchievementCategory::Challenge, "Challenge"},
{AchievementCategory::Collection, "Collection"},
{AchievementCategory::Minigame, "Minigame"},
{AchievementCategory::Misc, "Misc"},
{AchievementCategory::Glitched, "Glitched"},
{AchievementCategory::Minigame, "Minigame"},
{AchievementCategory::Misc, "Misc"},
{AchievementCategory::Glitched, "Glitched"},
};
Rml::String build_achievement_info_rml(const Achievement& a) {
Rml::String s = fmt::format(
R"(<div class="achievement-header">)"
R"(<span class="achievement-name{}">{}</span>)"
R"(<span class="achievement-badge{}">{}</span>)"
R"(</div>)"
R"(<p class="achievement-desc">{}</p>)",
a.unlocked ? " unlocked" : "",
a.name,
a.unlocked ? " unlocked" : " locked",
a.unlocked ? "Unlocked" : "Locked",
a.description
);
void append_achievement_info(Rml::Element* parent, const Achievement& a) {
auto* header = append(parent, "achievement-header");
auto* name = append(header, "achievement-name");
name->SetClass("unlocked", a.unlocked);
append_text(name, a.name);
auto* badge = append(header, "achievement-badge");
badge->SetClass(a.unlocked ? "unlocked" : "locked", true);
append_text(badge, a.unlocked ? "Unlocked" : "Locked");
auto* description = append(parent, "p");
description->SetClass("achievement-desc", true);
append_text(description, a.description);
if (a.isCounter) {
float fraction = a.goal > 0 ? float(a.progress) / float(a.goal) : 1.0f;
s += fmt::format(
R"(<progress value="{:.3f}" class="{}"/>)"
R"(<span class="achievement-progress">{} / {}</span>)",
fraction,
a.unlocked ? "progress-done" : "progress-ongoing",
a.progress,
a.goal
);
const float fraction = a.goal > 0 ? float(a.progress) / float(a.goal) : 1.0f;
auto* progress = append(parent, "progress");
progress->SetAttribute("value", fraction);
progress->SetClass(a.unlocked ? "progress-done" : "progress-ongoing", true);
append_text(
append(parent, "achievement-progress"), fmt::format("{} / {}", a.progress, a.goal));
}
return s;
}
class AchievementRow : public FluentComponent<AchievementRow> {
public:
AchievementRow(Rml::Element* parent, const Achievement& a)
: FluentComponent(createRowRoot(parent))
{
: FluentComponent(createRowRoot(parent)) {
auto& btn = add_child<Button>(Button::Props{"×"});
mClearButton = &btn;
btn.root()->SetClass("achievement-clear", true);
@@ -80,13 +72,10 @@ public:
return false;
});
Component::listen(btn.root(), Rml::EventId::Blur, [this](Rml::Event&) {
resetConfirm();
});
Component::listen(btn.root(), Rml::EventId::Blur, [this](Rml::Event&) { resetConfirm(); });
auto* infoDiv = append(mRoot, "div");
infoDiv->SetClass("achievement-info", true);
infoDiv->SetInnerRML(build_achievement_info_rml(a));
auto* infoDiv = append(mRoot, "achievement-info");
append_achievement_info(infoDiv, a);
}
bool focus() override { return mClearButton->focus(); }
@@ -94,8 +83,7 @@ public:
private:
static Rml::Element* createRowRoot(Rml::Element* parent) {
auto* doc = parent->GetOwnerDocument();
auto elem = doc->CreateElement("div");
elem->SetClass("achievement-row", true);
auto elem = doc->CreateElement("achievement-row");
return parent->AppendChild(std::move(elem));
}
@@ -114,8 +102,7 @@ AchievementsWindow::AchievementsWindow() {
const auto all = AchievementSystem::get().getAchievements();
{
auto elem = mDocument->CreateElement("div");
elem->SetClass("achievement-total", true);
auto elem = mDocument->CreateElement("achievement-total");
mTotalEl = mRoot->AppendChild(std::move(elem));
updateTotal();
}
@@ -215,7 +202,9 @@ void AchievementsWindow::updateTotal() {
return;
}
const auto all = AchievementSystem::get().getAchievements();
const int total = std::count_if(all.begin(), all.end(), [](const Achievement& achievement){ return achievement.category != AchievementCategory::Glitched;});
const int total = std::count_if(all.begin(), all.end(), [](const Achievement& achievement) {
return achievement.category != AchievementCategory::Glitched;
});
int unlocked = 0;
for (const auto& a : all) {
if (a.unlocked) {
@@ -223,7 +212,7 @@ void AchievementsWindow::updateTotal() {
}
}
const int pct = total > 0 ? (unlocked * 100 / total) : 0;
mTotalEl->SetInnerRML(fmt::format("{}%", pct));
set_text_content(mTotalEl, fmt::format("{}%", pct));
}
} // namespace dusk::ui
+1 -1
View File
@@ -25,7 +25,7 @@ Button::Button(Rml::Element* parent, Props props, const Rml::String& tagName)
void Button::set_text(const Rml::String& text) {
if (mProps.text != text) {
mRoot->SetInnerRML(escape(text));
set_text_content(mRoot, text);
mProps.text = text;
}
}
+2 -2
View File
@@ -546,7 +546,7 @@ void ColorInput::add_history(NavGroup& navigation) {
void ColorInput::add_swatch_button(NavGroup& navigation, const Rml::String& value) {
auto& button = navigation.add_item<Button>("");
button.root()->SetClass("color-swatch-button", true);
button.root()->SetInnerRML("");
dusk::ui::clear_children(button.root());
auto* chip = append(button.root(), "color-chip");
apply_swatch(chip, value, mProps.alpha);
Rml::String title = value == "rainbow" ? "Rainbow" : value;
@@ -734,7 +734,7 @@ void ColorInput::refresh_picker() {
opaque.green, opaque.blue, css_color(opaque)));
place(mAlphaCursor, mAlpha * kSvWidthDp, kBarHeightDp / 2.0f);
}
mPickerValue->SetInnerRML(format_color(color, mHexFormat, mProps.alpha));
set_text_content(mPickerValue, format_color(color, mHexFormat, mProps.alpha));
}
Rml::Colourb ColorInput::current_color() const {
+21 -22
View File
@@ -20,20 +20,21 @@ namespace {
const Rml::String kDocumentSource = R"RML(
<rml>
<head>
<link type="text/rcss" href="res/rml/theme.rcss" />
<link type="text/rcss" href="res/rml/tuner.rcss" />
</head>
<body>
<div id="root" class="tuner-root">
<div class="tuner">
<div class="header">
<div id="title"></div>
<div id="carousel-container" class="carousel-container"></div>
</div>
<div id="description" class="description"></div>
<div class="divider"></div>
<div id="footer" class="footer"></div>
</div>
</div>
<tuner-root id="root">
<graphics-tuner>
<tuner-header>
<tuner-title id="title" />
<carousel-container id="carousel-container" />
</tuner-header>
<tuner-description id="description" />
<tuner-divider />
<tuner-footer id="footer" />
</graphics-tuner>
</tuner-root>
</body>
</rml>
)RML";
@@ -118,8 +119,7 @@ const GraphicsSetting& bind(Min min, Max max, Def def, int step, Rml::String (*l
Rml::Element* create_stepped_carousel_root(Rml::Element* parent) {
auto* doc = parent->GetOwnerDocument();
auto root = doc->CreateElement("div");
root->SetClass("stepped-carousel", true);
auto root = doc->CreateElement("stepped-carousel");
root->SetAttribute("tabindex", "0");
return parent->AppendChild(std::move(root));
}
@@ -130,7 +130,7 @@ Rml::Element* create_stepped_carousel_arrow(
auto button = doc->CreateElement("button");
button->SetClass("stepped-carousel-arrow", true);
button->SetClass(className, true);
button->SetInnerRML(label);
append_text(button.get(), label);
return parent->AppendChild(std::move(button));
}
@@ -172,10 +172,9 @@ const GraphicsSetting& GraphicsSetting::of(GraphicsOption option) {
SteppedCarousel::SteppedCarousel(Rml::Element* parent, Props props)
: Component(create_stepped_carousel_root(parent)), mProps(std::move(props)) {
mPrevElem = create_stepped_carousel_arrow(mRoot, "prev", "&#xe5cb;");
mValueElem = append(mRoot, "div");
mValueElem->SetClass("stepped-carousel-value", true);
mNextElem = create_stepped_carousel_arrow(mRoot, "next", "&#xe5cc;");
mPrevElem = create_stepped_carousel_arrow(mRoot, "prev", "\uE5CB");
mValueElem = append(mRoot, "stepped-carousel-value");
mNextElem = create_stepped_carousel_arrow(mRoot, "next", "\uE5CC");
listen(mPrevElem, Rml::EventId::Click,
[this](Rml::Event&) { handle_nav_command(NavCommand::Left); });
@@ -201,9 +200,9 @@ void SteppedCarousel::refresh() {
}
const int value = std::clamp(mProps.getValue ? mProps.getValue() : 0, mProps.min, mProps.max);
if (mProps.formatValue) {
mValueElem->SetInnerRML(mProps.formatValue(value));
set_text_content(mValueElem, mProps.formatValue(value));
} else {
mValueElem->SetInnerRML(std::to_string(value));
set_text_content(mValueElem, std::to_string(value));
}
update_carousel_arrow_color(mPrevElem, value == mProps.min);
@@ -245,10 +244,10 @@ GraphicsTuner::GraphicsTuner(GraphicsTunerProps props)
}
if (auto* title = mDocument->GetElementById("title")) {
title->SetInnerRML(escape(props.title));
append_text(title, props.title);
}
if (auto* description = mDocument->GetElementById("description")) {
description->SetInnerRML(escape(props.helpText));
append_text(description, props.helpText);
}
if (auto* carouselParent = mDocument->GetElementById("carousel-container")) {
mCarousel = &add_component<SteppedCarousel>(carouselParent,
+16 -22
View File
@@ -72,11 +72,10 @@ std::string format_time(int64_t timeMs) {
return fmt::format("{}.{:03}", buffer.data(), timeMs % 1000);
}
Rml::Element* append_span(Rml::Element* parent, const char* className, const Rml::String& text) {
auto* span = append(parent, "span");
span->SetClass(className, true);
append_text(span, text);
return span;
Rml::Element* append_log_field(Rml::Element* parent, const char* tagName, const Rml::String& text) {
auto* field = append(parent, tagName);
append_text(field, text);
return field;
}
} // namespace
@@ -89,18 +88,15 @@ LogsWindow::LogsWindow(std::string modFilter)
}
void LogsWindow::build_content(Rml::Element* content) {
auto* toolbar = append(content, "div");
toolbar->SetClass("log-toolbar", true);
auto* toolbar = append(content, "log-toolbar");
auto* title = append(toolbar, "div");
title->SetClass("log-title", true);
title->SetInnerRML("Logs");
auto* title = append(toolbar, "log-title");
append_text(title, "Logs");
auto* modLabel = append(toolbar, "div");
modLabel->SetClass("log-title-mod", true);
modLabel->SetInnerRML(mModFilter.empty() ? "All mods" : fmt::format("{}", escape(mModFilter)));
auto* modLabel = append(toolbar, "log-title-mod");
append_text(modLabel, mModFilter.empty() ? "All mods" : mModFilter);
append(toolbar, "div")->SetClass("log-toolbar-spacer", true);
append(toolbar, "log-toolbar-spacer");
for (const LogLevel level :
{LOG_LEVEL_TRACE, LOG_LEVEL_DEBUG, LOG_LEVEL_INFO, LOG_LEVEL_WARN, LOG_LEVEL_ERROR})
@@ -116,7 +112,7 @@ void LogsWindow::build_content(Rml::Element* content) {
});
}
append(toolbar, "div")->SetClass("log-toolbar-spacer", true);
append(toolbar, "log-toolbar-spacer");
add_child<Button>(toolbar, "Copy").on_pressed([this] { copy_to_clipboard(); });
add_child<Button>(toolbar, "Clear").on_pressed([this] {
@@ -127,8 +123,7 @@ void LogsWindow::build_content(Rml::Element* content) {
auto& pane = add_child<Pane>(content, Pane::Type::Uncontrolled);
pane.root()->SetClass("log-view", true);
mScrollElem = pane.root();
mLinesElem = append(pane.root(), "div");
mLinesElem->SetClass("log-lines", true);
mLinesElem = append(pane.root(), "log-lines");
listen(mScrollElem, Rml::EventId::Scroll, [this](Rml::Event&) {
const float bottom = mScrollElem->GetScrollHeight() - mScrollElem->GetClientHeight();
@@ -259,16 +254,15 @@ Rml::Element* LogsWindow::append_log_line(const mods::log::Line& line) {
modId = "?";
}
auto* elem = append(mLinesElem, "div");
elem->SetClass("log-line", true);
auto* elem = append(mLinesElem, "log-line");
elem->SetClass(level_class(line.level), true);
constexpr const char* kNbsp = "\xc2\xa0";
append_span(elem, "log-time", format_time(line.timeMs));
append_log_field(elem, "log-time", format_time(line.timeMs));
append_text(elem, kNbsp);
append_span(elem, "log-mod", fmt::format("[{}]", modId));
append_log_field(elem, "log-mod", fmt::format("[{}]", modId));
append_text(elem, kNbsp);
append_span(elem, "log-msg", line.message);
append_log_field(elem, "log-msg", line.message);
return elem;
}
+11 -8
View File
@@ -35,6 +35,7 @@ namespace {
const Rml::String kDocumentSource = R"RML(
<rml>
<head>
<link type="text/rcss" href="res/rml/theme.rcss" />
<link type="text/rcss" href="res/rml/tabbing.rcss" />
<link type="text/rcss" href="res/rml/popup.rcss" />
</head>
@@ -79,8 +80,9 @@ void MenuBar::build_tabs() {
}
// Only allow us to access achievements if we are playing on a game mode that uses them
if (dusk::gamemode::getGameModeManager().isCurrentGameMode(dusk::gamemode::kVanillaGameModeId)
|| dusk::gamemode::getGameModeManager().isCurrentGameMode(dusk::speedrun::kSpeedrunGameModeId)) {
if (gamemode::getGameModeManager().isCurrentGameMode(gamemode::kVanillaGameModeId) ||
gamemode::getGameModeManager().isCurrentGameMode(speedrun::kSpeedrunGameModeId))
{
mTabBar->add_tab("Achievements", [this] { push(std::make_unique<AchievementsWindow>()); });
}
mTabBar->add_tab("Mods", [this] { push(std::make_unique<ModsWindow>()); });
@@ -94,7 +96,7 @@ void MenuBar::build_tabs() {
push(std::make_unique<Modal>(Modal::Props{
.title = "Reset Game",
.bodyRml = "Unsaved progress will be lost.<br/>"
"<span class=\"tip\">Tip: You can also reset by holding Start+X+B</span>",
"<modal-tip>Tip: You can also reset by holding Start+X+B</modal-tip>",
.actions =
{
ModalAction{
@@ -115,7 +117,8 @@ void MenuBar::build_tabs() {
return;
}
dismiss(modal);
if (gamemode::getGameModeManager().getRegisteredGameModes().size() > 1) {
if (gamemode::getGameModeManager().getRegisteredGameModes().size() >
1) {
// If game modes are registered, return to prelaunch on reset.
prelaunch_state().returnToPrelaunchOnReset = true;
}
@@ -133,7 +136,7 @@ void MenuBar::build_tabs() {
const auto dismiss = [](Modal& modal) { modal.pop(); };
push(std::make_unique<Modal>(Modal::Props{
.title = "Quit Dusklight",
.bodyRml = "Unsaved progress will be lost.",
.bodyText = "Unsaved progress will be lost.",
.actions =
{
ModalAction{
@@ -159,12 +162,12 @@ void MenuBar::build_tabs() {
}));
});
if (dusk::speedrun::isActive()) {
if (speedrun::isActive()) {
mTabBar->add_tab("Reset Run", [this] {
mTabBar->set_active_tab(-1);
mDoAud_seStartMenu(kSoundClick);
dusk::speedrun::g_speedrunInfo.reset();
dusk::speedrun::reset();
speedrun::g_speedrunInfo.reset();
speedrun::reset();
JUTGamePad::C3ButtonReset::sResetSwitchPushing = true;
hide(false);
});
+906
View File
@@ -0,0 +1,906 @@
#include "mod_browser.hpp"
#include "bool_button.hpp"
#include "button.hpp"
#include "dusk/mod_loader.hpp"
#include "dusk/mods/svc/registry.hpp"
#include "fmt/format.h"
#include "nav_group.hpp"
#include "remote_texture_provider.hpp"
#include "string_button.hpp"
#include <SDL3/SDL_misc.h>
#include <borealis/http.hpp>
#include <algorithm>
#include <array>
#include <charconv>
#include <chrono>
#include <cstddef>
#include <exception>
#include <memory>
#include <ranges>
#include <string_view>
namespace dusk::ui {
namespace {
struct SortOption {
mods::catalog::Sort value;
std::string_view label;
};
constexpr std::array sortOptions{
SortOption{mods::catalog::Sort::Updated, "Recently updated"},
SortOption{mods::catalog::Sort::Downloads, "Most downloaded"},
SortOption{mods::catalog::Sort::Endorsements, "Most endorsed"},
SortOption{mods::catalog::Sort::Newest, "Newest"},
SortOption{mods::catalog::Sort::Name, "Name"},
};
std::string_view sort_label(mods::catalog::Sort sort) noexcept {
const auto iter = std::ranges::find(sortOptions, sort, &SortOption::value);
return iter != sortOptions.end() ? iter->label : sortOptions.front().label;
}
std::string format_count(std::uint64_t value) {
if (value >= 1'000'000) {
return fmt::format("{:.1f}m", static_cast<double>(value) / 1'000'000.0);
}
if (value >= 1'000) {
return fmt::format("{:.1f}k", static_cast<double>(value) / 1'000.0);
}
return fmt::format("{}", value);
}
std::string format_bytes(std::uint64_t bytes) {
constexpr double kiB = 1024.0;
constexpr double miB = kiB * 1024.0;
constexpr double giB = miB * 1024.0;
if (bytes >= static_cast<std::uint64_t>(giB)) {
return fmt::format("{:.1f} GiB", static_cast<double>(bytes) / giB);
}
if (bytes >= static_cast<std::uint64_t>(miB)) {
return fmt::format("{:.1f} MiB", static_cast<double>(bytes) / miB);
}
if (bytes >= static_cast<std::uint64_t>(kiB)) {
return fmt::format("{:.0f} KiB", static_cast<double>(bytes) / kiB);
}
return fmt::format("{} B", bytes);
}
std::string display_date(std::string_view timestamp) {
return std::string{timestamp.substr(0, std::min<std::size_t>(timestamp.size(), 10))};
}
std::string relative_date(std::string_view timestamp) {
if (timestamp.size() < 10) {
return display_date(timestamp);
}
int yearValue = 0;
unsigned monthValue = 0;
unsigned dayValue = 0;
const auto parse = [](std::string_view text, auto& value) {
const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), value);
return error == std::errc{} && end == text.data() + text.size();
};
if (!parse(timestamp.substr(0, 4), yearValue) || !parse(timestamp.substr(5, 2), monthValue) ||
!parse(timestamp.substr(8, 2), dayValue))
{
return display_date(timestamp);
}
const std::chrono::year_month_day date{
std::chrono::year{yearValue}, std::chrono::month{monthValue}, std::chrono::day{dayValue}};
if (!date.ok()) {
return display_date(timestamp);
}
const auto today = std::chrono::floor<std::chrono::days>(std::chrono::system_clock::now());
const auto age = (today - std::chrono::sys_days{date}).count();
if (age < 0) {
return display_date(timestamp);
}
if (age == 0) {
return "today";
}
if (age == 1) {
return "yesterday";
}
if (age < 7) {
return fmt::format("{} days ago", age);
}
if (age < 30) {
return fmt::format("{} weeks ago", age / 7);
}
if (age < 365) {
return fmt::format("{} months ago", age / 30);
}
return fmt::format("{} years ago", age / 365);
}
std::string snippet(std::string_view text, std::size_t maxBytes) {
if (text.size() <= maxBytes) {
return std::string{text};
}
std::size_t end = maxBytes;
while (end > 0 && (static_cast<unsigned char>(text[end]) & 0xc0) == 0x80) {
--end;
}
return std::string{text.substr(0, end)} + "...";
}
void add_list_markers(Rml::Element* fragment) {
Rml::ElementList lists;
fragment->QuerySelectorAll(lists, "ul, ol");
for (auto* list : lists) {
const bool ordered = list->GetTagName() == "ol";
int ordinal = list->GetAttribute("start", 1);
for (int index = 0; index < list->GetNumChildren(); ++index) {
auto* item = list->GetChild(index);
if (item->GetTagName() != "li") {
continue;
}
auto marker = item->GetOwnerDocument()->CreateElement("catalog-list-marker");
Rml::Element* insertedMarker = nullptr;
if (auto* firstChild = item->GetFirstChild()) {
insertedMarker = item->InsertBefore(std::move(marker), firstChild);
} else {
insertedMarker = item->AppendChild(std::move(marker));
}
append_text(insertedMarker, ordered ? fmt::format("{}.", ordinal) : "\u2022");
if (ordered) {
++ordinal;
}
}
}
}
std::string image_source(const mods::catalog::Image& image, std::uint32_t preferredWidth) {
const auto wider = std::ranges::find_if(image.sources,
[preferredWidth](const auto& source) { return source.width >= preferredWidth; });
if (wider != image.sources.end()) {
return wider->pngUrl;
}
return image.sources.empty() ? std::string{} : image.sources.back().pngUrl;
}
void set_image(Rml::Element* element, const mods::catalog::Image& image,
std::uint32_t preferredWidth, std::string_view fit = "cover") {
if (element == nullptr) {
return;
}
const auto source = image_source(image, preferredWidth);
if (!source.empty()) {
set_remote_texture_dimensions(source, image.width, image.height);
element->SetProperty(
"decorator", fmt::format(R"(image("{}" {} center center))", escape(source), fit));
element->SetClass("has-image", true);
}
}
bool installed(std::string_view id) {
return std::ranges::any_of(mods::ModLoader::instance().mods(),
[id](const mods::LoadedMod& mod) { return mod.metadata.id == id; });
}
bool safe_web_url(std::string_view url) {
return url.starts_with("https://") || url.starts_with("http://");
}
void open_web_url(const std::string& url) {
if (safe_web_url(url)) {
SDL_OpenURL(url.c_str());
}
}
void set_icon_button_content(Button& button, const Rml::String& glyph, const Rml::String& label) {
clear_children(button.root());
append_text(append(button.root(), "icon"), glyph);
append_text_element(button.root(), "catalog-action-label", label);
}
void append_status(Rml::Element* parent, const Rml::String& title, const Rml::String& message) {
append_text_element(parent, "catalog-status-title", title);
append_text_element(parent, "catalog-status-message", message);
}
void append_stat(Rml::Element* parent, const Rml::String& glyph, const Rml::String& value,
const Rml::String& suffix) {
auto* stat = append(parent, "catalog-detail-stat");
if (!glyph.empty()) {
append_text(append(stat, "icon"), glyph);
}
append_text_element(stat, "catalog-stat-value", value);
append_text(stat, suffix);
}
void append_detail_field(Rml::Element* list, const Rml::String& label, const Rml::String& value) {
append_text_element(list, "dt", label);
append_text_element(list, "dd", value);
}
class ModBrowserDetail;
class CatalogCard final : public Button {
public:
CatalogCard(Rml::Element* parent, const mods::catalog::Mod& mod, std::function<void()> onOpen)
: Button{parent, Props{}} {
mRoot->SetClass("catalog-card", true);
const auto category = mod.category ? mod.category->name : "Uncategorized";
const auto isInstalled = installed(mod.id);
const auto installedLabel = isInstalled ? "Installed" : format_bytes(mod.packageSize);
auto* art = append(mRoot, "catalog-card-art");
append(art, "catalog-card-art-shadow");
auto* icon = append(art, "catalog-card-icon");
auto* iconImage = append(icon, "catalog-card-icon-image");
auto* body = append(mRoot, "catalog-card-body");
auto* kicker = append(body, "catalog-card-kicker");
append_text_element(kicker, "catalog-card-category", category);
append_text_element(kicker, "catalog-card-updated", relative_date(mod.updatedAt));
auto* identity = append(body, "catalog-card-identity");
append_text_element(identity, "catalog-card-title", mod.name);
append_text_element(identity, "catalog-card-author", "by " + mod.author.name);
append_text_element(body, "catalog-card-summary", snippet(mod.summary, 126));
auto* meta = append(body, "catalog-card-meta");
auto* downloads = append(meta, "catalog-card-stat");
append_text(append(downloads, "icon"), "\uF090");
append_text(downloads, format_count(mod.downloads));
auto* endorsements = append(meta, "catalog-card-stat");
append_text(append(endorsements, "icon"), "\uE87D");
append_text(endorsements, format_count(mod.endorsements));
auto* size = append_text_element(meta, "catalog-card-size", installedLabel);
if (isInstalled) {
size->SetClass("installed", true);
}
if (mod.banner) {
set_image(art, *mod.banner, 640);
} else if (mod.icon) {
set_image(art, *mod.icon, 256);
}
if (mod.icon) {
set_image(iconImage, *mod.icon, 128);
}
on_pressed(std::move(onOpen));
}
};
class ScreenshotViewer final : public Window {
public:
ScreenshotViewer(std::vector<mods::catalog::Screenshot> screenshots, std::size_t index)
: Window{Props{
.tabBar = false,
.styleSheets = {"res/rml/mod_browser.rcss"},
}},
mScreenshots{std::move(screenshots)}, mIndex{index} {
mRoot->SetClass("screenshot-viewer", true);
set_content([this](Rml::Element* content) { build_content(content); });
}
void update() override {
if (mRebuildRequested) {
mRebuildRequested = false;
rebuild_content();
}
Window::update();
}
private:
void build_content(Rml::Element* content) {
auto* image = append(content, "catalog-screenshot-full");
if (mIndex < mScreenshots.size()) {
set_image(image, mScreenshots[mIndex].image, 1280, "contain");
}
auto* actionsRoot = append(content, "catalog-screenshot-actions");
auto& actions =
add_child<NavGroup>(actionsRoot, NavGroup::Props{
.layout = NavGroup::Layout::Horizontal,
.horizontalBoundary = NavGroup::Boundary::Stop,
.verticalBoundary = NavGroup::Boundary::Stop,
});
auto& back = actions.add_item<Button>("Back");
back.root()->SetClass("catalog-icon-action", true);
set_icon_button_content(back, "\uE5C4", "Back");
back.on_pressed([this] { pop(); });
auto& previous = actions.add_item<ControlledButton>(ControlledButton::Props{
.text = "Previous",
.isDisabled = [this] { return mIndex == 0; },
});
previous.on_pressed([this] {
if (mIndex > 0) {
--mIndex;
mRestoreNav = -1;
mRebuildRequested = true;
}
});
actions.add_item<ControlledButton>(ControlledButton::Props{
.text = fmt::format("{} / {}", mIndex + 1, mScreenshots.size()),
.isDisabled = [] { return true; },
});
auto& next = actions.add_item<ControlledButton>(ControlledButton::Props{
.text = "Next",
.isDisabled = [this] { return mIndex + 1 >= mScreenshots.size(); },
});
next.on_pressed([this] {
if (mIndex + 1 < mScreenshots.size()) {
++mIndex;
mRestoreNav = 1;
mRebuildRequested = true;
}
});
if (mRestoreNav < 0) {
if (!previous.focus()) {
next.focus();
}
} else if (mRestoreNav > 0) {
if (!next.focus()) {
previous.focus();
}
}
mRestoreNav = 0;
}
std::vector<mods::catalog::Screenshot> mScreenshots;
std::size_t mIndex = 0;
bool mRebuildRequested = false;
int mRestoreNav = 0;
};
class DetailContent final : public NavGroup {
public:
DetailContent(
Rml::Element* root, ModBrowserDetail& window, const mods::catalog::Detail& detail);
};
class ScrollAnchor final : public Component {
public:
using Component::Component;
};
class ModBrowserDetail final : public Window {
public:
explicit ModBrowserDetail(mods::catalog::Mod mod)
: Window{Props{.tabBar = false, .styleSheets = {"res/rml/mod_browser.rcss"}}},
mSummary{std::move(mod)} {
mRoot->SetClass("mod-browser-detail", true);
set_content([this](Rml::Element* content) { build_content(content); });
begin_fetch();
}
void update() override {
if (mFetch && mFetch.ready()) {
try {
if (auto result = mFetch.try_take()) {
if (result->detail) {
mDetail = std::move(result->detail);
mError.clear();
} else {
mError = result->error.empty() ? "The mod request failed." :
std::move(result->error);
}
}
} catch (const std::exception& exception) {
mError = fmt::format("The mod request failed: {}", exception.what());
} catch (...) {
mError = "The mod request failed.";
}
mFetch = {};
mRebuildRequested = true;
}
if (mRebuildRequested) {
mRebuildRequested = false;
rebuild_content();
}
Window::update();
}
void show_screenshot(std::size_t index) {
if (mDetail && index < mDetail->screenshots.size()) {
push(std::make_unique<ScreenshotViewer>(mDetail->screenshots, index));
}
}
private:
void begin_fetch() {
mDetail.reset();
mError.clear();
mFetch = mods::catalog::fetch_detail(mSummary.id);
mRebuildRequested = true;
}
void build_content(Rml::Element* content) {
if (mDetail) {
auto* scroll = append(content, "detail-scroll");
add_child<DetailContent>(scroll, *this, *mDetail);
return;
}
auto* status = append(content, "catalog-detail-status");
if (mError.empty()) {
append_status(status, "Loading " + mSummary.name, "Fetching mod details and images...");
return;
}
append_status(status, "Could not load " + mSummary.name, mError);
auto* retryRoot = append(status, "catalog-retry-actions");
auto& retry = add_child<NavGroup>(retryRoot, NavGroup::Props{});
retry.add_item<Button>("Retry").on_pressed([this] { begin_fetch(); });
}
mods::catalog::Mod mSummary;
std::optional<mods::catalog::Detail> mDetail;
borealis::Task<mods::catalog::DetailFetchResult> mFetch;
std::string mError;
bool mRebuildRequested = false;
};
DetailContent::DetailContent(
Rml::Element* root, ModBrowserDetail& window, const mods::catalog::Detail& detail)
: NavGroup{root, Props{
.layout = Layout::Vertical,
.horizontalBoundary = Boundary::Stop,
.verticalBoundary = Boundary::Stop,
}} {
auto* hero = append(mRoot, "catalog-detail-hero");
if (detail.banner) {
set_image(hero, *detail.banner, 1280);
} else if (detail.mod.icon) {
set_image(hero, *detail.mod.icon, 512);
}
append(hero, "catalog-detail-hero-shadow");
auto* actionsRoot = append(hero, "catalog-detail-actions");
auto& actions =
add_existing_item<NavGroup>(actionsRoot, Props{
.layout = Layout::Horizontal,
.horizontalBoundary = Boundary::Bubble,
.verticalBoundary = Boundary::Bubble,
});
auto& back = actions.add_item<Button>("Back");
back.root()->SetClass("catalog-icon-action", true);
set_icon_button_content(back, "\ue5c4", "Back");
back.on_pressed([&window] { window.pop(); });
auto& open = actions.add_item<Button>("Open in browser");
open.root()->SetClass("catalog-icon-action", true);
set_icon_button_content(open, "\ue89e", "Open in browser");
open.on_pressed([url = detail.siteUrl] { open_web_url(url); });
auto* identity = append(hero, "catalog-detail-identity");
auto* detailIcon = append(identity, "catalog-detail-icon");
auto* detailIconImage = append(detailIcon, "catalog-detail-icon-image");
auto* detailHeading = append(identity, "catalog-detail-heading");
append_text_element(detailHeading, "catalog-detail-category",
detail.mod.category ? detail.mod.category->name : "Uncategorized");
auto* title = append(detailHeading, "h1");
append_text(title, detail.mod.name);
append_text_element(title, "small", "v" + detail.mod.version);
auto* author = append(detailHeading, "p");
append_text(author, "by " + detail.mod.author.name + " ");
if (detail.mod.author.official) {
append_text_element(author, "catalog-official-badge", "Official");
}
if (detail.mod.icon) {
set_image(detailIconImage, *detail.mod.icon, 256);
}
auto* stats = append(mRoot, "catalog-detail-stats");
append_stat(stats, "\uF090", format_count(detail.mod.downloads), " downloads");
append_stat(stats, "\uE87D", format_count(detail.mod.endorsements), " endorsements");
append_stat(stats, "", format_bytes(detail.mod.packageSize), " package");
// TODO replace with common banner component
// if (detail.mod.containsNativeCode) {
// auto* warning = append(mRoot, "catalog-native-warning");
// append_text(append(warning, "icon"), "\uE002");
// auto* copy = append(warning, "catalog-warning-copy");
// append_text_element(copy, "catalog-warning-title", "Contains native code");
// append_text_element(
// copy, "catalog-warning-message", "Review the source and author before installing.");
// }
auto* body = append(mRoot, "catalog-detail-body");
auto* main = append(body, "main");
auto* sidebar = append(body, "aside");
auto* description = append(main, "section");
description->SetClass("catalog-scroll-anchor", true);
add_existing_item<ScrollAnchor>(description);
// append_text_element(description, "h2", "About this mod");
auto* descriptionFragment = append(description, "catalog-fragment");
if (detail.descriptionHtml.empty()) {
append_text_element(descriptionFragment, "p", "No description provided.");
} else {
descriptionFragment->SetInnerRML(detail.descriptionHtml);
add_list_markers(descriptionFragment);
}
if (!detail.screenshots.empty()) {
auto* section = append(main, "section");
append_text(append(section, "h2"), "Screenshots");
auto* galleryRoot = append(section, "catalog-gallery");
auto& gallery =
add_existing_item<NavGroup>(galleryRoot, Props{
.layout = Layout::Horizontal,
.horizontalBoundary = Boundary::Bubble,
.verticalBoundary = Boundary::Bubble,
});
const std::size_t shown = std::min<std::size_t>(detail.screenshots.size(), 3);
for (std::size_t index = 0; index < shown; ++index) {
auto& screenshot = gallery.add_item<Button>(Button::Props{});
screenshot.root()->SetClass("catalog-screenshot", true);
screenshot.root()->SetClass("primary", index == 0);
set_image(screenshot.root(), detail.screenshots[index].image, index == 0 ? 1280 : 640);
if (index == 2 && detail.screenshots.size() > shown) {
screenshot.set_text(fmt::format("+{}", detail.screenshots.size() - shown));
}
screenshot.on_pressed([&window, index] { window.show_screenshot(index); });
}
}
auto* dependencies = append(main, "section");
dependencies->SetClass("catalog-scroll-anchor", true);
add_existing_item<ScrollAnchor>(dependencies);
append_text(append(dependencies, "h2"), "Dependencies");
auto* dependencyList = append(dependencies, "catalog-dependencies");
std::size_t requiredDusklight = 0;
bool dusklightSatisfied = true;
for (const auto& import : detail.serviceImports) {
if (import.optional) {
continue;
}
const bool available =
mods::svc::find_service(import.id.c_str(), import.major, import.minMinor) != nullptr;
if (import.id.starts_with("dev.twilitrealm.dusklight.")) {
++requiredDusklight;
dusklightSatisfied = dusklightSatisfied && available;
continue;
}
auto* row = append(dependencyList, "catalog-dependency");
append_text_element(row, "catalog-dependency-name", import.id);
append_text_element(row, "catalog-dependency-status",
fmt::format("v{}.{}+ · {}", import.major, import.minMinor,
available ? "Available" : "Not available"));
row->SetClass("missing", !available);
}
if (requiredDusklight != 0) {
auto* row = append(dependencyList, "catalog-dependency");
append_text_element(row, "catalog-dependency-name", "Dusklight services");
append_text_element(row, "catalog-dependency-status",
fmt::format("{} required · {}", requiredDusklight,
dusklightSatisfied ? "Available" : "Update required"));
row->SetClass("missing", !dusklightSatisfied);
}
if (requiredDusklight == 0 && dependencyList->GetNumChildren() == 0) {
append_text(dependencyList, "No required dependencies.");
}
auto* changelog = append(main, "section");
changelog->SetClass("catalog-scroll-anchor", true);
add_existing_item<ScrollAnchor>(changelog);
auto* changelogTitle = append(changelog, "h2");
append_text(changelogTitle, "Changelog ");
append_text_element(changelogTitle, "small",
"v" + detail.mod.version + " · " + display_date(detail.mod.updatedAt));
auto* changelogFragment = append(changelog, "catalog-fragment");
if (detail.changelogHtml.empty()) {
append_text_element(changelogFragment, "p", "No changelog was provided.");
} else {
changelogFragment->SetInnerRML(detail.changelogHtml);
add_list_markers(changelogFragment);
}
append_text_element(sidebar, "h3", "Details");
auto* detailList = append(sidebar, "dl");
append_detail_field(detailList, "Version", detail.mod.version);
append_detail_field(detailList, "Updated", display_date(detail.mod.updatedAt));
append_detail_field(detailList, "Published", display_date(detail.mod.publishedAt));
append_detail_field(
detailList, "Category", detail.mod.category ? detail.mod.category->name : "Uncategorized");
append_detail_field(detailList, "License", detail.license.value_or("Not specified"));
append_detail_field(
detailList, "Mod ABI", detail.modAbi ? fmt::format("{}", *detail.modAbi) : "Assets only");
if (detail.sourceUrl && safe_web_url(*detail.sourceUrl)) {
auto* sourceActions = append(sidebar, "catalog-source-actions");
auto& sourceGroup =
add_existing_item<NavGroup>(sourceActions, Props{
.layout = Layout::Vertical,
.horizontalBoundary = Boundary::Bubble,
.verticalBoundary = Boundary::Bubble,
});
sourceGroup.add_item<Button>("View source").on_pressed([url = *detail.sourceUrl] {
open_web_url(url);
});
}
}
} // namespace
ModBrowser::ModBrowser()
: Window{Props{.tabBar = false, .styleSheets = {"res/rml/mod_browser.rcss"}}} {
mRoot->SetClass("mod-browser", true);
mQuery.sort = mods::catalog::Sort::Updated;
mState = borealis::http::available() ? State::Loading : State::Unavailable;
set_content([this](Rml::Element* content) { build_content(content); });
if (mState == State::Loading) {
mFetch = mods::catalog::fetch_page(mQuery);
}
}
void ModBrowser::build_content(Rml::Element* content) {
auto* filtersRoot = append(content, "catalog-filters");
auto& filters =
add_child<NavGroup>(filtersRoot, NavGroup::Props{
.layout = NavGroup::Layout::Vertical,
.horizontalBoundary = NavGroup::Boundary::Bubble,
.verticalBoundary = NavGroup::Boundary::Stop,
});
append_text(append(filtersRoot, "h1"), "Browse Mods");
auto& search = filters.add_item<StringButton>(StringButton::Props{
.key = "Search",
.getValue = [this] { return mQuery.search; },
.setValue =
[this](Rml::String value) {
if (mQuery.search != value) {
mQuery.search = std::move(value);
mQuery.page = 1;
begin_fetch(FocusTarget::Search);
}
},
.maxLength = 100,
});
append_text(append(filtersRoot, "h2"), "Category");
auto& category = filters.add_item<ControlledSelectButton>(ControlledSelectButton::Props{
.key = "Category",
.getValue =
[this] {
if (mQuery.category.empty() || !mPage) {
return Rml::String{"All mods"};
}
const auto iter = std::ranges::find(
mPage->categories, mQuery.category, &mods::catalog::Category::slug);
return iter == mPage->categories.end() ? Rml::String{"All mods"} : iter->name;
},
});
category.on_pressed([this] { cycle_category(); });
auto& sort = filters.add_item<ControlledSelectButton>(ControlledSelectButton::Props{
.key = "Sort by",
.getValue = [this] { return Rml::String{sort_label(mQuery.sort)}; },
});
sort.on_pressed([this] { cycle_sort(); });
auto& device = filters.add_item<BoolButton>(BoolButton::Props{
.key = "This device",
.getValue = [this] { return mQuery.thisDevice; },
.setValue =
[this](bool value) {
if (mQuery.thisDevice != value) {
mQuery.thisDevice = value;
mQuery.page = 1;
begin_fetch(FocusTarget::Device);
}
},
});
append_text(append(filtersRoot, "h2"), "Library");
auto& library = filters.add_item<Button>(
fmt::format("Installed mods ({})", mods::ModLoader::instance().mods().size()));
library.root()->SetClass("catalog-library-link", true);
library.on_pressed([this] { pop(); });
auto* resultsRoot = append(content, "catalog-results");
auto& results =
add_child<NavGroup>(resultsRoot, NavGroup::Props{
.layout = NavGroup::Layout::Vertical,
.horizontalBoundary = NavGroup::Boundary::Bubble,
.verticalBoundary = NavGroup::Boundary::Stop,
});
auto* heading = append(resultsRoot, "header");
const std::string categoryName = [&] {
if (mQuery.category.empty() || !mPage) {
return std::string{"All mods"};
}
const auto iter =
std::ranges::find(mPage->categories, mQuery.category, &mods::catalog::Category::slug);
return iter == mPage->categories.end() ? std::string{"All mods"} : iter->name;
}();
const std::uint64_t total = mPage ? mPage->pagination.total : 0;
append_text_element(heading, "h1", categoryName);
append_text_element(heading, "catalog-results-summary",
fmt::format("{} mods · sorted by {}", total, sort_label(mQuery.sort)));
Component* resultFocus = nullptr;
Component* retryFocus = nullptr;
auto* viewport = append(resultsRoot, "catalog-viewport");
if (mState == State::Ready && mPage && !mPage->mods.empty()) {
auto* gridRoot = append(viewport, "catalog-grid");
auto& grid = results.add_existing_item<NavGroup>(
gridRoot, NavGroup::Props{
.layout = NavGroup::Layout::Grid,
.columns = 2,
.horizontalBoundary = NavGroup::Boundary::Bubble,
.verticalBoundary = NavGroup::Boundary::Bubble,
});
for (const auto& mod : mPage->mods) {
auto& card = grid.add_item<CatalogCard>(
mod, [this, mod] { push(std::make_unique<ModBrowserDetail>(mod)); });
if (resultFocus == nullptr) {
resultFocus = &card;
}
}
if (mPage->pagination.pageCount > 1) {
auto* paginationRoot = append(resultsRoot, "catalog-pagination");
auto& pagination = results.add_existing_item<NavGroup>(
paginationRoot, NavGroup::Props{
.layout = NavGroup::Layout::Horizontal,
.horizontalBoundary = NavGroup::Boundary::Stop,
.verticalBoundary = NavGroup::Boundary::Bubble,
});
pagination
.add_item<ControlledButton>(ControlledButton::Props{
.text = "Previous",
.isDisabled = [this] { return mQuery.page <= 1; },
})
.on_pressed([this] {
if (mQuery.page > 1) {
--mQuery.page;
begin_fetch(FocusTarget::Results);
}
});
auto* label = append(paginationRoot, "catalog-pagination-label");
append_text(label,
fmt::format("Page {} of {}", mPage->pagination.page, mPage->pagination.pageCount));
pagination
.add_item<ControlledButton>(ControlledButton::Props{
.text = "Next",
.isDisabled =
[this] { return !mPage || mQuery.page >= mPage->pagination.pageCount; },
})
.on_pressed([this] {
if (mPage && mQuery.page < mPage->pagination.pageCount) {
++mQuery.page;
begin_fetch(FocusTarget::Results);
}
});
}
} else {
auto* status = append(viewport, "catalog-results-status");
switch (mState) {
case State::Loading:
// TODO better loading state
append_status(status, "Loading catalog", "Fetching published mods...");
break;
case State::Unavailable:
append_status(status, "Catalog unavailable", "This build has no HTTP backend.");
break;
case State::Error: {
append_status(status, "Could not load mods", mError);
auto* retryRoot = append(status, "catalog-retry-actions");
auto& retryGroup = results.add_existing_item<NavGroup>(retryRoot, NavGroup::Props{});
auto& retry = retryGroup.add_item<Button>("Retry");
retry.on_pressed([this] { begin_fetch(FocusTarget::Retry); });
retryFocus = &retry;
break;
}
case State::Ready:
append_status(status, "No mods found", "Try changing the search or category.");
break;
}
}
Component* focus = nullptr;
switch (mFocusTarget) {
case FocusTarget::Search:
focus = &search;
break;
case FocusTarget::Category:
focus = &category;
break;
case FocusTarget::Sort:
focus = &sort;
break;
case FocusTarget::Device:
focus = &device;
break;
case FocusTarget::Results:
focus = resultFocus;
break;
case FocusTarget::Retry:
focus = retryFocus;
break;
case FocusTarget::Default:
break;
}
if (mState != State::Loading) {
mFocusTarget = FocusTarget::Default;
}
if (focus != nullptr) {
focus->focus();
}
}
void ModBrowser::begin_fetch(FocusTarget focusTarget) {
mFocusTarget = focusTarget;
if (!borealis::http::available()) {
mState = State::Unavailable;
mFetch = {};
} else {
mState = State::Loading;
mError.clear();
mFetch = mods::catalog::fetch_page(mQuery);
}
mRebuildRequested = true;
}
void ModBrowser::finish_fetch(mods::catalog::FetchResult result) {
if (result.page) {
mPage = std::move(result.page);
mQuery.page = std::max(mPage->pagination.page, 1);
mState = State::Ready;
mError.clear();
if (mFocusTarget == FocusTarget::Retry) {
mFocusTarget = FocusTarget::Results;
}
} else {
mState = State::Error;
mError = result.error.empty() ? "The catalog request failed." : std::move(result.error);
}
if (mFocusTarget == FocusTarget::Default) {
mFocusTarget = FocusTarget::Search;
}
mRebuildRequested = true;
}
void ModBrowser::cycle_category() {
if (!mPage || mPage->categories.empty()) {
return;
}
if (mQuery.category.empty()) {
mQuery.category = mPage->categories.front().slug;
} else {
const auto iter =
std::ranges::find(mPage->categories, mQuery.category, &mods::catalog::Category::slug);
const auto next =
iter == mPage->categories.end() ? mPage->categories.begin() : std::next(iter);
mQuery.category = next == mPage->categories.end() ? std::string{} : next->slug;
}
mQuery.page = 1;
begin_fetch(FocusTarget::Category);
}
void ModBrowser::cycle_sort() {
const auto iter = std::ranges::find(sortOptions, mQuery.sort, &SortOption::value);
const auto next = iter == sortOptions.end() || std::next(iter) == sortOptions.end() ?
sortOptions.begin() :
std::next(iter);
mQuery.sort = next->value;
mQuery.page = 1;
begin_fetch(FocusTarget::Sort);
}
void ModBrowser::update() {
if (mFetch && mFetch.ready()) {
try {
if (auto result = mFetch.try_take()) {
finish_fetch(std::move(*result));
}
} catch (const std::exception& exception) {
finish_fetch(
{.error = fmt::format("The catalog request failed: {}", exception.what())});
} catch (...) {
finish_fetch({.error = "The catalog request failed."});
}
mFetch = {};
}
if (mRebuildRequested) {
mRebuildRequested = false;
rebuild_content();
}
Window::update();
}
} // namespace dusk::ui
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include "dusk/mods/catalog.hpp"
#include "window.hpp"
#include <optional>
#include <string>
namespace dusk::ui {
class ModBrowser final : public Window {
public:
ModBrowser();
void update() override;
private:
enum class State {
Loading,
Ready,
Error,
Unavailable,
};
enum class FocusTarget {
Default,
Search,
Category,
Sort,
Device,
Results,
Retry,
};
void build_content(Rml::Element* content);
void begin_fetch(FocusTarget focusTarget);
void finish_fetch(mods::catalog::FetchResult result);
void cycle_category();
void cycle_sort();
mods::catalog::Query mQuery;
std::optional<mods::catalog::Page> mPage;
borealis::Task<mods::catalog::FetchResult> mFetch;
std::string mError;
State mState = State::Loading;
FocusTarget mFocusTarget = FocusTarget::Default;
bool mRebuildRequested = false;
};
} // namespace dusk::ui
+5 -70
View File
@@ -1,6 +1,7 @@
#include "mod_texture_provider.hpp"
#include "dusk/mod_loader.hpp"
#include "runtime_image.hpp"
#include <fmt/format.h>
@@ -14,14 +15,11 @@ std::string mod_image_source(const mods::LoadedMod& mod, std::string_view bundle
#ifdef AURORA_ENABLE_RMLUI
#include <SDL3/SDL_iostream.h>
#include <SDL3/SDL_surface.h>
#include <borealis/log.hpp>
#include <aurora/rmlui.hpp>
#include <borealis/log.hpp>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <memory>
#include <optional>
#include <span>
@@ -40,16 +38,8 @@ constexpr std::string_view kScheme = "mod";
constexpr std::string_view kSourcePrefix = "mod://";
constexpr size_t kMaxCachedImages = 64;
constexpr size_t kMaxImageFileSize = 16 * 1024 * 1024;
constexpr uint32_t kMaxImageDimension = 4096;
struct CachedImage {
std::vector<uint8_t> pixels;
uint32_t width = 0;
uint32_t height = 0;
};
std::unordered_map<std::string, CachedImage>& image_cache() {
static auto* cache = new std::unordered_map<std::string, CachedImage>();
std::unordered_map<std::string, DecodedImage>& image_cache() {
static auto* cache = new std::unordered_map<std::string, DecodedImage>();
return *cache;
}
@@ -61,62 +51,7 @@ std::string_view strip_query(std::string_view path) noexcept {
return path;
}
std::optional<CachedImage> decode_png(std::span<const uint8_t> data, std::string_view source) {
SDL_IOStream* stream = SDL_IOFromConstMem(data.data(), data.size());
if (stream == nullptr) {
Log.warn("Failed to open image stream for '{}': {}", source, SDL_GetError());
return std::nullopt;
}
SDL_Surface* loadedSurface = SDL_LoadPNG_IO(stream, true);
if (loadedSurface == nullptr) {
Log.warn("Failed to decode image '{}': {}", source, SDL_GetError());
return std::nullopt;
}
SDL_Surface* rgbaSurface = SDL_ConvertSurface(loadedSurface, SDL_PIXELFORMAT_RGBA32);
SDL_DestroySurface(loadedSurface);
if (rgbaSurface == nullptr) {
Log.warn("Failed to convert image '{}': {}", source, SDL_GetError());
return std::nullopt;
}
const auto width = static_cast<uint32_t>(rgbaSurface->w);
const auto height = static_cast<uint32_t>(rgbaSurface->h);
if (width == 0 || height == 0 || width > kMaxImageDimension || height > kMaxImageDimension) {
Log.warn("Image '{}' has unsupported dimensions {}x{}", source, width, height);
SDL_DestroySurface(rgbaSurface);
return std::nullopt;
}
const size_t rowSize = static_cast<size_t>(width) * 4;
CachedImage image{
.pixels = std::vector<uint8_t>(rowSize * height),
.width = width,
.height = height,
};
for (uint32_t row = 0; row < height; ++row) {
const auto* src = static_cast<const uint8_t*>(rgbaSurface->pixels) +
static_cast<size_t>(row) * static_cast<size_t>(rgbaSurface->pitch);
auto* dst = image.pixels.data() + static_cast<size_t>(row) * rowSize;
std::memcpy(dst, src, rowSize);
// Convert to premultiplied alpha for correct compositing.
for (size_t col = 0; col < rowSize; col += 4) {
const uint8_t alpha = dst[col + 3];
for (size_t channel = 0; channel < 3; ++channel) {
dst[col + channel] = static_cast<uint8_t>(
(static_cast<uint32_t>(dst[col + channel]) * static_cast<uint32_t>(alpha)) /
255);
}
}
}
SDL_DestroySurface(rgbaSurface);
return image;
}
std::optional<CachedImage> load_mod_image(std::string_view idAndPath, std::string_view source) {
std::optional<DecodedImage> load_mod_image(std::string_view idAndPath, std::string_view source) {
const auto slash = idAndPath.find('/');
if (slash == std::string_view::npos || slash == 0 || slash + 1 >= idAndPath.size()) {
Log.warn("Malformed mod image source '{}'", source);
+20 -17
View File
@@ -4,34 +4,33 @@
namespace dusk::ui {
Modal::Modal(Props props) : WindowSmall("modal", "modal-dialog"), mProps(std::move(props)) {
Modal::Modal(Props props) : WindowSmall("modal"), mProps(std::move(props)) {
if (!mProps.variant.empty()) {
mRoot->SetClass(mProps.variant, true);
}
auto* header = append(mDialog, "div");
header->SetClass("modal-header", true);
auto* header = append(mDialog, "modal-header");
auto* title = append(header, "div");
title->SetClass("modal-title", true);
title->SetInnerRML(mProps.title);
auto* title = append(header, "modal-title");
append_text(title, mProps.title);
if (!mProps.icon.empty()) {
auto* icon = append(header, "icon");
icon->SetClass(mProps.icon, true);
}
auto* body = append(mDialog, "div");
body->SetClass("modal-body", true);
body->SetInnerRML(mProps.bodyRml);
auto* body = append(mDialog, "modal-body");
if (mProps.bodyText) {
append_text(body, *mProps.bodyText);
} else {
body->SetInnerRML(mProps.bodyRml);
}
mContentRoot = append(mDialog, "div");
mContentRoot->SetClass("modal-content", true);
mContentRoot = append(mDialog, "modal-content");
auto* actions = append(mDialog, "div");
actions->SetClass("modal-actions", true);
auto* actions = append(mDialog, "modal-actions");
if (props.isVertical) {
actions->SetClass("modal-actions-vertical", true);
actions->SetClass("vertical", true);
}
for (auto& action : mProps.actions) {
@@ -64,7 +63,7 @@ Pane& Modal::content_pane() {
}
void Modal::add_action(ModalAction action) {
auto* actions = mDialog->QuerySelector(".modal-actions");
auto* actions = mDialog->QuerySelector("modal-actions");
auto btn =
std::make_unique<ControlledButton>(actions, ControlledButton::Props{
.text = std::move(action.label),
@@ -85,7 +84,11 @@ void Modal::add_action(ModalAction action) {
}
void Modal::set_body(const Rml::String& bodyRml) {
mDialog->QuerySelector(".modal-body")->SetInnerRML(bodyRml);
mDialog->QuerySelector("modal-body")->SetInnerRML(bodyRml);
}
void Modal::set_body_text(const Rml::String& bodyText) {
set_text_content(mDialog->QuerySelector("modal-body"), bodyText);
}
void Modal::set_icon(const Rml::String& icon) {
@@ -98,7 +101,7 @@ void Modal::set_icon(const Rml::String& icon) {
}
if (iconElem == nullptr) {
// The constructor only creates the icon element when Props.icon is set.
iconElem = append(mDialog->QuerySelector(".modal-header"), "icon");
iconElem = append(mDialog->QuerySelector("modal-header"), "icon");
}
iconElem->SetClassNames(icon);
}
+4
View File
@@ -4,6 +4,8 @@
#include "pane.hpp"
#include "window.hpp"
#include <optional>
namespace dusk::ui {
class Modal;
@@ -17,6 +19,7 @@ class Modal : public WindowSmall {
public:
struct Props {
Rml::String title;
std::optional<Rml::String> bodyText;
Rml::String bodyRml;
std::vector<ModalAction> actions;
std::function<void(Modal&)> onDismiss;
@@ -32,6 +35,7 @@ public:
Pane& content_pane();
void set_body(const Rml::String& bodyRml);
void set_body_text(const Rml::String& bodyText);
void set_icon(const Rml::String& icon);
protected:
+94 -48
View File
@@ -4,10 +4,13 @@
#include "dusk/mods/svc/ui.hpp"
#include "fmt/format.h"
#include "logs_window.hpp"
#include "mod_browser.hpp"
#include "mod_texture_provider.hpp"
#include "mods/svc/http.h"
#include "pane.hpp"
#include <borealis/http.hpp>
#include "Z2AudioLib/Z2SeMgr.h"
#include "m_Do/m_Do_audio.h"
@@ -64,28 +67,32 @@ class ModListEntry : public FluentComponent<ModListEntry> {
public:
ModListEntry(Rml::Element* parent, const mods::LoadedMod& mod)
: FluentComponent{append(parent, "mod-entry")} {
Rml::String iconRml;
Rml::Element* icon = nullptr;
if (!mod.metadata.iconPath.empty()) {
iconRml = fmt::format(R"(<img class="mod-icon" src="{}"/>)",
mod_image_source(mod, mod.metadata.iconPath));
icon = append(mRoot, "img");
icon->SetAttribute("src", mod_image_source(mod, mod.metadata.iconPath));
} else {
iconRml = R"(<icon class="mod-icon placeholder"/>)";
icon = append(mRoot, "icon");
icon->SetClass("placeholder", true);
}
icon->SetClass("mod-icon", true);
const auto status = mod_status(mod);
const auto networkBadge = mod_uses_network(mod) ?
R"(<span class="mod-entry-network">Network</span>)" :
Rml::String{};
mRoot->SetInnerRML(fmt::format(
R"({})"
R"(<div class="mod-entry-info">)"
R"(<div class="mod-entry-name"><span class="mod-entry-name-text">{}</span>)"
R"(<span class="mod-entry-version">v{}</span></div>)"
R"(<div class="mod-entry-sub">{} - <span class="mod-entry-status {}">{}</span>{}</div>)"
R"(<div class="mod-entry-desc">{}</div>)"
R"(</div>)",
iconRml, escape(mod.metadata.name), escape(mod.metadata.version),
escape(mod.metadata.author), status.badgeClass, status.text, networkBadge,
escape(snippet(mod.metadata.description, 90))));
auto* info = append(mRoot, "mod-entry-info");
auto* name = append(info, "mod-entry-name");
append_text(append(name, "mod-entry-name-text"), mod.metadata.name);
append_text(append(name, "mod-entry-version"), "v" + mod.metadata.version);
auto* sub = append(info, "mod-entry-sub");
append_text(sub, mod.metadata.author + " - ");
auto* statusElement = append(sub, "mod-entry-status");
if (status.badgeClass[0] != '\0') {
statusElement->SetClass(status.badgeClass, true);
}
append_text(statusElement, status.text);
if (mod_uses_network(mod)) {
append_text(append(sub, "mod-entry-network"), "Network");
}
append_text(append(info, "mod-entry-desc"), snippet(mod.metadata.description, 90));
mRoot->SetClass("inactive", !mod.active);
mRoot->SetClass("failed", mod.loadFailed);
@@ -111,8 +118,7 @@ public:
mod_image_source(mod, mod.metadata.bannerPath)));
}
auto* actions = append(mRoot, "div");
actions->SetClass("mod-actions", true);
auto* actions = append(mRoot, "mod-actions");
const std::string modId = mod.metadata.id;
if (mod_enabled(mod)) {
if (!mod.inPlace) {
@@ -196,14 +202,33 @@ ModsWindow::ModsWindow() : Window{Props{.tabBar = false, .styleSheets = {"res/rm
void ModsWindow::build_content(Rml::Element* content) {
mEntries.clear();
mEntryMods.clear();
mBrowserEntry = nullptr;
auto& listPane = add_child<Pane>(content, Pane::Type::Controlled);
listPane.root()->SetClass("mod-list", true);
auto& detailPane = add_child<Pane>(content, Pane::Type::Uncontrolled);
detailPane.root()->SetClass("mod-detail", true);
if (borealis::http::available()) {
auto& browse = listPane.add_button("Browse online mods");
mBrowserEntry = &browse;
browse.root()->SetClass("mod-browser-entry", true);
browse.on_pressed([this] { push(std::make_unique<ModBrowser>()); });
listPane.register_control(browse, detailPane, [this](Pane& pane) {
mBrowserSelected = true;
mSelectedMod = nullptr;
build_browser_detail(pane);
mark_current_entry();
});
}
if (mods::ModLoader::instance().mods().empty()) {
listPane.add_text("No mods installed.");
if (borealis::http::available()) {
mBrowserSelected = true;
build_browser_detail(detailPane);
}
mark_current_entry();
return;
}
@@ -212,6 +237,7 @@ void ModsWindow::build_content(Rml::Element* content) {
mEntries.push_back(&entry);
mEntryMods.push_back(&trackedMod);
listPane.register_control(entry, detailPane, [this, tracked = &trackedMod](Pane& pane) {
mBrowserSelected = false;
mSelectedMod = tracked;
pane.clear();
build_detail(pane, *tracked);
@@ -219,39 +245,53 @@ void ModsWindow::build_content(Rml::Element* content) {
});
}
if (mSelectedMod == nullptr) {
if (mBrowserSelected && mBrowserEntry != nullptr) {
mSelectedMod = nullptr;
build_browser_detail(detailPane);
} else if (mSelectedMod == nullptr) {
mSelectedMod = mEntryMods.front();
build_detail(detailPane, *mSelectedMod);
} else {
build_detail(detailPane, *mSelectedMod);
}
build_detail(detailPane, *mSelectedMod);
mark_current_entry();
}
void ModsWindow::build_browser_detail(Pane& pane) {
pane.root()->RemoveAttribute("mod-id");
append_text(append(pane.root(), "mod-title"), "Browse Mods");
pane.add_text("Find published Dusklight mods in the online catalog.");
}
void ModsWindow::build_detail(Pane& pane, mods::LoadedMod& mod) {
pane.root()->SetAttribute("mod-id", mod.metadata.id);
pane.add_child<ModDetailHeader>(
mod, [this, id = mod.metadata.id] { push(std::make_unique<LogsWindow>(id)); });
Rml::String statusBadge;
auto* title = append(pane.root(), "mod-title");
append_text(title, mod.metadata.name + " ");
append_text(append(title, "mod-title-version"), "v" + mod.metadata.version);
if (mod.loadFailed || mod.suspendedByProvider) {
const auto status = mod_status(mod);
statusBadge = fmt::format(
R"(&nbsp;<span class="status-badge {}">{}</span>)", status.badgeClass, status.text);
append_text(title, "\u00a0");
auto* badge = append(title, "status-badge");
badge->SetClass(status.badgeClass, true);
append_text(badge, status.text);
}
if (mod_uses_network(mod)) {
statusBadge += R"(&nbsp;<span class="status-badge network">Network</span>)";
append_text(title, "\u00a0");
auto* badge = append(title, "status-badge");
badge->SetClass("network", true);
append_text(badge, "Network");
}
pane.add_rml(fmt::format(R"(<div class="mod-title">{} )"
R"(<span class="mod-title-version">v{}</span>{}</div>)"
R"(<div class="mod-author">by {}</div>)",
escape(mod.metadata.name), escape(mod.metadata.version), statusBadge,
escape(mod.metadata.author)));
append_text(append(pane.root(), "mod-author"), "by " + mod.metadata.author);
if (mod.loadFailed && !mod.failureReason.empty()) {
pane.add_rml(fmt::format(R"(<div class="mod-info-row">)"
R"(<span class="mod-info-label failed">Reason</span>)"
R"(<span class="mod-info-value">{}</span>)"
R"(</div>)",
escape(mod.failureReason)));
auto* row = append(pane.root(), "mod-info-row");
auto* label = append(row, "mod-info-label");
label->SetClass("failed", true);
append_text(label, "Reason");
append_text(append(row, "mod-info-value"), mod.failureReason);
} else if (mod.suspendedByProvider) {
std::string providers;
for (const auto& edge : mod.dependencies) {
@@ -262,11 +302,9 @@ void ModsWindow::build_detail(Pane& pane, mods::LoadedMod& mod) {
providers += edge.mod->metadata.name;
}
}
pane.add_rml(fmt::format(R"(<div class="mod-info-row">)"
R"(<span class="mod-info-label">Waiting on</span>)"
R"(<span class="mod-info-value">{}</span>)"
R"(</div>)",
escape(providers)));
auto* row = append(pane.root(), "mod-info-row");
append_text(append(row, "mod-info-label"), "Waiting on");
append_text(append(row, "mod-info-value"), providers);
}
std::string activeDependents;
@@ -279,12 +317,13 @@ void ModsWindow::build_detail(Pane& pane, mods::LoadedMod& mod) {
}
}
if (mod.active && !activeDependents.empty()) {
pane.add_rml(fmt::format(R"(<div class="mod-restart-note">{}</div>)",
escape(fmt::format("Disabling or reloading also restarts: {}", activeDependents))));
append_text(append(pane.root(), "mod-restart-note"),
fmt::format("Disabling or reloading also restarts: {}", activeDependents));
}
if (!mod.metadata.description.empty()) {
pane.add_text(mod.metadata.description)->SetClass("mod-description", true);
auto* description = append(pane.root(), "mod-description");
append_text(description, mod.metadata.description);
}
if (mod.active) {
@@ -293,6 +332,9 @@ void ModsWindow::build_detail(Pane& pane, mods::LoadedMod& mod) {
}
void ModsWindow::mark_current_entry() {
if (mBrowserEntry != nullptr) {
mBrowserEntry->root()->SetClass("current", mBrowserSelected);
}
for (size_t i = 0; i < mEntries.size(); ++i) {
mEntries[i]->root()->SetClass("current", mEntryMods[i] == mSelectedMod);
}
@@ -325,10 +367,14 @@ void ModsWindow::update() {
}
rebuild_content();
if (hadContentFocus) {
for (size_t i = 0; i < mEntryMods.size(); ++i) {
if (mEntryMods[i] == mSelectedMod) {
mEntries[i]->focus();
break;
if (mBrowserSelected && mBrowserEntry != nullptr) {
mBrowserEntry->focus();
} else {
for (size_t i = 0; i < mEntryMods.size(); ++i) {
if (mEntryMods[i] == mSelectedMod) {
mEntries[i]->focus();
break;
}
}
}
}
+3
View File
@@ -26,13 +26,16 @@ private:
};
void build_content(Rml::Element* content);
void build_browser_detail(Pane& pane);
void build_detail(Pane& pane, mods::LoadedMod& mod);
void mark_current_entry();
std::vector<ModSnapshot> mSnapshot;
std::vector<Component*> mEntries;
std::vector<mods::LoadedMod*> mEntryMods;
Component* mBrowserEntry = nullptr;
mods::LoadedMod* mSelectedMod = nullptr;
bool mBrowserSelected = false;
};
} // namespace dusk::ui
+30 -26
View File
@@ -29,6 +29,7 @@ constexpr borealis::Log Log{"dusk::ui::overlay"};
const Rml::String kDocumentSource = R"RML(
<rml>
<head>
<link type="text/rcss" href="res/rml/theme.rcss" />
<link type="text/rcss" href="res/rml/overlay.rcss" />
</head>
<body>
@@ -82,8 +83,7 @@ Rml::Element* create_toast(Rml::Element* parent, const Toast& toast) {
if (toast.title.starts_with("<")) {
heading->SetInnerRML(toast.title);
} else {
auto* span = append(heading, "span");
span->SetInnerRML(toast.title);
append_text(append(heading, "toast-title"), toast.title);
}
if (toast.type == "achievement") {
auto* icon = append(heading, "icon");
@@ -102,8 +102,7 @@ Rml::Element* create_toast(Rml::Element* parent, const Toast& toast) {
if (toast.content.starts_with("<")) {
message->SetInnerRML(toast.content);
} else {
auto* span = append(message, "span");
span->SetInnerRML(toast.content);
append_text(append(message, "toast-message-text"), toast.content);
}
}
{
@@ -118,14 +117,15 @@ Rml::Element* create_controller_warning(Rml::Element* parent) {
elem->SetClass("controller-warning", true);
auto* heading = append(elem, "heading");
auto* title = append(heading, "span");
title->SetInnerRML("No Device Assigned");
append_text(append(heading, "toast-title"), "No Device Assigned");
auto* icon = append(heading, "icon");
icon->SetClass("warning", true);
auto* message = append(elem, "message");
auto* content = append(message, "span");
content->SetInnerRML("Configure <b>Port 1</b> in Settings.");
auto* content = append(message, "toast-message-text");
append_text(content, "Configure ");
append_text(append(content, "b"), "Port 1");
append_text(content, " in Settings.");
return elem;
}
@@ -160,12 +160,6 @@ Rml::String back_button_name() {
return "Back";
}
#if defined(TARGET_ANDROID) || (defined(__APPLE__) && TARGET_OS_IOS && !TARGET_OS_MACCATALYST)
constexpr auto kMenuNotificationPrefix = "3-finger tap or";
#else
constexpr auto kMenuNotificationPrefix = "Press <b>F1</b> or";
#endif
Rml::Element* create_menu_notification(Rml::Element* parent) {
auto* elem = append(parent, "toast");
elem->SetClass("menu-notification", true);
@@ -182,11 +176,18 @@ Rml::Element* create_menu_notification(Rml::Element* parent) {
auto* message = append(elem, "message");
auto* row = append(message, "row");
append(row, "span")->SetInnerRML(kMenuNotificationPrefix);
auto* prefix = append(row, "notification-prefix");
#if defined(TARGET_ANDROID) || (defined(__APPLE__) && TARGET_OS_IOS && !TARGET_OS_MACCATALYST)
append_text(prefix, "3-finger tap or");
#else
append_text(prefix, "Press ");
append_text(append(prefix, "b"), "F1");
append_text(prefix, " or");
#endif
auto* icon = append(row, "icon");
icon->SetClass("controller", true);
append(row, "span")->SetInnerRML("<b>" + escape(padButton) + "</b>");
append(row, "span")->SetInnerRML("to open menu");
append_text(append(append(row, "notification-button"), "b"), padButton);
append_text(append(row, "notification-action"), "to open menu");
return elem;
}
@@ -215,7 +216,8 @@ static std::string FormatElapsedTime(OSTime ticksElapsed) {
const seconds sec = duration_cast<seconds>(ms);
ms -= sec;
return fmt::format("{0:02}:{1:02}:{2:02}.{3:03}", hr.count(), min.count(), sec.count(), ms.count());
return fmt::format(
"{0:02}:{1:02}:{2:02}.{3:03}", hr.count(), min.count(), sec.count(), ms.count());
}
Overlay::Overlay() : Document(kDocumentSource, true, DocumentScope::Overlay) {
@@ -290,7 +292,7 @@ void Overlay::update() {
static_cast<double>(now - mFpsLastUpdate) >= 0.5 * static_cast<double>(perfFreq);
if (refreshLabel) {
mFpsLastUpdate = now;
mFpsCounter->SetInnerRML(escape(fmt::format("{:.0f} FPS", fps)));
set_text_content(mFpsCounter, fmt::format("{:.0f} FPS", fps));
}
} else {
mFpsCounter->RemoveAttribute("open");
@@ -338,21 +340,23 @@ void Overlay::update() {
}
if (speedrun::g_speedrunInfo.m_isRunStarted && !speedrun::g_speedrunInfo.m_isPauseIGT) {
speedrun::g_speedrunInfo.m_igtTimer = OSGetTime() - speedrun::g_speedrunInfo.m_igtStartTimestamp -
speedrun::g_speedrunInfo.m_totalLoadTime;
speedrun::g_speedrunInfo.m_igtTimer = OSGetTime() -
speedrun::g_speedrunInfo.m_igtStartTimestamp -
speedrun::g_speedrunInfo.m_totalLoadTime;
}
mSpeedrunTimer->SetAttribute("open", "");
if (getSettings().game.showSpeedrunRTATimer) {
mSpeedrunRta->SetAttribute("open", "");
mSpeedrunRta->SetInnerRML(escape(fmt::format("RTA {}", FormatElapsedTime(rtaElapsedTime))));
set_text_content(
mSpeedrunRta, fmt::format("RTA {}", FormatElapsedTime(rtaElapsedTime)));
} else {
mSpeedrunRta->RemoveAttribute("open");
}
mSpeedrunIgt->SetInnerRML(
escape(fmt::format("IGT {}", FormatElapsedTime(speedrun::g_speedrunInfo.m_igtTimer))));
set_text_content(mSpeedrunIgt,
fmt::format("IGT {}", FormatElapsedTime(speedrun::g_speedrunInfo.m_igtTimer)));
} else {
mSpeedrunTimer->RemoveAttribute("open");
}
@@ -470,8 +474,8 @@ void Overlay::update_pipeline_progress() {
if (queuedPipelines != mLastQueuedPipelines) {
mLastQueuedPipelines = queuedPipelines;
const auto noun = queuedPipelines == 1 ? "pipeline" : "pipelines";
mPipelineProgressLabel->SetInnerRML(
escape(fmt::format("Building {} {}", queuedPipelines, noun)));
set_text_content(
mPipelineProgressLabel, fmt::format("Building {} {}", queuedPipelines, noun));
}
mPipelineProgressBar->SetAttribute("value", progress);
+1 -2
View File
@@ -168,8 +168,7 @@ bool Pane::focus_last() {
}
Rml::Element* Pane::add_section(const Rml::String& text) {
auto* elem = append(mRoot, "div");
elem->SetClass("section-heading", true);
auto* elem = append(mRoot, "section-heading");
append_text(elem, text);
return elem;
}
+1
View File
@@ -8,6 +8,7 @@ namespace {
const Rml::String kDocumentSource = R"RML(
<rml>
<head>
<link type="text/rcss" href="res/rml/theme.rcss" />
<link type="text/rcss" href="res/rml/popover.rcss" />
</head>
<body>
+47 -53
View File
@@ -43,35 +43,36 @@ PrelaunchState sPrelaunchState;
const Rml::String kDocumentSource = R"RML(
<rml>
<head>
<link type="text/rcss" href="res/rml/theme.rcss" />
<link type="text/rcss" href="res/rml/prelaunch.rcss" />
</head>
<body>
<div class="gradient" />
<div class="background" />
<prelaunch-gradient />
<prelaunch-background />
<content id="root" open>
<menu>
<hero class="intro-item delay-0">
<eyebrow><span>Twilit Realm</span> presents</eyebrow>
<eyebrow><studio-name>Twilit Realm</studio-name> presents</eyebrow>
<img src="res/logo.png" />
</hero>
<div id="menu-list" />
<menu-list id="menu-list" />
</menu>
<disc-info class="intro-item delay-5">
<div id="disc-status">
<disc-status id="disc-status">
<icon />
<span id="disc-status-label" />
</div>
<span id="disc-version" class="detail" />
<disc-status-label id="disc-status-label" />
</disc-status>
<disc-version id="disc-version" />
</disc-info>
<version-info class="intro-item delay-6">
<div class="version">Version <span id="version-text"></span></div>
<div id="update-status" class="update">
<span id="update-message"></span>
<version-label>Version <version-number id="version-text"></version-number></version-label>
<update-status id="update-status">
<update-message id="update-message"></update-message>
<button id="update-download">
<span id="update-download-label"></span>
<update-download-label id="update-download-label"></update-download-label>
&nbsp;<icon />
</button>
</div>
</update-status>
</version-info>
</content>
</body>
@@ -329,7 +330,7 @@ void apply_disc_verification_result(const DiscVerificationResult& result) {
state.pendingDiscPath = result.path;
state.pendingDiscInfo = result.info;
state.pendingDiscValidation = result.validation;
state.errorString = escape(get_error_msg(result.validation));
state.errorString = get_error_msg(result.validation);
return;
}
@@ -345,41 +346,34 @@ void apply_disc_verification_result(const DiscVerificationResult& result) {
state.pendingDiscPath.clear();
state.pendingDiscInfo = {};
state.pendingDiscValidation = iso::ValidationError::Unknown;
state.errorString = escape(get_error_msg(result.validation));
state.errorString = get_error_msg(result.validation);
}
class DiscVerificationModal : public WindowSmall {
public:
DiscVerificationModal() : WindowSmall("modal", "modal-dialog") {
auto* header = append(mDialog, "div");
header->SetClass("modal-header", true);
DiscVerificationModal() : WindowSmall("modal") {
auto* header = append(mDialog, "modal-header");
auto* title = append(header, "div");
title->SetClass("modal-title", true);
title->SetInnerRML("Verifying disc image");
auto* title = append(header, "modal-title");
append_text(title, "Verifying disc image");
auto* icon = append(header, "icon");
icon->SetClass("verifying", true);
auto* body = append(mDialog, "div");
body->SetClass("modal-body", true);
auto* body = append(mDialog, "modal-body");
auto* content = append(body, "div");
content->SetClass("verification-progress", true);
auto* content = append(body, "verification-progress");
mFileName = append(content, "div");
mFileName->SetClass("verification-file", true);
mFileName = append(content, "verification-file");
mProgress = append(content, "progress");
mProgress->SetClass("progress-ongoing", true);
mProgress->SetClass("verification-progress-bar", true);
mProgress->SetAttribute("value", 0.f);
mDetail = append(content, "div");
mDetail->SetClass("verification-detail", true);
mDetail = append(content, "verification-detail");
auto* actions = append(mDialog, "div");
actions->SetClass("modal-actions", true);
auto* actions = append(mDialog, "modal-actions");
mCancelButton = std::make_unique<Button>(actions, "Cancel");
mCancelButton->root()->SetClass("modal-btn", true);
mCancelButton->on_pressed([this] { request_cancel(); });
@@ -448,7 +442,7 @@ private:
if (fileName.empty()) {
fileName = sDiscVerificationTask->path;
}
mFileName->SetInnerRML(escape(fileName));
set_text_content(mFileName, fileName);
}
const std::size_t bytesRead =
@@ -461,7 +455,7 @@ private:
mProgress->SetAttribute("value", 0.f);
}
if (mDetail != nullptr) {
mDetail->SetInnerRML("Opening disc image...");
set_text_content(mDetail, "Opening disc image...");
}
return;
}
@@ -472,8 +466,8 @@ private:
mProgress->SetAttribute("value", fraction);
}
if (mDetail != nullptr) {
mDetail->SetInnerRML(escape(fmt::format("{} / {} ({:.0f}%)", format_bytes(bytesRead),
format_bytes(bytesTotal), fraction * 100.0f)));
set_text_content(mDetail, fmt::format("{} / {} ({:.0f}%)", format_bytes(bytesRead),
format_bytes(bytesTotal), fraction * 100.0f));
}
}
@@ -598,7 +592,7 @@ private:
}
mText = text;
if (direction == 0) {
mLabels[mActiveLabel]->SetInnerRML(escape(text));
set_text_content(mLabels[mActiveLabel], text);
mLabels[mActiveLabel]->SetProperty(
Rml::PropertyId::Left, Rml::Property{0.0f, Rml::Unit::PERCENT});
mLabels[mActiveLabel]->SetClass("active", true);
@@ -611,7 +605,7 @@ private:
auto* outgoing = mLabels[mActiveLabel];
mActiveLabel = 1 - mActiveLabel;
auto* incoming = mLabels[mActiveLabel];
incoming->SetInnerRML(escape(text));
set_text_content(incoming, text);
const Rml::Property incomingOffset{
static_cast<float>(direction) * kSlideDistance, Rml::Unit::PERCENT};
@@ -709,7 +703,7 @@ void try_push_verification_modal(Document& host) {
if (!state.pendingDiscPath.empty()) {
const Rml::String bodyRml =
state.errorString + "<br/><br/>You may proceed at your own risk.";
escape(state.errorString) + "<br/><br/>You may proceed at your own risk.";
auto acceptHashMismatch = [](Modal& modal) {
auto& st = prelaunch_state();
std::string path = std::move(st.pendingDiscPath);
@@ -746,7 +740,7 @@ void try_push_verification_modal(Document& host) {
host.push(std::make_unique<Modal>(Modal::Props{
.title = "Disc verification error",
.bodyRml = state.errorString,
.bodyText = state.errorString,
.actions =
{
ModalAction{
@@ -1055,22 +1049,22 @@ void Prelaunch::update() {
if (mDiscStatus != nullptr && discStatusLabel != nullptr) {
if (!activeDiscLoaded) {
mDiscStatus->RemoveAttribute("status");
discStatusLabel->SetInnerRML("No disc image found.");
set_text_content(discStatusLabel, "No disc image found.");
} else if (discRestartPending) {
mDiscStatus->SetAttribute("status", "pending");
discStatusLabel->SetInnerRML("Pending restart.");
set_text_content(discStatusLabel, "Pending restart.");
} else if (state.configuredDiscValidation == iso::ValidationError::Success) {
mDiscStatus->SetAttribute("status", "good");
discStatusLabel->SetInnerRML("Disc ready.");
set_text_content(discStatusLabel, "Disc ready.");
} else if (state.configuredDiscValidation == iso::ValidationError::HashMismatch) {
mDiscStatus->SetAttribute("status", "mismatch");
discStatusLabel->SetInnerRML("Disc hash mismatch.");
set_text_content(discStatusLabel, "Disc hash mismatch.");
} else if (canLaunchConfiguredDisc) {
mDiscStatus->SetAttribute("status", "unknown");
discStatusLabel->SetInnerRML("Disc not verified.");
set_text_content(discStatusLabel, "Disc not verified.");
} else {
mDiscStatus->SetAttribute("status", "bad");
discStatusLabel->SetInnerRML("Disc unavailable.");
set_text_content(discStatusLabel, "Disc unavailable.");
}
}
if (mDiscDetail != nullptr) {
@@ -1112,7 +1106,7 @@ void Prelaunch::update() {
innerRML += "Unknown";
break;
}
mDiscDetail->SetInnerRML(innerRML);
set_text_content(mDiscDetail, innerRML);
} else {
mDiscDetail->SetProperty(Rml::PropertyId::Display, Rml::Style::Display::None);
}
@@ -1122,7 +1116,7 @@ void Prelaunch::update() {
if (versionStr[0] == 'v') {
versionStr = versionStr.substr(1);
}
mVersion->SetInnerRML(escape(versionStr));
set_text_content(mVersion, Rml::String{versionStr});
}
if (mUpdateStatus != nullptr && mUpdateMessage != nullptr) {
if (auto result = take_finished_update_check()) {
@@ -1134,22 +1128,22 @@ void Prelaunch::update() {
if (sUpdateCheck) {
mUpdateStatus->SetAttribute("state", "checking");
mUpdateMessage->SetInnerRML("Checking for updates...");
set_text_content(mUpdateMessage, "Checking for updates...");
} else if (!sUpdateCheckResult.has_value() ||
sUpdateCheckResult->status == borealis::update::Status::UpToDate)
{
mUpdateStatus->RemoveAttribute("state");
mUpdateMessage->SetInnerRML("");
set_text_content(mUpdateMessage, "");
} else if (sUpdateCheckResult->status == borealis::update::Status::UpdateAvailable) {
mUpdateStatus->SetAttribute("state", "available");
mUpdateMessage->SetInnerRML("Update available!");
set_text_content(mUpdateMessage, "Update available!");
if (mUpdateDownloadLabel != nullptr) {
mUpdateDownloadLabel->SetInnerRML(escape(
fmt::format("Download {}", update_release_label(sUpdateCheckResult->latest))));
set_text_content(mUpdateDownloadLabel,
fmt::format("Download {}", update_release_label(sUpdateCheckResult->latest)));
}
} else {
mUpdateStatus->SetAttribute("state", "failed");
mUpdateMessage->SetInnerRML("Failed to check for updates");
set_text_content(mUpdateMessage, "Failed to check for updates");
}
}
+22 -26
View File
@@ -59,26 +59,20 @@ void applyPresetDusk() {
} // namespace
PresetWindow::PresetWindow() : WindowSmall("modal", "modal-dialog") {
mDialog->SetClass("modal-dialog", true);
PresetWindow::PresetWindow() : WindowSmall("modal") {
auto* header = append(mDialog, "modal-header");
auto* header = append(mDialog, "div");
header->SetClass("modal-header", true);
auto* title = append(header, "div");
title->SetClass("modal-title", true);
title->SetInnerRML("Welcome to Dusklight");
auto* title = append(header, "modal-title");
append_text(title, "Welcome to Dusklight");
auto* headIcon = append(header, "icon");
headIcon->SetClass("celebration", true);
auto* intro = append(mDialog, "div");
intro->SetClass("modal-body", true);
intro->SetInnerRML(
auto* intro = append(mDialog, "modal-body");
append_text(intro,
"Choose a preset to get started. You can change any setting later from the Settings menu.");
auto* grid = append(mDialog, "div");
grid->SetClass("preset-grid", true);
auto* grid = append(mDialog, "preset-grid");
struct PresetInfo {
const char* name;
@@ -87,19 +81,22 @@ PresetWindow::PresetWindow() : WindowSmall("modal", "modal-dialog") {
};
static constexpr PresetInfo kPresets[] = {
{"Classic",
"Enhancements disabled to match the GameCube version. "
"Good for speedrunning or simple nostalgia!",
applyPresetClassic},
{"Dusklight",
"Graphics & quality of life tweaks, including some from the Wii U version. "
"Our recommended way to play!",
applyPresetDusk},
{
"Classic",
"Enhancements disabled to match the GameCube version. "
"Good for speedrunning or simple nostalgia!",
applyPresetClassic,
},
{
"Dusklight",
"Graphics & quality of life tweaks, including some from the Wii U version. "
"Our recommended way to play!",
applyPresetDusk,
},
};
for (const auto& preset : kPresets) {
auto* col = append(grid, "div");
col->SetClass("preset-col", true);
auto* col = append(grid, "preset-option");
auto btn = std::make_unique<Button>(col, Rml::String(preset.name));
btn->on_nav_command([this, apply = preset.apply](Rml::Event&, NavCommand cmd) {
@@ -115,9 +112,8 @@ PresetWindow::PresetWindow() : WindowSmall("modal", "modal-dialog") {
});
mButtons.push_back(std::move(btn));
auto* desc = append(col, "div");
desc->SetClass("preset-desc", true);
desc->SetInnerRML(preset.desc);
auto* desc = append(col, "preset-description");
append_text(desc, preset.desc);
}
}
+243
View File
@@ -0,0 +1,243 @@
#include "remote_texture_provider.hpp"
#ifdef AURORA_ENABLE_RMLUI
#include "dusk/app_info.hpp"
#include "runtime_image.hpp"
#include <RmlUi/Core.h>
#include <aurora/rmlui.hpp>
#include <borealis/http.hpp>
#include <borealis/log.hpp>
#include <algorithm>
#include <array>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <optional>
#include <ranges>
#include <span>
#include <string>
#include <string_view>
#include <unordered_map>
namespace dusk::ui {
namespace {
using namespace std::chrono_literals;
constexpr borealis::Log Log{"dusk::ui"};
constexpr std::string_view kScheme = "https";
constexpr std::string_view kAllowedPrefix = "https://staging.twilitrealm.workers.dev/images/v1/";
constexpr std::size_t kMaxCachedImages = 64;
constexpr std::size_t kMaxImageFileSize = 16 * 1024 * 1024;
// RmlUi caches the first texture dimensions in image decorators, so the async
// placeholder must preserve the final image's aspect ratio.
constexpr std::uint32_t kPlaceholderMaxDimension = 256;
constexpr std::array<std::byte, kPlaceholderMaxDimension * kPlaceholderMaxDimension * 4>
kTransparentPixels{};
enum class State {
Unrequested,
Pending,
Ready,
Failed,
};
struct Entry {
borealis::Task<borealis::http::Result> request;
DecodedImage image;
State state = State::Unrequested;
std::uint64_t lastUsed = 0;
std::uint32_t placeholderWidth = 1;
std::uint32_t placeholderHeight = 1;
};
std::unordered_map<std::string, Entry>& image_cache() {
static auto* cache = new std::unordered_map<std::string, Entry>();
return *cache;
}
std::uint64_t& use_counter() {
static auto* counter = new std::uint64_t{};
return *counter;
}
bool make_cache_room() {
auto& cache = image_cache();
if (cache.size() < kMaxCachedImages) {
return true;
}
const auto victim = std::ranges::min_element(cache, {}, [](const auto& pair) {
return pair.second.state == State::Pending ? std::numeric_limits<std::uint64_t>::max() :
pair.second.lastUsed;
});
if (victim == cache.end() || victim->second.state == State::Pending) {
return false;
}
cache.erase(victim);
return true;
}
aurora::rmlui::RuntimeTexture transparent_texture(const Entry& entry) {
const auto size = static_cast<std::size_t>(entry.placeholderWidth) *
static_cast<std::size_t>(entry.placeholderHeight) * 4;
return {
.width = entry.placeholderWidth,
.height = entry.placeholderHeight,
.rgba8 = std::span{kTransparentPixels}.first(size),
.premultipliedAlpha = true,
};
}
borealis::Task<borealis::http::Result> start_request(std::string source) {
return borealis::http::start({
.url = std::move(source),
.headers =
{
{.name = "User-Agent", .value = borealis::user_agent(dusk::AppInfo)},
{.name = "Accept", .value = "image/png"},
},
.connectTimeout = 10s,
.idleTimeout = 10s,
.totalTimeout = 30s,
.maxBodyBytes = kMaxImageFileSize,
});
}
std::optional<aurora::rmlui::RuntimeTexture> remote_texture_provider(std::string_view source) {
if (!source.starts_with(kAllowedPrefix)) {
return std::nullopt;
}
auto& cache = image_cache();
const std::string key{source};
auto iter = cache.find(key);
if (iter == cache.end()) {
if (!make_cache_room()) {
Log.warn("Remote image cache is full; skipping '{}'", source);
return transparent_texture(Entry{});
}
iter = cache.emplace(key, Entry{}).first;
}
if (iter->second.state == State::Unrequested) {
iter->second.request = start_request(key);
iter->second.state = State::Pending;
}
iter->second.lastUsed = ++use_counter();
if (iter->second.state != State::Ready) {
return transparent_texture(iter->second);
}
const auto& image = iter->second.image;
return aurora::rmlui::RuntimeTexture{
.width = image.width,
.height = image.height,
.rgba8 =
std::span{reinterpret_cast<const std::byte*>(image.pixels.data()), image.pixels.size()},
.premultipliedAlpha = true,
};
}
void finish_request(const std::string& source, Entry& entry, borealis::http::Result result) {
if (result.error != borealis::http::Error::None) {
entry.state = State::Failed;
Log.warn("Failed to fetch image '{}': {}", source, result.message);
return;
}
if (result.response.statusCode != 200) {
entry.state = State::Failed;
Log.warn("Image '{}' returned HTTP {}", source, result.response.statusCode);
return;
}
const auto* data = reinterpret_cast<const std::uint8_t*>(result.response.body.data());
auto image = decode_png(std::span{data, result.response.body.size()}, source);
if (!image) {
entry.state = State::Failed;
return;
}
entry.image = std::move(*image);
entry.state = State::Ready;
// RmlUi cached the transparent placeholder while the request was in flight.
Rml::ReleaseTexture(source);
}
} // namespace
void register_remote_texture_provider() noexcept {
aurora::rmlui::register_texture_provider(std::string{kScheme}, remote_texture_provider);
}
void unregister_remote_texture_provider() noexcept {
aurora::rmlui::unregister_texture_provider(kScheme);
image_cache().clear();
use_counter() = 0;
}
void update_remote_texture_provider() noexcept {
for (auto& [source, entry] : image_cache()) {
if (entry.state != State::Pending || !entry.request.ready()) {
continue;
}
try {
if (auto result = entry.request.try_take()) {
finish_request(source, entry, std::move(*result));
}
} catch (const std::exception& exception) {
entry.state = State::Failed;
Log.warn("Failed to fetch image '{}': {}", source, exception.what());
} catch (...) {
entry.state = State::Failed;
Log.warn("Failed to fetch image '{}'", source);
}
entry.request = {};
}
}
void set_remote_texture_dimensions(
std::string_view source, std::uint32_t width, std::uint32_t height) noexcept {
if (!source.starts_with(kAllowedPrefix) || width == 0 || height == 0) {
return;
}
auto& cache = image_cache();
auto iter = cache.find(std::string{source});
if (iter == cache.end()) {
if (!make_cache_room()) {
return;
}
iter = cache.emplace(std::string{source}, Entry{}).first;
}
const auto maxDimension = std::max(width, height);
if (maxDimension > kPlaceholderMaxDimension) {
width = std::max(1u,
static_cast<std::uint32_t>(
(static_cast<std::uint64_t>(width) * kPlaceholderMaxDimension + maxDimension / 2) /
maxDimension));
height = std::max(1u,
static_cast<std::uint32_t>(
(static_cast<std::uint64_t>(height) * kPlaceholderMaxDimension + maxDimension / 2) /
maxDimension));
}
iter->second.placeholderWidth = width;
iter->second.placeholderHeight = height;
}
} // namespace dusk::ui
#else
namespace dusk::ui {
void register_remote_texture_provider() noexcept {}
void unregister_remote_texture_provider() noexcept {}
void update_remote_texture_provider() noexcept {}
void set_remote_texture_dimensions(std::string_view, std::uint32_t, std::uint32_t) noexcept {}
} // namespace dusk::ui
#endif
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include <cstdint>
#include <string_view>
namespace dusk::ui {
void register_remote_texture_provider() noexcept;
void unregister_remote_texture_provider() noexcept;
void update_remote_texture_provider() noexcept;
void set_remote_texture_dimensions(
std::string_view source, std::uint32_t width, std::uint32_t height) noexcept;
} // namespace dusk::ui
+29 -25
View File
@@ -10,30 +10,36 @@
namespace dusk::ui {
CrashReportWindow::CrashReportWindow() : WindowSmall("modal", "modal-dialog") {
mDialog->SetClass("modal-dialog", true);
CrashReportWindow::CrashReportWindow() : WindowSmall("modal") {
auto* header = append(mDialog, "modal-header");
auto* header = append(mDialog, "div");
header->SetClass("modal-header", true);
auto* title = append(header, "div");
title->SetClass("modal-title", true);
title->SetInnerRML("Send Crash Reports");
auto* title = append(header, "modal-title");
append_text(title, "Send Crash Reports");
auto* headIcon = append(header, "icon");
headIcon->SetClass("question-mark", true);
auto* intro = append(mDialog, "div");
intro->SetClass("modal-body", true);
intro->SetInnerRML(
"Dusklight can automatically send crash reports to the developers. Crash reports contain the "
"following:"
"<br/>• Operating system version<br/>• CPU architecture<br/>• GPU model & driver version"
"<br/>• File paths (may include account username)<br/>• Stack trace<br/><br/>"
"This can be changed in the Settings menu at any time.");
auto* intro = append(mDialog, "modal-body");
append_text(intro,
"Dusklight can automatically send crash reports to the developers. Crash reports contain "
"the following:");
for (const char* item :
{
"• Operating system version",
"• CPU architecture",
"• GPU model & driver version",
"• File paths (may include account username)",
"• Stack trace",
})
{
append(intro, "br");
append_text(intro, item);
}
append(intro, "br");
append(intro, "br");
append_text(intro, "This can be changed in the Settings menu at any time.");
auto* grid = append(mDialog, "div");
grid->SetClass("preset-grid", true);
auto* grid = append(mDialog, "preset-grid");
struct OptionInfo {
const char* name;
@@ -43,8 +49,8 @@ CrashReportWindow::CrashReportWindow() : WindowSmall("modal", "modal-dialog") {
static constexpr OptionInfo kOptions[] = {
{"Enable",
"Send crash reports to Dusklight developers. Reports will include the information described "
"above.",
"Send crash reports to Dusklight developers. Reports will include the information "
"described above.",
[] { borealis::sentry::set_consent(true); }},
{"Disable",
"Do not send crash reports. This may make it more difficult to resolve issues you "
@@ -53,8 +59,7 @@ CrashReportWindow::CrashReportWindow() : WindowSmall("modal", "modal-dialog") {
};
for (const auto& option : kOptions) {
auto* col = append(grid, "div");
col->SetClass("preset-col", true);
auto* col = append(grid, "preset-option");
auto btn = std::make_unique<Button>(col, Rml::String(option.name));
btn->on_nav_command([this, apply = option.apply](Rml::Event&, NavCommand cmd) {
@@ -68,9 +73,8 @@ CrashReportWindow::CrashReportWindow() : WindowSmall("modal", "modal-dialog") {
});
mButtons.push_back(std::move(btn));
auto* desc = append(col, "div");
desc->SetClass("preset-desc", true);
desc->SetInnerRML(option.desc);
auto* desc = append(col, "preset-description");
append_text(desc, option.desc);
}
}
+73
View File
@@ -0,0 +1,73 @@
#include "runtime_image.hpp"
#include <SDL3/SDL_iostream.h>
#include <SDL3/SDL_surface.h>
#include <borealis/log.hpp>
#include <cstddef>
#include <cstring>
namespace dusk::ui {
namespace {
constexpr borealis::Log Log{"dusk::ui"};
constexpr std::uint32_t kMaxImageDimension = 4096;
} // namespace
std::optional<DecodedImage> decode_png(
std::span<const std::uint8_t> data, std::string_view source) {
SDL_IOStream* stream = SDL_IOFromConstMem(data.data(), data.size());
if (stream == nullptr) {
Log.warn("Failed to open image stream for '{}': {}", source, SDL_GetError());
return std::nullopt;
}
SDL_Surface* loadedSurface = SDL_LoadPNG_IO(stream, true);
if (loadedSurface == nullptr) {
Log.warn("Failed to decode image '{}': {}", source, SDL_GetError());
return std::nullopt;
}
SDL_Surface* rgbaSurface = SDL_ConvertSurface(loadedSurface, SDL_PIXELFORMAT_RGBA32);
SDL_DestroySurface(loadedSurface);
if (rgbaSurface == nullptr) {
Log.warn("Failed to convert image '{}': {}", source, SDL_GetError());
return std::nullopt;
}
const auto width = static_cast<std::uint32_t>(rgbaSurface->w);
const auto height = static_cast<std::uint32_t>(rgbaSurface->h);
if (width == 0 || height == 0 || width > kMaxImageDimension || height > kMaxImageDimension) {
Log.warn("Image '{}' has unsupported dimensions {}x{}", source, width, height);
SDL_DestroySurface(rgbaSurface);
return std::nullopt;
}
const std::size_t rowSize = static_cast<std::size_t>(width) * 4;
DecodedImage image{
.pixels = std::vector<std::uint8_t>(rowSize * height),
.width = width,
.height = height,
};
for (std::uint32_t row = 0; row < height; ++row) {
const auto* src =
static_cast<const std::uint8_t*>(rgbaSurface->pixels) +
static_cast<std::size_t>(row) * static_cast<std::size_t>(rgbaSurface->pitch);
auto* dst = image.pixels.data() + static_cast<std::size_t>(row) * rowSize;
std::memcpy(dst, src, rowSize);
for (std::size_t col = 0; col < rowSize; col += 4) {
const std::uint8_t alpha = dst[col + 3];
for (std::size_t channel = 0; channel < 3; ++channel) {
dst[col + channel] = static_cast<std::uint8_t>(
(static_cast<std::uint32_t>(dst[col + channel]) * alpha) / 255);
}
}
}
SDL_DestroySurface(rgbaSurface);
return image;
}
} // namespace dusk::ui
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include <cstdint>
#include <optional>
#include <span>
#include <string_view>
#include <vector>
namespace dusk::ui {
struct DecodedImage {
std::vector<std::uint8_t> pixels;
std::uint32_t width = 0;
std::uint32_t height = 0;
};
std::optional<DecodedImage> decode_png(std::span<const std::uint8_t> data, std::string_view source);
} // namespace dusk::ui
+7 -12
View File
@@ -2,7 +2,6 @@
#include "ui.hpp"
#include <fmt/format.h>
#include <utility>
namespace dusk::ui {
@@ -14,6 +13,10 @@ Rml::Element* createRoot(Rml::Element* parent) {
return parent->AppendChild(std::move(elem));
}
Rml::String value_label(const Rml::String& value, bool modified) {
return modified ? Rml::String{"\u00a0"} + value : value;
}
} // namespace
SelectButton::SelectButton(Rml::Element* parent, Props props)
@@ -32,22 +35,14 @@ bool SelectButton::modified() const {
void SelectButton::set_modified(bool value) {
if (mProps.modified != value) {
mValueElem->SetClass("modified", value);
if (value) {
mValueElem->SetInnerRML(fmt::format("•&nbsp;{}", escape(mProps.value)));
} else {
mValueElem->SetInnerRML(escape(mProps.value));
}
set_text_content(mValueElem, value_label(mProps.value, value));
mProps.modified = value;
}
}
void SelectButton::set_value_label(const Rml::String& value) {
if (mProps.value != value) {
if (mProps.modified) {
mValueElem->SetInnerRML(fmt::format("•&nbsp;{}", escape(value)));
} else {
mValueElem->SetInnerRML(escape(value));
}
set_text_content(mValueElem, value_label(value, mProps.modified));
mProps.value = value;
}
}
@@ -67,7 +62,7 @@ SelectButton& SelectButton::on_pressed(SelectButtonCallback callback) {
void SelectButton::update_props(Props props) {
if (mProps.key != props.key) {
mKeyElem->SetInnerRML(escape(props.key));
set_text_content(mKeyElem, props.key);
}
if (mProps.icon != props.icon) {
Rml::StringList iconClasses;
+43 -37
View File
@@ -1,5 +1,7 @@
#include "settings.hpp"
#include <borealis/file_select.hpp>
#include <borealis/io.hpp>
#include "aurora/gfx.h"
#include "bool_button.hpp"
#include "controller_config.hpp"
@@ -7,30 +9,27 @@
#include "dusk/audio/DuskAudioSystem.h"
#include "dusk/audio/DuskDsp.hpp"
#include "dusk/config.hpp"
#include "dusk/hotkeys.h"
#include "dusk/data.hpp"
#include "dusk/discord_presence.hpp"
#include "dusk/hotkeys.h"
#include "dusk/imgui/ImGuiEngine.hpp"
#include "dusk/io.hpp"
#include "dusk/language.hpp"
#include "dusk/presentation.hpp"
#include <borealis/io.hpp>
#include <borealis/file_select.hpp>
#include "dusk/livesplit.h"
#include "dusk/discord_presence.hpp"
#include "dusk/presentation.hpp"
#include "dusk/speedrun.h"
#include "graphics_tuner.hpp"
#include "m_Do/m_Do_main.h"
#include "menu_bar.hpp"
#include "modal.hpp"
#include "number_button.hpp"
#include "menu_bar.hpp"
#include "pane.hpp"
#include "prelaunch.hpp"
#include "touch_controls_editor.hpp"
#include "ui.hpp"
#include <aurora/lib/window.hpp>
#include <SDL3/SDL_filesystem.h>
#include <aurora/lib/window.hpp>
#include <fmt/format.h>
#if BOREALIS_HAS_SENTRY
@@ -44,7 +43,7 @@
#include <TargetConditionals.h>
#endif
#if defined(TARGET_ANDROID) || defined(__ANDROID__) || \
#if defined(TARGET_ANDROID) || defined(__ANDROID__) || \
(defined(__APPLE__) && TARGET_OS_IOS && !TARGET_OS_MACCATALYST)
#define TOUCH_CONTROLS_AVAILABLE true
#else
@@ -240,21 +239,26 @@ Rml::String configured_data_path_display_name() {
class DataFolderPathText : public Component {
public:
explicit DataFolderPathText(Rml::Element* parent) : Component(append(parent, "div")) {}
explicit DataFolderPathText(Rml::Element* parent)
: Component(append(parent, "data-folder-path")) {
auto* current = append(mRoot, "data-folder-current");
append_text(current, "Current data folder:");
append(current, "br");
mPath = append(current, "data-folder-value");
}
void update() override {
const Rml::String rml =
"<span class=\"data-folder-current\">Current data folder:<br/>" +
escape(data::abbreviated_path_string(data::configured_data_path())) + "</span>";
if (rml != mCurrentRml) {
mRoot->SetInnerRML(rml);
mCurrentRml = rml;
const Rml::String path = data::abbreviated_path_string(data::configured_data_path());
if (path != mCurrentPath) {
set_text_content(mPath, path);
mCurrentPath = path;
}
Component::update();
}
private:
Rml::String mCurrentRml;
Rml::Element* mPath = nullptr;
Rml::String mCurrentPath;
};
void show_data_folder_error_modal(std::string_view message) {
@@ -264,7 +268,7 @@ void show_data_folder_error_modal(std::string_view message) {
};
push_document(std::make_unique<Modal>(Modal::Props{
.title = "Data Folder Not Changed",
.bodyRml = escape(message),
.bodyText = Rml::String{message},
.actions =
{
ModalAction{
@@ -381,7 +385,7 @@ void add_speedrun_disabled_option(Pane& leftPane, Pane& rightPane, ConfigVar<boo
config_bool_select(leftPane, rightPane, var, {
.key = key,
.helpText = helpText,
.isDisabled = [] { return dusk::speedrun::isActive(); },
.isDisabled = [] { return speedrun::isActive(); },
});
}
@@ -694,7 +698,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) {
{
.key = "Pause on Focus Lost",
.helpText = "Pause the game when window focus is lost.",
.isDisabled = [] { return IsMobile || dusk::speedrun::isActive(); },
.isDisabled = [] { return IsMobile || speedrun::isActive(); },
});
leftPane.register_control(
leftPane.add_select_button({
@@ -753,11 +757,11 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) {
.helpText = "Save and restore the previous session's window size when opening Dusklight.",
.onChange =
[](bool value) {
if (value && !dusk::getSettings().video.enableFullscreen) {
if (value && !getSettings().video.enableFullscreen) {
const auto windowSize = aurora::window::get_window_size();
dusk::getSettings().video.lastWindowWidth.setValue(windowSize.width);
dusk::getSettings().video.lastWindowHeight.setValue(windowSize.height);
dusk::config::save();
getSettings().video.lastWindowWidth.setValue(windowSize.width);
getSettings().video.lastWindowHeight.setValue(windowSize.height);
config::save();
}
},
.isDisabled = [] { return IsMobile; },
@@ -820,7 +824,8 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) {
.key = "Unlock Framerate",
.getValue =
[] {
return kInterpolationModes[static_cast<u8>(getSettings().game.enableFrameInterpolation.getValue())];
return kInterpolationModes[static_cast<u8>(
getSettings().game.enableFrameInterpolation.getValue())];
},
.isModified =
[] {
@@ -1034,7 +1039,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) {
leftPane.add_section("Tools");
addOption("Turbo Key", getSettings().game.enableTurboKeybind,
"Hold Tab to increase game speed by up to 4x.",
[] { return dusk::speedrun::isActive(); });
[] { return speedrun::isActive(); });
addOption("Reset Key (" + Rml::String{hotkeys::DO_RESET} + ")",
getSettings().game.enableResetKeybind,
"Press " + Rml::String{hotkeys::DO_RESET} + " to reset the game.");
@@ -1147,7 +1152,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) {
getSettings().game.damageMultiplier.setValue(value);
config::save();
},
.isDisabled = [] { return dusk::speedrun::isActive(); },
.isDisabled = [] { return speedrun::isActive(); },
.isModified =
[] {
return getSettings().game.damageMultiplier.getValue() !=
@@ -1210,12 +1215,12 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) {
.onChange =
[this](bool enabled) {
if (enabled) {
dusk::speedrun::registerSpeedrunGameMode();
speedrun::registerSpeedrunGameMode();
} else {
if (dusk::speedrun::isActive()) {
if (speedrun::isActive()) {
pop();
}
dusk::speedrun::unregisterSpeedrunGameMode();
speedrun::unregisterSpeedrunGameMode();
}
MenuBar::refresh_tabs();
},
@@ -1233,13 +1238,13 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) {
speedrun::disconnectLiveSplit();
}
},
.isDisabled = [] { return IsMobile || !dusk::speedrun::isActive(); },
.isDisabled = [] { return IsMobile || !speedrun::isActive(); },
});
config_bool_select(leftPane, rightPane, getSettings().game.showSpeedrunRTATimer,
{
.key = "Show RTA",
.helpText = "Display the RTA timer. IGT is always visible.",
.isDisabled = [] { return !dusk::speedrun::isActive(); },
.isDisabled = [] { return !speedrun::isActive(); },
});
});
@@ -1286,9 +1291,10 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) {
.key = "Magic Armor Behavior",
.getValue =
[] {
return kMagicArmorModes[static_cast<u8>(getSettings().game.armorRupeeDrain.getValue())];
return kMagicArmorModes[static_cast<u8>(
getSettings().game.armorRupeeDrain.getValue())];
},
.isDisabled = [] { return dusk::speedrun::isActive(); },
.isDisabled = [] { return speedrun::isActive(); },
.isModified =
[] {
return getSettings().game.armorRupeeDrain.getValue() !=
@@ -1337,7 +1343,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) {
leftPane.register_control(leftPane.add_button("Restart to Main Menu").on_pressed([this] {
mDoAud_seStartMenu(kSoundClick);
pop();
ui::prelaunch_state().returnToPrelaunchOnReset = true;
prelaunch_state().returnToPrelaunchOnReset = true;
JUTGamePad::C3ButtonReset::sResetSwitchPushing = true;
}),
rightPane, [](Pane& pane) {
@@ -1450,9 +1456,9 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) {
.helpText = "Enable Dusklight to integrate with Discord Rich Presence. This allows Discord to show your status in-game.",
.onChange = [](bool enabled) {
if (enabled) {
dusk::discord::initialize();
discord::initialize();
} else {
dusk::discord::shutdown();
discord::shutdown();
}
},
});
@@ -1465,7 +1471,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) {
"Shift+F1.<br/><br/><icon class=\"warning\"/> WARNING: Debugging tools "
"can easily break your game. Do not use on a regular save!",
.onChange = [](bool) { MenuBar::refresh_tabs(); },
.isDisabled = [] { return dusk::speedrun::isActive(); },
.isDisabled = [] { return speedrun::isActive(); },
});
config_bool_select(leftPane, rightPane, getSettings().game.showInputViewer,
{
+2 -1
View File
@@ -133,6 +133,7 @@ Rml::String touch_controls_document_source() {
return Rml::String{R"RML(
<rml>
<head>
<link type="text/rcss" href="res/rml/theme.rcss" />
<link type="text/rcss" href="res/rml/touch_controls.rcss" />
</head>
<body id="root">
@@ -1091,7 +1092,7 @@ void TouchControls::sync_control_displays() noexcept {
return;
}
countElement->SetClass("visible", !label.empty());
countElement->SetInnerRML(label);
set_text_content(countElement, label);
};
syncCount(x.count, mButtonXCountLabel, Control::X, xState);
+3 -1
View File
@@ -46,6 +46,7 @@ Rml::String touch_controls_editor_document_source() {
return Rml::String{R"RML(
<rml>
<head>
<link type="text/rcss" href="res/rml/theme.rcss" />
<link type="text/rcss" href="res/rml/touch_controls.rcss" />
<link type="text/rcss" href="res/rml/touch_controls_editor.rcss" />
</head>
@@ -594,7 +595,8 @@ void TouchControlsEditor::request_reset() {
auto dismiss = [](Modal& modal) { modal.pop(); };
push(std::make_unique<Modal>(Modal::Props{
.title = "Reset Touch Layout?",
.bodyRml = "Reset controls to their default layout. This will not be saved until you press Save.",
.bodyText =
"Reset controls to their default layout. This will not be saved until you press Save.",
.actions =
{
ModalAction{
+28 -1
View File
@@ -14,15 +14,16 @@
#include <filesystem>
#include <ranges>
#include <borealis/io.hpp>
#include "aurora/lib/window.hpp"
#include "dusk/config.hpp"
#include "dusk/io.hpp"
#include <borealis/io.hpp>
#include "command_console.hpp"
#include "icon_provider.hpp"
#include "input.hpp"
#include "mod_texture_provider.hpp"
#include "prelaunch.hpp"
#include "remote_texture_provider.hpp"
#include "window.hpp"
namespace dusk::ui {
@@ -98,11 +99,13 @@ bool initialize() noexcept {
register_icon_texture_provider();
register_mod_texture_provider();
register_remote_texture_provider();
sInitialized = true;
return true;
}
void shutdown() noexcept {
unregister_remote_texture_provider();
unregister_mod_texture_provider();
unregister_icon_texture_provider();
sDocumentStack.clear();
@@ -361,6 +364,7 @@ void update() noexcept {
return;
}
update_remote_texture_provider();
input::update_input();
const auto update_documents = [](auto& documents) {
const std::size_t count = documents.size();
@@ -449,6 +453,29 @@ Rml::Element* append_text(Rml::Element* parent, const Rml::String& text) noexcep
return parent->AppendChild(doc->CreateTextNode(text));
}
Rml::Element* append_text_element(
Rml::Element* parent, const Rml::String& tag, const Rml::String& text) noexcept {
auto* element = append(parent, tag);
append_text(element, text);
return element;
}
void clear_children(Rml::Element* parent) noexcept {
if (parent == nullptr) {
return;
}
while (parent->GetNumChildren() > 0) {
parent->RemoveChild(parent->GetFirstChild());
}
}
void set_text_content(Rml::Element* parent, const Rml::String& text) noexcept {
clear_children(parent);
if (!text.empty()) {
append_text(parent, text);
}
}
NavCommand map_nav_event(const Rml::Event& event) noexcept {
const auto key = static_cast<Rml::Input::KeyIdentifier>(
event.GetParameter<int>("key_identifier", Rml::Input::KI_UNKNOWN));
+4
View File
@@ -104,6 +104,10 @@ std::filesystem::path resource_path(const std::filesystem::path& filename) noexc
std::string escape(std::string_view str) noexcept;
Rml::Element* append(Rml::Element* parent, const Rml::String& tag) noexcept;
Rml::Element* append_text(Rml::Element* parent, const Rml::String& text) noexcept;
Rml::Element* append_text_element(
Rml::Element* parent, const Rml::String& tag, const Rml::String& text) noexcept;
void clear_children(Rml::Element* parent) noexcept;
void set_text_content(Rml::Element* parent, const Rml::String& text) noexcept;
NavCommand map_nav_event(const Rml::Event& event) noexcept;
Insets safe_area_insets(Rml::Context* context) noexcept;
+4 -3
View File
@@ -33,6 +33,7 @@ Rml::String window_document_source(const std::vector<Rml::String>& styleSheets)
return fmt::format(R"RML(
<rml>
<head>
<link type="text/rcss" href="res/rml/theme.rcss" />
<link type="text/rcss" href="res/rml/tabbing.rcss" />
<link type="text/rcss" href="res/rml/window.rcss" />
{}</head>
@@ -47,11 +48,12 @@ Rml::String window_document_source(const std::vector<Rml::String>& styleSheets)
const Rml::String kDocumentSourceSmall = R"RML(
<rml>
<head>
<link type="text/rcss" href="res/rml/theme.rcss" />
<link type="text/rcss" href="res/rml/window.rcss" />
</head>
<body>
<window id="window" class="small">
<div id="dialog"/>
<modal-dialog id="dialog"/>
</window>
</body>
</rml>
@@ -330,7 +332,7 @@ bool Window::handle_content_nav(Rml::Event& event, NavCommand cmd) noexcept {
return false;
}
WindowSmall::WindowSmall(const Rml::String& windowClass, const Rml::String& dialogClass)
WindowSmall::WindowSmall(const Rml::String& windowClass)
: Document(kDocumentSourceSmall, false, DocumentScope::Window),
mRoot(mDocument->GetElementById("window")), mDialog(mDocument->GetElementById("dialog")) {
listen(mRoot, Rml::EventId::Transitionend, [this](Rml::Event& event) {
@@ -342,7 +344,6 @@ WindowSmall::WindowSmall(const Rml::String& windowClass, const Rml::String& dial
});
mRoot->SetClass(windowClass, true);
mDialog->SetClass(dialogClass, true);
}
void WindowSmall::show() {
+1 -1
View File
@@ -71,7 +71,7 @@ protected:
// Shared shell for small-style windows such as Modal and PresetWindow
class WindowSmall : public Document {
public:
WindowSmall(const Rml::String& windowClass, const Rml::String& dialogClass);
explicit WindowSmall(const Rml::String& windowClass);
void show() override;
void hide(bool close) override;