From 3e249de680b4dffbc7e6b4aa8489ba54a24e37ce Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Sat, 27 Apr 2024 19:20:12 +0200 Subject: [PATCH] Switch to Canvas Rendering This switches the rendering of the layout from using HTML and CSS to using a canvas (technically it's two). This not only should improve the performance but also brings the rendering in line with our native renderers, enabling new features such as vertical resizing and horizontal layouts. This also removes a lot of code specific to the HTML rendering and thus greatly simplifies the codebase. There is one slight regression in that the layout editor doesn't allow clicking and dragging the individual components directly on the layout anymore. This is however something we can explore directly in `livesplit-core` in the future, so other frontends can benefit from it as well. --- buildCore.js | 5 +- livesplit-core | 2 +- src/assets/icon.svg | 2 +- src/css/Abbreviated.scss | 6 - src/css/DetailedTimer.scss | 59 --------- src/css/Graph.scss | 3 - src/css/KeyValue.scss | 59 --------- src/css/Layout.scss | 45 ++++--- src/css/Separator.scss | 6 - src/css/Splits.scss | 160 ----------------------- src/css/Time.scss | 5 - src/css/Timer.scss | 4 - src/css/Title.scss | 99 --------------- src/index.tsx | 6 + src/layout/Abbreviated.tsx | 76 ----------- src/layout/AutoRefreshLayout.tsx | 47 ------- src/layout/BlankSpace.tsx | 19 --- src/layout/Component.tsx | 75 ----------- src/layout/DetailedTimer.tsx | 132 ------------------- src/layout/DragAutoRefreshLayout.tsx | 176 -------------------------- src/layout/Graph.tsx | 143 --------------------- src/layout/KeyValue.tsx | 26 ---- src/layout/KeyValueGeneric.tsx | 75 ----------- src/layout/Layout.tsx | 183 ++++++++------------------- src/layout/Separator.tsx | 16 --- src/layout/Split.tsx | 133 ------------------- src/layout/SplitLabels.tsx | 35 ----- src/layout/Splits.tsx | 71 ----------- src/layout/Text.tsx | 44 ------- src/layout/Timer.tsx | 116 ----------------- src/layout/Title.tsx | 89 ------------- src/storage/index.tsx | 11 +- src/ui/LayoutEditor.tsx | 31 ++--- src/ui/LayoutView.tsx | 7 +- src/ui/LiveSplit.tsx | 29 ++++- src/ui/TimerView.tsx | 56 ++++---- test/rendering-test.js | 22 ++-- 37 files changed, 193 insertions(+), 1880 deletions(-) delete mode 100644 src/css/Abbreviated.scss delete mode 100644 src/css/DetailedTimer.scss delete mode 100644 src/css/Graph.scss delete mode 100644 src/css/KeyValue.scss delete mode 100644 src/css/Separator.scss delete mode 100644 src/css/Splits.scss delete mode 100644 src/css/Time.scss delete mode 100644 src/css/Timer.scss delete mode 100644 src/css/Title.scss delete mode 100644 src/layout/Abbreviated.tsx delete mode 100644 src/layout/AutoRefreshLayout.tsx delete mode 100644 src/layout/BlankSpace.tsx delete mode 100644 src/layout/Component.tsx delete mode 100644 src/layout/DetailedTimer.tsx delete mode 100644 src/layout/DragAutoRefreshLayout.tsx delete mode 100644 src/layout/Graph.tsx delete mode 100644 src/layout/KeyValue.tsx delete mode 100644 src/layout/KeyValueGeneric.tsx delete mode 100644 src/layout/Separator.tsx delete mode 100644 src/layout/Split.tsx delete mode 100644 src/layout/SplitLabels.tsx delete mode 100644 src/layout/Splits.tsx delete mode 100644 src/layout/Text.tsx delete mode 100644 src/layout/Timer.tsx delete mode 100644 src/layout/Title.tsx diff --git a/buildCore.js b/buildCore.js index ae86616..28a4b70 100644 --- a/buildCore.js +++ b/buildCore.js @@ -42,7 +42,8 @@ if (process.argv.some((v) => v === "--nightly")) { // but it forgets to pass the LTO flag that we specified in the // Cargo.toml, so the virtual-function-elimination complains that it's // only compatible with LTO, so we have to specify lto here too. - rustFlags += " -Z virtual-function-elimination -C lto"; + // FIXME: Seems to be broken at the moment. + // rustFlags += " -Z virtual-function-elimination -C lto"; } } @@ -55,7 +56,7 @@ execSync( ); execSync( - `cargo ${toolchain} rustc -p livesplit-core-capi --crate-type cdylib --features wasm-web --target wasm32-unknown-unknown ${cargoFlags}`, + `cargo ${toolchain} rustc -p livesplit-core-capi --crate-type cdylib --features wasm-web,web-rendering --target wasm32-unknown-unknown ${cargoFlags}`, { cwd: "livesplit-core", stdio: "inherit", diff --git a/livesplit-core b/livesplit-core index f957c93..a7c9df2 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit f957c93b03aafcca925fcf35a2f4949f7b921f58 +Subproject commit a7c9df2ef1da927e743bd4a0783a5266d70ac402 diff --git a/src/assets/icon.svg b/src/assets/icon.svg index 1cdb312..8c93ee3 100644 --- a/src/assets/icon.svg +++ b/src/assets/icon.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/css/Abbreviated.scss b/src/css/Abbreviated.scss deleted file mode 100644 index 1864e35..0000000 --- a/src/css/Abbreviated.scss +++ /dev/null @@ -1,6 +0,0 @@ -.abbreviated { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - width: 100%; -} diff --git a/src/css/DetailedTimer.scss b/src/css/DetailedTimer.scss deleted file mode 100644 index 19f433e..0000000 --- a/src/css/DetailedTimer.scss +++ /dev/null @@ -1,59 +0,0 @@ -@import 'Time'; -@import 'variables'; - -.detailed-timer { - position: relative; - - @include time; - - .detailed-timer-left-side { - position: absolute; - display: flex; - top: 0; - left: $side-padding; - height: 100%; - width: 50%; - - .detailed-timer-icon-container { - height: 100%; - padding: $vertical-padding $side-padding; - margin-right: $side-padding; - flex-shrink: 0; - box-sizing: border-box; - - .detailed-timer-icon-inner-container { - width: 100%; - height: 100%; - text-align: center; - vertical-align: center; - - .detailed-timer-icon { - max-width: 100%; - height: 100%; - object-fit: contain; - } - } - } - - .detailed-timer-left-side-text { - position: relative; - width: 100%; - - .detailed-timer-segment-name { - position: absolute; - top: $vertical-padding; - left: 0; - width: 100%; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } - - .detailed-timer-comparisons { - position: absolute; - left: 0; - bottom: $vertical-padding; - } - } - } -} diff --git a/src/css/Graph.scss b/src/css/Graph.scss deleted file mode 100644 index fb499f1..0000000 --- a/src/css/Graph.scss +++ /dev/null @@ -1,3 +0,0 @@ -.graph { - display: block; -} diff --git a/src/css/KeyValue.scss b/src/css/KeyValue.scss deleted file mode 100644 index 61c3d81..0000000 --- a/src/css/KeyValue.scss +++ /dev/null @@ -1,59 +0,0 @@ -@import 'Time'; -@import 'variables'; - -.key-value { - height: $default-component-height; - padding: 0 $side-padding; - display: flex; - - @include time; - - &.key-value-two-rows { - height: $two-row-height; - padding-top: $vertical-padding; - padding-bottom: $vertical-padding; - flex-wrap: wrap; - align-items: stretch; - - .key-value-value { - width: 100%; - } - } - - &.key-value-center { - justify-content: center; - text-align: center; - - .key-value-key { - justify-content: center; - } - } - - &.key-value-left-right { - .key-value-key { - padding-right: $side-padding; - } - } - - .key-value-key { - display: flex; - flex-grow: 1; - justify-content: flex-start; - align-items: center; - overflow: hidden; - } - - .key-value-value { - display: flex; - flex-shrink: 0; - justify-content: flex-end; - align-items: center; - overflow: hidden; - - .key-value-value-inner { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - } -} diff --git a/src/css/Layout.scss b/src/css/Layout.scss index 7e63595..3604991 100644 --- a/src/css/Layout.scss +++ b/src/css/Layout.scss @@ -1,27 +1,42 @@ -@import 'variables'; @import 'Font'; .layout { - font-family: "fira", sans-serif; - font-size: $default-font-size; - text-shadow: 2px 2px 2px rgba(0, 0, 0, 0.5); - position: relative; - -webkit-touch-callout: none; - -webkit-user-select: none; - user-select: none; - .resizable-layout { position: absolute; - z-index: 2; - height: 100% !important; + top: 0; + width: inherit; + height: inherit; - .resizable-handle { + .react-resizable { + width: 0 !important; + height: 0 !important; + } + + .resizable-handle-east { cursor: e-resize; - bottom: -4px; - right: -4px; + right: -10px; + bottom: 10px; + top: 0; position: absolute; width: 20px; - height: 100%; + } + + .resizable-handle-south { + cursor: s-resize; + bottom: -10px; + left: 0; + right: 10px; + position: absolute; + height: 20px; + } + + .resizable-handle-south-east { + cursor: se-resize; + bottom: -10px; + right: -10px; + position: absolute; + width: 20px; + height: 20px; } } } diff --git a/src/css/Separator.scss b/src/css/Separator.scss deleted file mode 100644 index 1b5dbdf..0000000 --- a/src/css/Separator.scss +++ /dev/null @@ -1,6 +0,0 @@ -@import 'variables'; - -.separator { - background-color: hsla(0, 0%, 43%, 1); - height: $thick-separator-width; -} diff --git a/src/css/Splits.scss b/src/css/Splits.scss deleted file mode 100644 index f223a4a..0000000 --- a/src/css/Splits.scss +++ /dev/null @@ -1,160 +0,0 @@ -@use 'sass:math'; - -@import "Time"; -@import 'variables'; - -$split-column-width: $default-line-height * 3; -$split-label-height: $default-component-height - $thick-separator-width; -$split-icon-size: $default-component-height - $vertical-padding * 2; -$split-icon-two-row-size: $two-row-height - $vertical-padding * 2; - -.splits { - width: 100%; - position: relative; - - .split { - display: flex; - position: relative; - width: 100%; - height: $default-component-height; - - @include time; - - &.two-rows { - height: $two-row-height; - - .split-rows { - flex-wrap: wrap; - padding-top: $vertical-padding; - padding-bottom: $vertical-padding; - - .split-row { - height: initial; - - &.split-second-row { - width: 100%; - justify-content: flex-end; - } - } - } - - .split-icon-container { - width: $split-icon-two-row-size; - } - } - - .split-icon-container { - padding: $vertical-padding $side-padding; - width: $split-icon-size; - display: flex; - flex-shrink: 0; - align-items: center; - justify-content: center; - position: relative; - - .split-icon { - width: 100%; - height: 100%; - object-fit: contain; - filter: drop-shadow(2px 2px 1px rgba(0, 0, 0, 0.3)); - } - } - - .split-icon-container-empty { - padding-right: $side-padding; - position: relative; - } - - .split-rows { - display: flex; - align-items: center; - flex-grow: 1; - overflow: hidden; - height: 100%; - position: relative; - box-sizing: border-box; - - .split-row { - display: flex; - align-items: center; - overflow: hidden; - position: relative; - height: 100%; - - &.split-first-row, - .split-name { - flex-grow: 1; - } - - &.split-second-row, - .split-time { - flex-shrink: 0; - } - - .split-name { - display: flex; - align-items: center; - justify-content: flex-start; - overflow: hidden; - padding: 0 $side-padding; - padding-left: 0; - height: 100%; - - .split-name-inner { - padding: 0; - max-width: 100%; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - } - - .split-time { - display: flex; - align-items: center; - justify-content: flex-end; - padding: 0 $side-padding; - padding-left: 0; - height: 100%; - - &.split-time-full { - width: $split-column-width; - flex-shrink: 0; - } - - .split-time-inner { - padding: 0; - } - } - } - } - - &.split-label { - height: $split-label-height; - } - - .current-split-background, - .split-borders { - position: absolute; - width: 100%; - height: 100%; - top: 0; - padding: 0; - box-sizing: border-box; - } - - .split-borders { - border-bottom-width: $thin-separator-width; - border-top-width: $thin-separator-width; - border-bottom-style: solid; - border-top-style: solid; - top: math.div(-$thick-separator-width, 2); - height: calc(100% + #{math.div($thick-separator-width, 2)}); - - &.separator-above { - border-top-width: $thick-separator-width; - border-top-style: solid; - } - } - } -} diff --git a/src/css/Time.scss b/src/css/Time.scss deleted file mode 100644 index ae56428..0000000 --- a/src/css/Time.scss +++ /dev/null @@ -1,5 +0,0 @@ -@mixin time { - .time { - font-variant-numeric: tabular-nums; - } -} diff --git a/src/css/Timer.scss b/src/css/Timer.scss deleted file mode 100644 index 52203ea..0000000 --- a/src/css/Timer.scss +++ /dev/null @@ -1,4 +0,0 @@ -.timer { - will-change: transform; - transform: translateZ(0); -} diff --git a/src/css/Title.scss b/src/css/Title.scss deleted file mode 100644 index 40eefd2..0000000 --- a/src/css/Title.scss +++ /dev/null @@ -1,99 +0,0 @@ -@import "variables"; - -$game-icon-size: $two-row-height - $vertical-padding * 2; - -.title { - height: $two-row-height; - width: 100%; - display: flex; - padding-top: $vertical-padding; - padding-bottom: $vertical-padding; - - .game-icon-container { - max-width: $game-icon-size; - max-height: $game-icon-size; - padding: $vertical-padding 0; - padding-left: $side-padding; - flex-shrink: 0; - - .title-icon { - width: 100%; - height: 100%; - object-fit: contain; - } - } - - .title-text { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .run-info { - height: 100%; - flex-grow: 1; - overflow: hidden; - - .title-game { - display: flex; - align-items: center; - height: 50%; - padding: 0 $side-padding; - - &.centered .abbreviated { - text-align: center; - } - } - - .lower-row { - display: flex; - position: relative; - height: 50%; - - &.centered { - justify-content: center; - } - - &.one-line { - height: 100%; - } - - .title-category { - display: flex; - align-items: center; - height: 100%; - flex-grow: 1; - overflow: hidden; - padding: 0 $side-padding; - - &.centered .abbreviated { - text-align: center; - } - } - - .title-attempts, - .title-attempts-invisible { - height: 100%; - flex-shrink: 0; - display: flex; - align-items: center; - - &.one-line { - height: 50%; - align-self: flex-end; - } - } - - .title-attempts { - justify-content: flex-end; - padding-right: $side-padding; - } - - .title-attempts-invisible { - justify-content: flex-start; - padding-left: $side-padding; - visibility: hidden; - } - } - } -} diff --git a/src/index.tsx b/src/index.tsx index 747a4d6..f0d6a52 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -25,6 +25,7 @@ try { layout, hotkeys, layoutWidth, + layoutHeight, } = await LiveSplit.loadStoredData(); function requestWakeLock() { @@ -43,6 +44,10 @@ try { } }); + // The renderer requires the fonts to be loaded before it gets created + // as otherwise information may be cached incorrectly. + await document.fonts.ready; + ReactDOM.render(
{ - public sorted: string[]; - public oldAbbreviations: string[]; - public element: React.RefObject; - - constructor(props: Props) { - super(props); - this.element = React.createRef(); - this.sorted = [...this.props.abbreviations]; - this.sorted.sort((a, b) => b.length - a.length); - this.oldAbbreviations = this.props.abbreviations; - } - - public componentDidMount() { - const element = expect(this.element.current, "Element should exist by now."); - new (window as any).ResizeObserver(() => { - this.chooseAbbreviation(); - }).observe(element); - - this.chooseAbbreviation(); - } - - public componentDidUpdate() { - const arrEq = (a: string[], b: string[]) => { - if (a.length !== b.length) { - return false; - } - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) { - return false; - } - } - return true; - }; - if (!arrEq(this.props.abbreviations, this.oldAbbreviations)) { - this.oldAbbreviations = this.props.abbreviations; - this.sorted = [...this.props.abbreviations]; - this.sorted.sort((a, b) => b.length - a.length); - this.chooseAbbreviation(); - } - } - - public render() { - return
; - } - - private chooseAbbreviation() { - if (this.element.current === null) { - // We can still get here for a short time when unmounting. - return; - } - - for (const abbrev of this.sorted) { - this.element.current.innerText = abbrev; - if (this.element.current.scrollWidth <= this.element.current.clientWidth) { - return; - } - } - this.element.current.innerText = this.sorted[0]; - } -} diff --git a/src/layout/AutoRefreshLayout.tsx b/src/layout/AutoRefreshLayout.tsx deleted file mode 100644 index 0a6574a..0000000 --- a/src/layout/AutoRefreshLayout.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import * as React from "react"; -import { LayoutStateJson } from "../livesplit-core"; -import AutoRefresh from "../util/AutoRefresh"; -import Layout from "./Layout"; -import { UrlCache } from "../util/UrlCache"; - -export interface Props { - getState: () => LayoutStateJson, - layoutUrlCache: UrlCache, - allowResize: boolean, - width: number, - onResize(width: number): void, -} - -export interface State { - layoutState: LayoutStateJson, -} - -export default class AutoRefreshLayout extends React.Component { - constructor(props: Props) { - super(props); - - this.state = { - layoutState: this.props.getState(), - }; - } - - public refreshLayout() { - this.setState({ - layoutState: this.props.getState(), - }); - } - - public render() { - return ( - this.refreshLayout()} > - - - ); - } -} diff --git a/src/layout/BlankSpace.tsx b/src/layout/BlankSpace.tsx deleted file mode 100644 index d47c9e5..0000000 --- a/src/layout/BlankSpace.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import * as React from "react"; -import * as LiveSplit from "../livesplit-core"; -import { gradientToCss } from "../util/ColorUtil"; - -export interface Props { state: LiveSplit.BlankSpaceComponentStateJson } - -export default class BlankSpace extends React.Component { - public render() { - return ( -
- ); - } -} diff --git a/src/layout/Component.tsx b/src/layout/Component.tsx deleted file mode 100644 index 0345153..0000000 --- a/src/layout/Component.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import * as React from "react"; -import deepEqual from "fast-deep-equal"; -import { ComponentStateJson } from "../livesplit-core"; -import { assertNever } from "../util/OptionUtil"; -import BlankSpace from "./BlankSpace"; -import KeyValue from "./KeyValue"; -import DetailedTimer from "./DetailedTimer"; -import Graph from "./Graph"; -import Separator from "./Separator"; -import Splits from "./Splits"; -import Text from "./Text"; -import Timer from "./Timer"; -import Title from "./Title"; -import { UrlCache } from "../util/UrlCache"; - -export interface Props { - state: ComponentStateJson, - layoutUrlCache: UrlCache, - layoutWidth: number, - componentId: string, -} - -export default class Component extends React.Component { - public render() { - const { state } = this.props; - if ("KeyValue" in state) { - return ; - } else if ("DetailedTimer" in state) { - return ; - } else if ("Graph" in state) { - return ; - } else if ("Splits" in state) { - return ; - } else if ("Text" in state) { - return ; - } else if ("Timer" in state) { - return ; - } else if ("Title" in state) { - return ; - } else if ("Separator" in state) { - return <Separator />; - } else if ("BlankSpace" in state) { - return <BlankSpace state={state.BlankSpace} />; - } else { - return assertNever(state); - } - } - - public shouldComponentUpdate(nextProps: Readonly<Props>): boolean { - return !deepEqual(nextProps.state, this.props.state) || - nextProps.layoutWidth !== this.props.layoutWidth || - nextProps.componentId !== this.props.componentId; - } -} diff --git a/src/layout/DetailedTimer.tsx b/src/layout/DetailedTimer.tsx deleted file mode 100644 index f45302e..0000000 --- a/src/layout/DetailedTimer.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import * as React from "react"; -import * as LiveSplit from "../livesplit-core"; -import { colorToCss, gradientToCss } from "../util/ColorUtil"; -import { renderToSVG } from "./Timer"; - -import "../css/DetailedTimer.scss"; -import variables from "../css/variables.scss"; -import { map } from "../util/OptionUtil"; -import { UrlCache } from "../util/UrlCache"; - -const fontSizeToLineHeightRatio = parseFloat(variables.fontSizeToLineHeightRatio); -const lineHeightToComponentHeightRatio = parseFloat(variables.lineHeightToComponentHeightRatio); -const sidePadding = parseFloat(variables.sidePadding); - -export interface Props { - state: LiveSplit.DetailedTimerComponentStateJson, - layoutUrlCache: UrlCache, - layoutWidth: number, -} - -export default class DetailedTimer extends React.Component<Props> { - constructor(props: Props) { - super(props); - } - - public render() { - const icon = this.props.layoutUrlCache.cache(this.props.state.icon); - - const totalHeight = this.props.state.timer.height + this.props.state.segment_timer.height; - const topRowHeight = totalHeight * 0.55; - const bottomRowHeight = totalHeight - topRowHeight; - const topRowFontSize = topRowHeight * 0.5 * fontSizeToLineHeightRatio; - const bottomRowFontSize = bottomRowHeight * 0.5 * lineHeightToComponentHeightRatio * fontSizeToLineHeightRatio; - - const comparisonNamesColor = map(this.props.state.comparison_names_color, colorToCss); - const comparisonTimesColor = map(this.props.state.comparison_times_color, colorToCss); - - return ( - <div - className="detailed-timer" - style={{ - background: gradientToCss(this.props.state.background), - height: totalHeight, - }} - > - { - renderToSVG( - this.props.state.timer, - "timer", - this.props.layoutWidth, - ) - } - { - renderToSVG( - this.props.state.segment_timer, - "segment-timer", - this.props.layoutWidth, - ) - } - <div className="detailed-timer-left-side"> - { - icon !== undefined && - <div className="detailed-timer-icon-container" style={{ width: totalHeight }}> - <div className="detailed-timer-icon-inner-container"> - <img className="detailed-timer-icon" src={icon} /> - </div> - </div> - } - <div className="detailed-timer-left-side-text"> - { - this.props.state.segment_name !== null && - <div - className="detailed-timer-segment-name" - style={{ - color: map(this.props.state.segment_name_color, colorToCss), - height: topRowHeight, - fontSize: topRowFontSize, - }} - > - {this.props.state.segment_name} - </div> - } - <table - className="detailed-timer-comparisons" - style={{ height: bottomRowHeight, fontSize: bottomRowFontSize }} - > - <tbody> - { - this.props.state.comparison1 !== null && - formatComparison( - this.props.state.comparison1, - comparisonNamesColor, - comparisonTimesColor, - ) - } - { - this.props.state.comparison2 !== null && - formatComparison( - this.props.state.comparison2, - comparisonNamesColor, - comparisonTimesColor, - ) - } - </tbody> - </table> - </div> - </div> - </div> - ); - } -} - -function formatComparison( - comparison: LiveSplit.DetailedTimerComponentComparisonStateJson, - comparisonNamesColor?: string, - comparisonTimesColor?: string, -) { - return ( - <tr> - <td style={{ color: comparisonNamesColor, padding: 0 }}> - {comparison.name + ":"} - </td> - <td className="time" style={{ - color: comparisonTimesColor, - padding: 0, - paddingLeft: sidePadding, - }}> - {comparison.time} - </td> - </tr> - ); -} diff --git a/src/layout/DragAutoRefreshLayout.tsx b/src/layout/DragAutoRefreshLayout.tsx deleted file mode 100644 index 5ed6179..0000000 --- a/src/layout/DragAutoRefreshLayout.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import * as React from "react"; -import { LayoutStateJson } from "../livesplit-core"; -import AutoRefresh from "../util/AutoRefresh"; -import { backgroundToCss, colorToCss } from "../util/ColorUtil"; -import { Option } from "../util/OptionUtil"; -import Component from "./Component"; -import { getLayoutStateStyle } from "./Layout"; -import { UrlCache } from "../util/UrlCache"; - -export interface Props { - getState: () => LayoutStateJson, - layoutUrlCache: UrlCache, - layoutWidth: number, - onClick: (componentIndex: number) => void, - onDrag: (componentIndex: number) => void, - onDragEnd: (componentIndex: number) => void, - onDrop: (componentIndex: number) => void, - isSelected: (componentIndex: number) => boolean, -} - -export interface State { - layoutState: LayoutStateJson, - startIndex: Option<number>, - hoverIndex: Option<number>, -} - -export default class DragAutoRefreshLayout extends React.Component<Props, State> { - constructor(props: Props) { - super(props); - - this.state = { - hoverIndex: null, - layoutState: this.props.getState(), - startIndex: null, - }; - } - - public refreshLayout() { - this.setState({ - layoutState: this.props.getState(), - }); - } - - public render() { - const layoutState = this.state.layoutState; - const layoutStateStyle = getLayoutStateStyle(layoutState); - const counts = new Map<string, number>(); - - const background = backgroundToCss( - layoutState.background, - this.props.layoutUrlCache, - this.props.layoutWidth, - ); - - const dragLayout = ( - <div - className="layout" - style={{ - overflow: "hidden", - color: colorToCss(layoutState.text_color), - width: this.props.layoutWidth, - ...layoutStateStyle - }} - > - <div style={{ - position: "absolute", - width: "100%", - height: "100%", - zIndex: -1, - ...background, - }} /> - { - layoutState.components.map((c, i) => { - const componentType = Object.keys(c)[0]; - const id = counts.get(componentType) || 0; - counts.set(componentType, id + 1); - - const key = `${componentType}${id}`; - - return <div - key={key} - onClick={(_) => this.props.onClick(i)} - draggable - onDragStart={(e) => { - e.dataTransfer.setData("text/plain", ""); - this.setState({ - ...this.state, - hoverIndex: i, - startIndex: i, - }); - this.props.onDrag(i); - }} - onDragOver={(e) => { - if (e.preventDefault) { - e.preventDefault(); - } - e.dataTransfer.dropEffect = "move"; - }} - onDragEnter={(_) => { - this.setState({ - ...this.state, - hoverIndex: i, - }); - }} - onDragEnd={(_) => { - this.props.onDragEnd(i); - this.setState({ - ...this.state, - hoverIndex: null, - startIndex: null, - }); - }} - onDrop={(e) => { - if (e.stopPropagation) { - e.stopPropagation(); - } - this.props.onDrop(i); - return false; - }} - style={{ - cursor: "pointer", - position: "relative", - }} - > - { - getBorderDiv(i, this.state, this.props) - } - <Component - state={c} - layoutUrlCache={this.props.layoutUrlCache} - layoutWidth={this.props.layoutWidth} - componentId={key} - /> - </div>; - }) - } - </div> - ); - - return ( - <AutoRefresh update={() => this.refreshLayout()}> - {dragLayout} - </AutoRefresh> - ); - } -} - -function getBorderDiv(index: number, state: State, props: Props): Option<JSX.Element> { - const style: any = { - height: "100%", - position: "absolute", - width: "100%", - zIndex: 2, - }; - - if (state.startIndex == null || state.startIndex === state.hoverIndex) { - if (props.isSelected(index)) { - style.border = "2px solid rgb(50, 114, 241)"; - style.width = "calc(100% - 4px)"; - style.height = "calc(100% - 4px)"; - } else { - return null; - } - } else if (index === state.hoverIndex) { - if (index < state.startIndex) { - style.borderTop = "2px solid rgb(50, 114, 241)"; - } else { - style.borderBottom = "2px solid rgb(50, 114, 241)"; - style.height = "calc(100% - 2px)"; - } - } else { - return null; - } - - return <div style={style} />; -} diff --git a/src/layout/Graph.tsx b/src/layout/Graph.tsx deleted file mode 100644 index d3d8d51..0000000 --- a/src/layout/Graph.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import * as React from "react"; -import * as LiveSplit from "../livesplit-core"; -import { colorToCss } from "../util/ColorUtil"; - -import "../css/Graph.scss"; - -export interface Props { - state: LiveSplit.GraphComponentStateJson, - layoutWidth: number, -} - -export default class Graph extends React.Component<Props> { - public render() { - const width = this.props.layoutWidth; - const height = this.props.state.height; - - const middle = height * this.props.state.middle; - - const colorTop = colorToCss(this.props.state.top_background_color); - const colorBottom = colorToCss(this.props.state.bottom_background_color); - const colorGridLines = colorToCss(this.props.state.grid_lines_color); - const colorGraphLines = colorToCss(this.props.state.graph_lines_color); - const colorPartialFill = colorToCss(this.props.state.partial_fill_color); - const colorCompleteFill = colorToCss(this.props.state.complete_fill_color); - const colorBestSegment = colorToCss(this.props.state.best_segment_color); - - const rect1 = <rect - key="rect1" - width="100%" - height={middle} - style={{ fill: colorTop }} - />; - const rect2 = <rect - key="rect2" - y={middle} - width="100%" - height={height - middle} - style={{ fill: colorBottom }} - />; - const children = [rect1, rect2]; - - this.props.state.horizontal_grid_lines.forEach((y, i) => { - const line = <line - key={`hline${i}`} - x1="0" y1={height * y} - x2="100%" y2={height * y} - style={{ - stroke: colorGridLines, - strokeWidth: "2", - }} - />; - children.push(line); - }); - - this.props.state.vertical_grid_lines.forEach((x, i) => { - const line = <line - key={`vline${i}`} - x1={`${x * 100}%`} y1="0" - x2={`${x * 100}%`} y2={height} - style={{ - stroke: colorGridLines, - strokeWidth: "2", - }} - />; - children.push(line); - }); - - let points = ""; - - let length = this.props.state.points.length; - if (this.props.state.is_live_delta_active) { - length -= 1; - } - - for (let i = 0; i < length; i++) { - const point = this.props.state.points[i]; - points += `${width * point.x},${height * point.y} `; - } - - points += `${width * this.props.state.points[length - 1].x},${middle}`; - - const fill = <polygon key="fill" points={points} style={{ - fill: colorCompleteFill, - }} />; - - children.push(fill); - - if (this.props.state.is_live_delta_active) { - const x1 = width * this.props.state.points[length - 1].x; - const y1 = height * this.props.state.points[length - 1].y; - const x2 = width * this.props.state.points[length].x; - const y2 = height * this.props.state.points[length].y; - - const fill = <polygon - key="pfill" - points={`${x1},${middle} ${x1},${y1} ${x2},${y2} ${x2},${middle}`} - style={{ - fill: colorPartialFill, - }} - />; - - children.push(fill); - } - - const childrenPoints = []; - - for (let i = 1; i < this.props.state.points.length; i++) { - const px = `${100 * this.props.state.points[i - 1].x}%`; - const py = height * this.props.state.points[i - 1].y; - const x = `${100 * this.props.state.points[i].x}%`; - const y = height * this.props.state.points[i].y; - - const fill = this.props.state.points[i].is_best_segment - ? colorBestSegment - : colorGraphLines; - - const line = <line - key={`s${i}`} - x1={px} y1={py} - x2={x} y2={y} - style={{ - stroke: fill, - strokeWidth: "2", - }} - />; - children.push(line); - - if (i !== this.props.state.points.length - 1 || !this.props.state.is_live_delta_active) { - const circle = <ellipse - key={`p${i}`} - cx={x} cy={y} - rx="2.5" ry="2.5" - style={{ fill }} - />; - childrenPoints.push(circle); - } - } - - children.push(...childrenPoints); - - return (<svg className="graph" width={width} height={height}>{children}</svg>); - } -} diff --git a/src/layout/KeyValue.tsx b/src/layout/KeyValue.tsx deleted file mode 100644 index 1733823..0000000 --- a/src/layout/KeyValue.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import * as React from "react"; -import * as LiveSplit from "../livesplit-core"; -import KeyValueGeneric, { KeyValueDisplay } from "./KeyValueGeneric"; - -import "../css/KeyValue.scss"; - -export interface Props { - state: LiveSplit.KeyValueComponentStateJson, -} - -export default class KeyValue extends React.Component<Props> { - public render() { - return <KeyValueGeneric - display={this.props.state.display_two_rows ? - KeyValueDisplay.SplitTwoRows : - KeyValueDisplay.SplitOneRow - } - keyText={this.props.state.key} - keyAbbreviations={this.props.state.key_abbreviations} - keyColor={this.props.state.key_color} - valueText={this.props.state.value} - valueColor={this.props.state.value_color} - wrapperBackground={this.props.state.background} - />; - } -} diff --git a/src/layout/KeyValueGeneric.tsx b/src/layout/KeyValueGeneric.tsx deleted file mode 100644 index 0af8adb..0000000 --- a/src/layout/KeyValueGeneric.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import * as React from "react"; -import * as LiveSplit from "../livesplit-core"; - -import { colorToCss, gradientToCss } from "../util/ColorUtil"; -import { map } from "../util/OptionUtil"; -import Abbreviated from "./Abbreviated"; - -import "../css/KeyValue.scss"; - -export enum KeyValueDisplay { - Center, - SplitOneRow, - SplitTwoRows, -} - -export interface Props { - display: KeyValueDisplay, - keyColor: LiveSplit.Color | null, - keyText: string, - keyAbbreviations: string[], - valueColor: LiveSplit.Color | null, - valueText: string | null, - wrapperBackground: LiveSplit.Gradient, -} - -export default class KeyValueGeneric extends React.Component<Props> { - public render() { - const keyCell = <div - className="key-value-key text-font" - style={{ - color: map(this.props.keyColor, colorToCss), - }} - > - <Abbreviated abbreviations={[...this.props.keyAbbreviations, this.props.keyText]} /> - </div>; - - const valueCell = <div - className="key-value-value time times-font" - style={{ - color: map(this.props.valueColor, colorToCss), - }} - > - <div className="key-value-value-inner">{this.props.valueText}</div> - </div>; - - let keyValueRows: JSX.Element; - let wrapperClassName: string; - if (this.props.display === KeyValueDisplay.Center) { - keyValueRows = keyCell; - wrapperClassName = "key-value-center"; - } else { - keyValueRows = <> - {keyCell} - {valueCell} - </>; - - if (this.props.display === KeyValueDisplay.SplitTwoRows) { - wrapperClassName = "key-value-two-rows"; - } else { - wrapperClassName = "key-value-left-right"; - } - } - - return ( - <div - className={`key-value ${wrapperClassName}`} - style={{ - background: gradientToCss(this.props.wrapperBackground), - }} - > - {keyValueRows} - </div> - ); - } -} diff --git a/src/layout/Layout.tsx b/src/layout/Layout.tsx index 28e7f92..3f4faa2 100644 --- a/src/layout/Layout.tsx +++ b/src/layout/Layout.tsx @@ -1,145 +1,68 @@ import * as React from "react"; -import { Font, FontStretch, FontStyle, FontWeight, LayoutStateJson } from "../livesplit-core"; -import { backgroundToCss, colorToCss } from "../util/ColorUtil"; -import Component from "./Component"; -import { ResizableBox, ResizeCallbackData } from "react-resizable"; - -import "../css/Layout.scss"; +import { ResizableBox } from "react-resizable"; +import { LayoutStateRef } from "../livesplit-core"; +import { WebRenderer } from "../livesplit-core/livesplit_core"; +import AutoRefresh from "../util/AutoRefresh"; import { UrlCache } from "../util/UrlCache"; - -interface LayoutStateStyle { - "--thin-separators-color": string, - "--separators-color": string, - - "--timer-font-family"?: string, - "--timer-font-style"?: FontStyle, - "--timer-font-weight"?: string | number, - "--timer-font-stretch"?: FontStretch, - - "--times-font-family"?: string, - "--times-font-style"?: FontStyle, - "--times-font-weight"?: string | number, - "--times-font-stretch"?: FontStretch, - - "--text-font-family"?: string, - "--text-font-style"?: FontStyle, - "--text-font-weight"?: string | number, - "--text-font-stretch"?: FontStretch, -} +import "../css/Layout.scss"; export interface Props { - state: LayoutStateJson, + getState: () => LayoutStateRef, layoutUrlCache: UrlCache, allowResize: boolean, width: number, - onResize(width: number): void, + height: number, + renderer: WebRenderer, + onResize(width: number, height: number): void, } -export default class Layout extends React.Component<Props> { +export default class Layout extends React.Component<Props, unknown> { + public refreshLayout() { + const layoutState = this.props.getState(); + const newDims = this.props.renderer.render(layoutState.ptr, this.props.layoutUrlCache.imageCache.ptr); + if (newDims !== undefined) { + this.props.onResize(newDims[0], newDims[1]); + } + } + public render() { - const layoutState = getLayoutStateStyle(this.props.state); - const counts = new Map<string, number>(); - - const background = backgroundToCss( - this.props.state.background, - this.props.layoutUrlCache, - this.props.width, - ); - return ( - <div - className="layout" - style={{ - overflow: "hidden", - color: colorToCss(this.props.state.text_color), - width: this.props.width, - ...layoutState - }} - > - <div style={{ - position: "absolute", - width: "100%", - height: "100%", - zIndex: -1, - ...background, - }} /> - {this.props.allowResize && - <ResizableBox - className="resizable-layout" - width={this.props.width} - minConstraints={[160, 0]} - height={0} - handle={<span onClick={(event: React.SyntheticEvent) => { event.stopPropagation(); }} className="resizable-handle" />} - onResize={ - ( - _event: React.SyntheticEvent, - data: ResizeCallbackData, - ) => this.props.onResize(data.size.width) - } - axis="x" + <AutoRefresh update={() => this.refreshLayout()} > + <div className="layout" style={{ width: this.props.width, height: this.props.height }}> + <div + style={{ width: "inherit", height: "inherit" }} + ref={(element) => element?.appendChild(this.props.renderer.element())} /> - } - { - this.props.state.components.map((c) => { - const componentType = Object.keys(c)[0]; - const id = counts.get(componentType) || 0; - counts.set(componentType, id + 1); - - const key = `${componentType}${id}`; - - return <Component - key={key} - state={c} - layoutUrlCache={this.props.layoutUrlCache} - componentId={key} - layoutWidth={this.props.width} - />; - }) - } - </div> + { + this.props.allowResize && <div className="resizable-layout"> + <ResizableBox + axis="x" + width={this.props.width} + height={this.props.height} + minConstraints={[100, 40]} + handle={<div onClick={(e) => e.stopPropagation()} className="resizable-handle-east" />} + onResize={(_event, data) => this.props.onResize(data.size.width, data.size.height)} + /> + <ResizableBox + axis="y" + width={this.props.width} + height={this.props.height} + minConstraints={[100, 40]} + handle={<div onClick={(e) => e.stopPropagation()} className="resizable-handle-south" />} + onResize={(_event, data) => this.props.onResize(data.size.width, data.size.height)} + /> + <ResizableBox + axis="both" + width={this.props.width} + height={this.props.height} + minConstraints={[100, 40]} + handle={<div onClick={(e) => e.stopPropagation()} className="resizable-handle-south-east" />} + onResize={(_event, data) => this.props.onResize(data.size.width, data.size.height)} + /> + </div> + } + </div> + </AutoRefresh> ); } } - -export function getLayoutStateStyle(state: LayoutStateJson): LayoutStateStyle { - const style = { - "--thin-separators-color": colorToCss(state.thin_separators_color), - "--separators-color": colorToCss(state.separators_color), - "--times-font-weight": "bold", - }; - - fillFont(style, "timer", state.timer_font, "timer, sans-serif"); - fillFont(style, "times", state.times_font, "fira, sans-serif"); - fillFont(style, "text", state.text_font, "fira, sans-serif"); - - return style; -} - -function fillFont(style: any, prefix: string, font: Font | null, defaultName: string) { - if (font == null) { - style[`--${prefix}-font-family`] = defaultName; - return; - } - style[`--${prefix}-font-family`] = `"${font.family}", ${defaultName}`; - style[`--${prefix}-font-style`] = font.style; - style[`--${prefix}-font-weight`] = translateWeight(font.weight); - style[`--${prefix}-font-stretch`] = font.stretch; -} - -function translateWeight(weight: FontWeight): string | number { - switch (weight) { - case "bold": - case "normal": return weight; - case "thin": return 100; - case "extra-light": return 200; - case "light": return 300; - case "semi-light": return 350; - case "normal": return 400; - case "medium": return 500; - case "semi-bold": return 600; - case "bold": return 700; - case "extra-bold": return 800; - case "black": return 900; - case "extra-black": return 950; - } -} diff --git a/src/layout/Separator.tsx b/src/layout/Separator.tsx deleted file mode 100644 index beb0ff5..0000000 --- a/src/layout/Separator.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import * as React from "react"; - -import "../css/Separator.scss"; - -export default class Separator extends React.Component { - public render() { - return ( - <div - className="separator" - style={{ - backgroundColor: "var(--separators-color)", - }} - /> - ); - } -} diff --git a/src/layout/Split.tsx b/src/layout/Split.tsx deleted file mode 100644 index dd3a68b..0000000 --- a/src/layout/Split.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import * as React from "react"; -import deepEqual from "fast-deep-equal"; -import * as LiveSplit from "../livesplit-core"; -import { colorToCss, gradientToCss } from "../util/ColorUtil"; -import { Option } from "../util/OptionUtil"; -import { UrlCache } from "../util/UrlCache"; - -export interface Props { - splitsState: { - has_icons: boolean, - show_thin_separators: boolean, - display_two_rows: boolean, - current_split_gradient: LiveSplit.Gradient, - }, - evenOdd: [Option<string>, Option<string>], - split: LiveSplit.SplitStateJson, - separatorInFrontOfSplit: boolean, - visualSplitIndex: number, - layoutUrlCache: UrlCache, -} - -export default class Split extends React.Component<Props> { - constructor(props: Props) { - super(props); - } - - public render() { - const currentSplit = this.props.split.is_current_split ? "current-split" : ""; - const twoRows = this.props.splitsState.display_two_rows ? "two-rows" : ""; - - const splitsHaveIcons = this.props.splitsState.has_icons; - const icon = splitsHaveIcons - ? this.props.layoutUrlCache.cache(this.props.split.icon) - : undefined; - - const innerStyle: any = {}; - const outerStyle: any = {}; - - if (this.props.split.index % 2 === 1) { - if (this.props.splitsState.show_thin_separators) { - innerStyle.borderBottomColor = "var(--thin-separators-color)"; - } else { - innerStyle.borderBottomColor = "transparent"; - } - outerStyle.backgroundColor = this.props.evenOdd[1]; - } else { - innerStyle.borderBottomColor = "transparent"; - outerStyle.backgroundColor = this.props.evenOdd[0]; - } - innerStyle.borderTopColor = innerStyle.borderBottomColor; - - let separatorAbove = ""; - if (this.props.separatorInFrontOfSplit) { - innerStyle.borderTopColor = "var(--separators-color)"; - separatorAbove = "separator-above"; - } - - const currentSplitBackgroundStyle: any = {}; - if (this.props.split.is_current_split) { - currentSplitBackgroundStyle.background = gradientToCss(this.props.splitsState.current_split_gradient); - } - - let lastEmptyColumnIndex = this.props.split.columns.length; - for (let i = this.props.split.columns.length - 1; i >= 0; i--) { - const column = this.props.split.columns[i]; - if (!column.value) { - lastEmptyColumnIndex = i; - } else { - break; - } - } - - const columns = this.props.split.columns.slice(0, lastEmptyColumnIndex); - - return ( - <span - className={["split", currentSplit, twoRows].filter((s) => s.length > 0).join(" ")} - style={outerStyle} - > - <div className="current-split-background" style={currentSplitBackgroundStyle}></div> - <div className={`split-borders ${separatorAbove}`} style={innerStyle}></div> - <div - key="split-icon" - className={splitsHaveIcons ? "split-icon-container" : "split-icon-container-empty"} - > - { - splitsHaveIcons && icon !== undefined && - <img className="split-icon" src={icon} /> - } - </div> - <div className="split-rows"> - <div className="split-row split-first-row"> - <div - key="split-name" - className="split-name text-font" - > - <div className="split-name-inner"> - {this.props.split.name} - </div> - </div> - </div> - <div - className="split-row split-second-row" - > - { - columns.map((column, i) => - <div - key={i} - className={`split-time time times-font ${i < columns.length - 1 ? "split-time-full" : ""}`} - style={{ - color: colorToCss(column.visual_color), - }} - > - <div className="split-time-inner"> - {column.value} - </div> - </div>, - ).reverse() - } - </div> - </div> - </span> - ); - } - - public shouldComponentUpdate(nextProps: Readonly<Props>): boolean { - return !deepEqual(nextProps.splitsState, this.props.splitsState) || - !deepEqual(nextProps.evenOdd, this.props.evenOdd) || - !deepEqual(nextProps.split, this.props.split) || - nextProps.separatorInFrontOfSplit !== this.props.separatorInFrontOfSplit || - nextProps.visualSplitIndex !== this.props.visualSplitIndex; - } -} diff --git a/src/layout/SplitLabels.tsx b/src/layout/SplitLabels.tsx deleted file mode 100644 index a2232eb..0000000 --- a/src/layout/SplitLabels.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import * as React from "react"; - -export interface Props { - labels: string[], -} - -export default class SplitLabels extends React.Component<Props> { - public render() { - return ( - <span - className="split split-label text-font" - > - <div className="current-split-background" /> - <div className="split-icon-container-empty" /> - <div className="split-rows"> - <div className="split-row split-first-row"> - <div className="split-name" /> - </div> - <div className="split-row split-second-row"> - { - this.props.labels.map((label, i) => - <div - key={i} - className={`split-time split-label ${i < this.props.labels.length - 1 ? "split-time-full" : ""}`} - > - {label} - </div>, - ).reverse() - } - </div> - </div> - </span> - ); - } -} diff --git a/src/layout/Splits.tsx b/src/layout/Splits.tsx deleted file mode 100644 index 51b9a7a..0000000 --- a/src/layout/Splits.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import * as React from "react"; -import * as LiveSplit from "../livesplit-core"; -import Split from "./Split"; -import { gradientToCss, colorToCss } from "../util/ColorUtil"; -import { Option, map } from "../util/OptionUtil"; -import SplitLabels from "./SplitLabels"; - -import "../css/Splits.scss"; -import { UrlCache } from "../util/UrlCache"; - -export interface Props { - state: LiveSplit.SplitsComponentStateJson, - layoutUrlCache: UrlCache, -} - -export default class Splits extends React.Component<Props> { - constructor(props: Props) { - super(props); - } - - public render() { - const background = this.props.state.background; - const style: any = {}; - let evenOdd: [Option<string>, Option<string>] = [null, null]; - if ("Alternating" in background) { - evenOdd = [ - colorToCss(background.Alternating[0]), - colorToCss(background.Alternating[1]), - ]; - } else { - style.background = gradientToCss(background.Same); - } - - const splitsState = { - has_icons: this.props.state.has_icons, - show_thin_separators: this.props.state.show_thin_separators, - display_two_rows: this.props.state.display_two_rows, - current_split_gradient: this.props.state.current_split_gradient - }; - - return ( - <div className="splits" style={style}> - { - map( - this.props.state.column_labels, - (labels) => <SplitLabels - labels={labels} - />, - ) - } - { - this.props.state.splits.map((s, i) => - <Split - evenOdd={evenOdd} - split={s} - splitsState={splitsState} - layoutUrlCache={this.props.layoutUrlCache} - key={s.index.toString()} - separatorInFrontOfSplit={ - (this.props.state.show_final_separator && - i + 1 === this.props.state.splits.length) - || (i === 0 && this.props.state.column_labels !== null) - } - visualSplitIndex={i} - />, - ) - } - </div> - ); - } -} diff --git a/src/layout/Text.tsx b/src/layout/Text.tsx deleted file mode 100644 index 5b7f715..0000000 --- a/src/layout/Text.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import * as React from "react"; -import * as LiveSplit from "../livesplit-core"; -import KeyValueGeneric, { KeyValueDisplay } from "./KeyValueGeneric"; - -import "../css/KeyValue.scss"; - -export interface Props { - state: LiveSplit.TextComponentStateJson, -} - -export default class Text extends React.Component<Props> { - constructor(props: Props) { - super(props); - } - - public render() { - const { text } = this.props.state; - - if ("Center" in text) { - return <KeyValueGeneric - display={KeyValueDisplay.Center} - keyColor={this.props.state.left_center_color} - keyText={text.Center} - keyAbbreviations={[]} - valueColor={null} - valueText={null} - wrapperBackground={this.props.state.background} - />; - } - - return <KeyValueGeneric - display={this.props.state.display_two_rows ? - KeyValueDisplay.SplitTwoRows : - KeyValueDisplay.SplitOneRow - } - keyColor={this.props.state.left_center_color} - keyText={text.Split[0]} - keyAbbreviations={[]} - valueColor={this.props.state.right_color} - valueText={text.Split[1]} - wrapperBackground={this.props.state.background} - />; - } -} diff --git a/src/layout/Timer.tsx b/src/layout/Timer.tsx deleted file mode 100644 index 25b8c41..0000000 --- a/src/layout/Timer.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import * as React from "react"; -import * as LiveSplit from "../livesplit-core"; -import { colorToCss, gradientToCss } from "../util/ColorUtil"; - -import variables from "../css/variables.scss"; -import "../css/Timer.scss"; - -const sidePadding = parseFloat(variables.sidePadding); - -export interface Props { - state: LiveSplit.TimerComponentStateJson, - componentId: string, - layoutWidth: number, -} - -export function renderToSVG( - state: LiveSplit.TimerComponentStateJson, - className: string, - componentWidth: number, -): JSX.Element { - const time = state.time; - const fraction = state.fraction; - const height = state.height; - - const y = `${0.82 * height}px`; - - const alignState: { x: string, element: SVGTextElement | null } = { - x: "", - element: null, - }; - - const updateAlign = () => { - alignState.element?.setAttribute("x", alignState.x); - }; - - return ( - <div - className="timer" - style={{ - background: gradientToCss(state.background), - }} - > - <svg - className={className} - height={`${height}px`} - style={{ - display: "block", - filter: "drop-shadow(2px 2px 1px rgba(0, 0, 0, 0.5))", - width: componentWidth, - }} - > - <defs> - <linearGradient id={`${className}-text-gradient`} x1="0%" x2="0%" y1="0%" y2="100%"> - <stop - offset="0%" - style={{ - stopColor: colorToCss(state.top_color), - }} - /> - <stop - offset="100%" - style={{ - stopColor: colorToCss(state.bottom_color), - }} - /> - </linearGradient> - </defs> - <text - ref={(instance) => { - alignState.element = instance; - updateAlign(); - }} - className="timer-time timer-font" - style={{ - fill: `url(#${className}-text-gradient)`, - fontSize: `${0.9 * height}px`, - fontVariant: "tabular-nums", - }} - y={y} - textAnchor="end" - > - {time} - </text> - <text - ref={(instance) => { - if (instance != null) { - alignState.x = `${componentWidth - sidePadding - instance.getComputedTextLength()}px`; - } - updateAlign(); - }} - className="timer-time timer-font" - style={{ - fill: `url(#${className}-text-gradient)`, - fontSize: `${0.6 * height}px`, - fontVariant: "tabular-nums", - }} - x={`${componentWidth - sidePadding}px`} - y={y} - textAnchor="end" - > - {fraction} - </text> - </svg> - </div> - ); -} - -export default class Timer extends React.Component<Props> { - public render() { - return renderToSVG( - this.props.state, - this.props.componentId, - this.props.layoutWidth, - ); - } -} diff --git a/src/layout/Title.tsx b/src/layout/Title.tsx deleted file mode 100644 index 8820f78..0000000 --- a/src/layout/Title.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import * as React from "react"; -import * as LiveSplit from "../livesplit-core"; -import { colorToCss, gradientToCss } from "../util/ColorUtil"; -import { map } from "../util/OptionUtil"; - -import "../css/Title.scss"; -import Abbreviated from "./Abbreviated"; -import { UrlCache } from "../util/UrlCache"; - -export interface Props { - state: LiveSplit.TitleComponentStateJson, - layoutUrlCache: UrlCache, -} - -export default class Title extends React.Component<Props> { - constructor(props: Props) { - super(props); - } - - public render() { - const iconUrl = this.props.layoutUrlCache.cache(this.props.state.icon); - - const finishedRunsExist = this.props.state.finished_runs !== null; - const attemptsExist = this.props.state.attempts !== null; - const line2 = this.props.state.line2; - const twoLines = line2.length !== 0; - const showIcon = iconUrl !== undefined; - const showAttempts = attemptsExist || finishedRunsExist; - - let attemptsLabel = ""; - if (finishedRunsExist) { - attemptsLabel += this.props.state.finished_runs; - if (attemptsExist) { - attemptsLabel += "/"; - } - } - if (attemptsExist) { - attemptsLabel += this.props.state.attempts; - } - - const alignmentClass = this.props.state.is_centered ? "centered" : "left-align"; - const numLinesClass = twoLines ? "two-lines" : "one-line"; - const iconClass = showIcon ? "show-icon" : ""; - - return ( - <div - className="title" - style={{ - background: gradientToCss(this.props.state.background), - color: map(this.props.state.text_color, colorToCss), - }} - > - { - showIcon && <div className="game-icon-container"> - <img className="title-icon" src={iconUrl} /> - </div> - } - <div - className={`run-info text-font ${alignmentClass} ${iconClass}`} - > - { - twoLines && <span className={`title-game ${alignmentClass}`}> - <Abbreviated abbreviations={this.props.state.line1} /> - </span> - } - <div className={`lower-row ${numLinesClass} ${alignmentClass}`} > - { - this.props.state.is_centered && showAttempts && - <div className={`title-attempts-invisible ${numLinesClass}`}> - <div className="title-text">{attemptsLabel}</div> - </div> - } - <div className={`title-category ${alignmentClass}`}> - <Abbreviated abbreviations={ - twoLines ? line2 : this.props.state.line1 - } /> - </div> - { - showAttempts && - <div className={`title-attempts ${numLinesClass}`}> - <div className="title-text">{attemptsLabel}</div> - </div> - } - </div> - </div> - </div> - ); - } -} diff --git a/src/storage/index.tsx b/src/storage/index.tsx index a135f3d..eeec0a7 100644 --- a/src/storage/index.tsx +++ b/src/storage/index.tsx @@ -6,6 +6,7 @@ export type HotkeyConfigSettings = unknown; export type LayoutSettings = unknown; const DEFAULT_LAYOUT_WIDTH = 300; +const DEFAULT_LAYOUT_HEIGHT = 500; let db: Option<IDBPDatabase<unknown>> = null; @@ -206,16 +207,20 @@ export async function loadHotkeys(): Promise<HotkeyConfigSettings | undefined> { return await db.get("settings", "hotkeys"); } -export async function storeLayoutWidth(layoutWidth: number) { +export async function storeLayoutDims(layoutWidth: number, layoutHeight: number) { const db = await getDb(); await db.put("settings", layoutWidth, "layoutWidth"); + await db.put("settings", layoutHeight, "layoutHeight"); } -export async function loadLayoutWidth(): Promise<number> { +export async function loadLayoutDims(): Promise<[number, number]> { const db = await getDb(); - return await db.get("settings", "layoutWidth") ?? DEFAULT_LAYOUT_WIDTH; + return [ + await db.get("settings", "layoutWidth") ?? DEFAULT_LAYOUT_WIDTH, + await db.get("settings", "layoutHeight") ?? DEFAULT_LAYOUT_HEIGHT, + ]; } export async function storeSplitsKey(splitsKey?: number) { diff --git a/src/ui/LayoutEditor.tsx b/src/ui/LayoutEditor.tsx index c124fb4..6533aa3 100644 --- a/src/ui/LayoutEditor.tsx +++ b/src/ui/LayoutEditor.tsx @@ -1,18 +1,22 @@ import * as React from "react"; import { ContextMenu, ContextMenuTrigger, MenuItem } from "react-contextmenu"; -import DragAutoRefreshLayout from "../layout/DragAutoRefreshLayout"; import * as LiveSplit from "../livesplit-core"; import { SettingsComponent } from "./Settings"; +import { UrlCache } from "../util/UrlCache"; +import Layout from "../layout/Layout"; +import { WebRenderer } from "../livesplit-core/livesplit_core"; import "../css/LayoutEditor.scss"; -import { UrlCache } from "../util/UrlCache"; export interface Props { editor: LiveSplit.LayoutEditor, + layoutState: LiveSplit.LayoutStateRefMut, layoutEditorUrlCache: UrlCache, layoutUrlCache: UrlCache, layoutWidth: number, + layoutHeight: number, timer: LiveSplit.SharedTimerRef, + renderer: WebRenderer, callbacks: Callbacks, } export interface State { @@ -21,6 +25,7 @@ export interface State { } interface Callbacks { + onResize(width: number, height: number): Promise<void>, renderViewWithSidebar(renderedView: JSX.Element, sidebarContent: JSX.Element): JSX.Element, closeLayoutEditor(save: boolean): void, } @@ -254,24 +259,20 @@ export class LayoutEditor extends React.Component<Props, State> { </div> </div> <div className="layout-container"> - <DragAutoRefreshLayout + <Layout getState={() => this.props.timer.readWith( (t) => { - const result = this.props.editor.layoutStateAsJson(this.props.layoutUrlCache.imageCache, t); + this.props.editor.updateLayoutState(this.props.layoutState, this.props.layoutUrlCache.imageCache, t); this.props.layoutUrlCache.collect(); - return result; - } + return this.props.layoutState; + }, )} layoutUrlCache={this.props.layoutUrlCache} - layoutWidth={this.props.layoutWidth} - onClick={(i) => this.selectComponent(i)} - onDrag={(i) => { - this.props.editor.select(i); - this.update(); - }} - onDragEnd={(_) => this.update()} - onDrop={(i) => this.props.editor.moveComponent(i)} - isSelected={(i) => this.state.editor.selected_component === i} + allowResize={true} + width={this.props.layoutWidth} + height={this.props.layoutHeight} + renderer={this.props.renderer} + onResize={(width, height) => this.props.callbacks.onResize(width, height)} /> </div> </div> diff --git a/src/ui/LayoutView.tsx b/src/ui/LayoutView.tsx index 19ecfd8..59fc7b2 100644 --- a/src/ui/LayoutView.tsx +++ b/src/ui/LayoutView.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { SharedTimer, Layout, LayoutStateRefMut } from "../livesplit-core"; import { TimerView } from "./TimerView"; import { UrlCache } from "../util/UrlCache"; +import { WebRenderer } from "../livesplit-core/livesplit_core"; export interface Props { isDesktop: boolean, @@ -9,9 +10,11 @@ export interface Props { layoutState: LayoutStateRefMut, layoutUrlCache: UrlCache, layoutWidth: number, + layoutHeight: number, renderWithSidebar: boolean, sidebarOpen: boolean, timer: SharedTimer, + renderer: WebRenderer, callbacks: Callbacks, } @@ -21,7 +24,7 @@ interface Callbacks { importLayoutFromFile(file: File): Promise<void>, importSplitsFromFile(file: File): Promise<void>, loadDefaultLayout(): void, - onResize(width: number): Promise<void>, + onResize(width: number, height: number): Promise<void>, openAboutView(): void, openLayoutEditor(): void, openLayoutView(): void, @@ -39,10 +42,12 @@ export class LayoutView extends React.Component<Props> { layoutState={this.props.layoutState} layoutUrlCache={this.props.layoutUrlCache} layoutWidth={this.props.layoutWidth} + layoutHeight={this.props.layoutHeight} isDesktop={this.props.isDesktop} renderWithSidebar={false} sidebarOpen={this.props.sidebarOpen} timer={this.props.timer} + renderer={this.props.renderer} callbacks={this.props.callbacks} />; const sidebarContent = this.renderSidebarContent(); diff --git a/src/ui/LiveSplit.tsx b/src/ui/LiveSplit.tsx index 632ccb2..10de1cb 100644 --- a/src/ui/LiveSplit.tsx +++ b/src/ui/LiveSplit.tsx @@ -17,10 +17,11 @@ import { SplitsSelection, EditingInfo } from "./SplitsSelection"; import { LayoutView } from "./LayoutView"; import { toast } from "react-toastify"; import * as Storage from "../storage"; +import { UrlCache } from "../util/UrlCache"; +import { WebRenderer } from "../livesplit-core/livesplit_core"; import "react-toastify/dist/ReactToastify.css"; import "../css/LiveSplit.scss"; -import { UrlCache } from "../util/UrlCache"; export enum MenuKind { Timer, @@ -46,6 +47,7 @@ export interface Props { layout?: Storage.LayoutSettings, hotkeys?: Storage.HotkeyConfigSettings, layoutWidth: number, + layoutHeight: number, splitsKey?: number, } @@ -59,12 +61,15 @@ export interface State { runEditorUrlCache: UrlCache, layoutEditorUrlCache: UrlCache, layoutWidth: number, + layoutHeight: number, menu: Menu, openedSplitsKey?: number, sidebarOpen: boolean, sidebarTransitionsEnabled: boolean, storedLayoutWidth: number, + storedLayoutHeight: number, timer: SharedTimer, + renderer: WebRenderer, } export let hotkeySystem: Option<HotkeySystem> = null; @@ -75,7 +80,7 @@ export class LiveSplit extends React.Component<Props, State> { const splits = splitsKey !== undefined ? await Storage.loadSplits(splitsKey) : undefined; const layout = await Storage.loadLayout(); const hotkeys = await Storage.loadHotkeys(); - const layoutWidth = await Storage.loadLayoutWidth(); + const [layoutWidth, layoutHeight] = await Storage.loadLayoutDims(); return { splits, @@ -83,6 +88,7 @@ export class LiveSplit extends React.Component<Props, State> { layout, hotkeys, layoutWidth, + layoutHeight, }; } @@ -149,6 +155,9 @@ export class LiveSplit extends React.Component<Props, State> { const isDesktop = this.isDesktopQuery.matches; const isBrowserSource = !!(window as any).obsstudio; + const renderer = WebRenderer.new(); + renderer.element().setAttribute("style", "width: inherit; height: inherit;"); + this.state = { isDesktop: isDesktop && !isBrowserSource, isBrowserSource, @@ -158,13 +167,16 @@ export class LiveSplit extends React.Component<Props, State> { runEditorUrlCache: new UrlCache(), layoutEditorUrlCache: new UrlCache(), layoutWidth: props.layoutWidth, + layoutHeight: props.layoutHeight, menu: { kind: MenuKind.Timer }, sidebarOpen: false, sidebarTransitionsEnabled: false, storedLayoutWidth: props.layoutWidth, + storedLayoutHeight: props.layoutHeight, timer, hotkeySystem, openedSplitsKey: props.splitsKey, + renderer, }; this.mediaQueryChanged = this.mediaQueryChanged.bind(this); @@ -237,10 +249,13 @@ export class LiveSplit extends React.Component<Props, State> { } else if (this.state.menu.kind === MenuKind.LayoutEditor) { return <LayoutEditorComponent editor={this.state.menu.editor} + layoutState={this.state.layoutState} layoutEditorUrlCache={this.state.layoutEditorUrlCache} layoutUrlCache={this.state.layoutUrlCache} layoutWidth={this.state.layoutWidth} + layoutHeight={this.state.layoutHeight} timer={this.state.timer} + renderer={this.state.renderer} callbacks={this} />; } else if (this.state.menu.kind === MenuKind.SettingsEditor) { @@ -263,10 +278,12 @@ export class LiveSplit extends React.Component<Props, State> { layoutState={this.state.layoutState} layoutUrlCache={this.state.layoutUrlCache} layoutWidth={this.state.layoutWidth} + layoutHeight={this.state.layoutHeight} isDesktop={this.state.isDesktop} renderWithSidebar={true} sidebarOpen={this.state.sidebarOpen} timer={this.state.timer} + renderer={this.state.renderer} callbacks={this} />; } else if (this.state.menu.kind === MenuKind.Layout) { @@ -275,10 +292,12 @@ export class LiveSplit extends React.Component<Props, State> { layoutState={this.state.layoutState} layoutUrlCache={this.state.layoutUrlCache} layoutWidth={this.state.layoutWidth} + layoutHeight={this.state.layoutHeight} isDesktop={this.state.isDesktop} renderWithSidebar={true} sidebarOpen={this.state.sidebarOpen} timer={this.state.timer} + renderer={this.state.renderer} callbacks={this} />; } @@ -492,12 +511,14 @@ export class LiveSplit extends React.Component<Props, State> { this.openTimerView(); } - public async onResize(width: number) { + public async onResize(width: number, height: number) { this.setState({ layoutWidth: width, + layoutHeight: height, storedLayoutWidth: width, + storedLayoutHeight: height, }); - await Storage.storeLayoutWidth(width); + await Storage.storeLayoutDims(width, height); } private getDefaultRun() { diff --git a/src/ui/TimerView.tsx b/src/ui/TimerView.tsx index 93958ff..0ab0564 100644 --- a/src/ui/TimerView.tsx +++ b/src/ui/TimerView.tsx @@ -1,28 +1,32 @@ import * as React from "react"; import { toast } from "react-toastify"; import { - SharedTimer, TimerRef, TimerRefMut, Layout, + SharedTimer, TimerRef, TimerRefMut, TimingMethod, TimeSpan, LayoutStateRefMut, } from "../livesplit-core"; +import * as LiveSplit from "../livesplit-core"; import { Option } from "../util/OptionUtil"; import DragUpload from "./DragUpload"; import AutoRefresh from "../util/AutoRefresh"; -import AutoRefreshLayout from "../layout/AutoRefreshLayout"; +import Layout from "../layout/Layout"; +import { UrlCache } from "../util/UrlCache"; +import { WebRenderer } from "../livesplit-core/livesplit_core"; import LiveSplitIcon from "../assets/icon.svg"; import "../css/TimerView.scss"; -import { UrlCache } from "../util/UrlCache"; export interface Props { isDesktop: boolean, - layout: Layout, + layout: LiveSplit.Layout, layoutState: LayoutStateRefMut, layoutUrlCache: UrlCache, layoutWidth: number, + layoutHeight: number, renderWithSidebar: boolean, sidebarOpen: boolean, timer: SharedTimer, + renderer: WebRenderer, callbacks: Callbacks, } export interface State { @@ -33,7 +37,7 @@ export interface State { interface Callbacks { importLayoutFromFile(file: File): Promise<void>, importSplitsFromFile(file: File): Promise<void>, - onResize(width: number): Promise<void>, + onResize(width: number, height: number): Promise<void>, openAboutView(): void, openLayoutView(): void, openSplitsView(): void, @@ -80,36 +84,38 @@ export class TimerView extends React.Component<Props, State> { cursor: "pointer", }} > - <AutoRefreshLayout + <Layout getState={() => this.readWith( (t) => { - const result = this.props.layout.updateStateAsJson(this.props.layoutState, this.props.layoutUrlCache.imageCache, t); + this.props.layout.updateState(this.props.layoutState, this.props.layoutUrlCache.imageCache, t); this.props.layoutUrlCache.collect(); - return result; + return this.props.layoutState; }, )} layoutUrlCache={this.props.layoutUrlCache} allowResize={this.props.isDesktop} width={this.props.layoutWidth} - onResize={(width) => this.props.callbacks.onResize(width)} + height={this.props.layoutHeight} + renderer={this.props.renderer} + onResize={(width, height) => this.props.callbacks.onResize(width, height)} /> </div> - <div className="buttons" style={{ width: this.props.layoutWidth }}> - <div className="small"> - <button aria-label="Undo Split" onClick={(_) => this.undoSplit()}> - <i className="fa fa-arrow-up" aria-hidden="true" /></button> - <button aria-label="Pause" onClick={(_) => this.togglePauseOrStart()}> - <i className="fa fa-pause" aria-hidden="true" /> - </button> - </div> - <div className="small"> - <button aria-label="Skip Split" onClick={(_) => this.skipSplit()}> - <i className="fa fa-arrow-down" aria-hidden="true" /> - </button> - <button aria-label="Reset" onClick={(_) => this.reset()}> - <i className="fa fa-times" aria-hidden="true" /> - </button> - </div> + </div> + <div className="buttons" style={{ width: this.props.layoutWidth }}> + <div className="small"> + <button aria-label="Undo Split" onClick={(_) => this.undoSplit()}> + <i className="fa fa-arrow-up" aria-hidden="true" /></button> + <button aria-label="Pause" onClick={(_) => this.togglePauseOrStart()}> + <i className="fa fa-pause" aria-hidden="true" /> + </button> + </div> + <div className="small"> + <button aria-label="Skip Split" onClick={(_) => this.skipSplit()}> + <i className="fa fa-arrow-down" aria-hidden="true" /> + </button> + <button aria-label="Reset" onClick={(_) => this.reset()}> + <i className="fa fa-times" aria-hidden="true" /> + </button> </div> </div> </DragUpload>; diff --git a/test/rendering-test.js b/test/rendering-test.js index 783f5ae..cbb136c 100644 --- a/test/rendering-test.js +++ b/test/rendering-test.js @@ -101,6 +101,10 @@ describe("Layout Rendering Tests", function () { await clickElement(By.xpath(".//button[contains(text(), 'Back')]")); const layoutElement = await findElement(By.className("layout")); + + // The icons load asynchronously, so we need to wait for them to load. + await driver.sleep(500); + const layoutScreenshot = await layoutElement.takeScreenshot(); const tempFilePath = `${SCREENSHOTS_FOLDER}/${layoutName}.png`; @@ -149,15 +153,15 @@ describe("Layout Rendering Tests", function () { }); } - testRendering("all_components", "default", "f4G-QgAAAAAeAAAO____________________AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA__wCAAAAAABv______4PZ4AG"); - testRendering("all_components", "pmw3", "f4G-QgAAAAAeAAAO________fmAOf6AAX4AG3_gH3_AOX_APVkgOX-AOVvAPX_AeX_AeWrAeb_gfAAAAAABv__x-____JABO"); - testRendering("default", "default", "____________AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAD_________"); - testRendering("default", "pmw3", "__wA___GX-AG3-gGUNgGQkAOXnAOW9gOX-APVtwOH_APX8AOX_APRnAOXVAOXJAeHvAeX_wf__AfAAAAAABgAAD_Pz9_____"); - testRendering("splits_two_rows", "celeste", "f8AAcAAHf8AA____d4AAcAAHf_gAcAAPd_AAYAAHf4AA____f4AAYAAH__AA____Z_gAYAAHf_8A9VVfb_wAcAAPf_AAYAAP"); - testRendering("splits_with_labels", "celeste", "AABEAADPAADPAADPAADA____fAAH_wAP_wAP_wAPxgAA____wgAH34AP_4AH34APQYAA____0EAP_8AP38AP38APQAAAAAAA"); - testRendering("title_centered_no_game_icon", "celeste", "________AAAAADQAAHQAAH4A______3_____AAAAAAAAAAAA____AAkAAL8DAP8DAP8DAP8D_________-n9AAAAAAAAAAAA"); - testRendering("title_centered_with_game_icon", "celeste", "________AAAAQBoAQBoAYB8A________9V-U4AAA4AAA4AAA4AAA4ASA4H_D4H_D4H_D4H_D_________9T84AAAAAAAAAAA"); - testRendering("title_left_no_attempt_count", "celeste", "________AAAA-AAA-AAA-AAA____________AAAAAAAAAAAA____rVKV_wAA_4AA_4AA_4AA________qf__AAAAAAAAAAAA"); + testRendering("all_components", "default", "PwD-XAAAAAA2AAAO____________________AAAAAAAAAAAA____AAAA____AAAA____AAAAf_AAd_AAAAD-Nz___34HbwAG"); + testRendering("all_components", "pmw3", "PwD-XAAAAAA2AAAO_________sAPf8AAf4AC3_AH_-AOX-APXfAGX-AP18AOf-APXeAOX-AOD7AOQIAIAAD-b_j_____LwBs"); + testRendering("default", "default", "________8AADVVVVAAAAAAAAAAAA____________AAAAAAAAAAAA____VVVVAAAA____AAAAVVVVAAAAAADgAAD_________"); + testRendering("default", "pmw3", "b_gAb9-HV8AG__AHV_AG7kAGX-AGV-AOX8APX_gOX-AP28AO_-APX-APXeAOUQAOX-AOX_gP_-AfAAAAAADgAAD_q97_____"); + testRendering("splits_two_rows", "celeste", "b4AAYAAH________bwAA4AAHb-AA4AAHb-AAYAAHbwAA____bwAAYAAH_9VV____b_AAYAAH__wA____b_AAYAAPb8AAYAAP"); + testRendering("splits_with_labels", "celeste", "AAAAAABMAADnAADnAABH________WAAHfwAHfwAHTwAHVVVVAAAA0gAH_wAH_wAH3wAH_________IAP34AP34AP38APAAAA"); + testRendering("title_centered_no_game_icon", "celeste", "________MzMzADAAADwAADwA________VX1VADwAAAAAAAAA____ABkAAP8DAP8DAP8DAP8D________V_1XAGAAAAAAAAAA"); + testRendering("title_centered_with_game_icon", "celeste", "________AiIiQDAAYDwAYDwA________YDwAYDwAYAAAYAAAd393YBkAYL8DYP8DYP8DYP8D________d_1XYGAAAAAAAAAA"); + testRendering("title_left_no_attempt_count", "celeste", "________MzMz4AAA8AAA-AAA_________VVV-AAAAAAAAAAA____GQAA_wAA_wAA_wAA_wAA________f_1XIAAAAAAAAAAA"); after(async () => { await driver.quit();