Skip to content
This repository was archived by the owner on Nov 20, 2025. It is now read-only.

Commit dec14c0

Browse files
Much cleaner theme application impl
1 parent a7781f8 commit dec14c0

24 files changed

Lines changed: 494 additions & 605 deletions

File tree

sandbox-ui/src/main/scala/example/Model.scala

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -37,19 +37,21 @@ object Model:
3737
TopNav.initial,
3838
Input(UIKey("name-input"))
3939
.withPlaceholder("Type here...")
40-
.withTextColor(RGBA.fromHex("#1f2937"))
41-
.withBackgroundColor(RGBA.fromHex("#f9fafb"))
42-
.solidBorder(BorderWidth.Thin, RGBA.fromHex("#d1d5db"))
43-
.rounded
44-
.withPadding(Spacing.Small)
40+
.withThemeOverride(
41+
_.withTextColor(RGBA.fromHex("#1f2937"))
42+
.withBackgroundColor(RGBA.fromHex("#f9fafb"))
43+
.solidBorder(BorderWidth.Thin, RGBA.fromHex("#d1d5db"))
44+
.rounded
45+
.withPadding(Spacing.Small)
46+
)
4547
)
4648

4749
def tmpView(m: Model)(using Theme): HtmlFragment =
4850
HtmlFragment(
4951
Row(
5052
Column(
5153
TextBlock("Welcome to Tyrian UI!").toHeading1
52-
.withColor(RGBA.fromHex("#2563eb")),
54+
.withThemeOverride(_.withTextColor(RGBA.fromHex("#2563eb"))),
5355
Row(
5456
Column(
5557
TextBlock("Your name:"),
@@ -58,12 +60,12 @@ object Model:
5860
)
5961
).withSpacing(Spacing.Small),
6062
Row(
61-
TextBlock("Hello, Tyrian!").withColor(RGBA.Blue),
62-
TextBlock("More text").withColor(RGBA.Red.mix(RGBA.Blue))
63+
TextBlock("Hello, Tyrian!").withThemeOverride(_.withTextColor(RGBA.Blue)),
64+
TextBlock("More text").withThemeOverride(_.withTextColor(RGBA.Red.mix(RGBA.Blue)))
6365
)
6466
.withSpacing(Spacing.Medium),
6567
TextBlock("This is just some text")
66-
.withColor(RGBA.fromHex("#6b7280")),
68+
.withThemeOverride(_.withTextColor(RGBA.fromHex("#6b7280"))),
6769
HtmlElement(
6870
tyrian.Html.div(
6971
tyrian.Html.style := "border: 2px dashed #ccc; padding: 1rem; border-radius: 4px; margin: 1rem 0;"
@@ -80,22 +82,25 @@ object Model:
8082
TextBlock("This is some more text.")
8183
).middle.center
8284
.withPadding(Spacing.Large)
83-
.rounded
84-
.solidBorder(BorderWidth.Medium, RGBA.fromHex("#10b981"))
85-
.shadowMedium(RGBA.fromHex("#00000040"))
86-
.withBackgroundColor(RGBA.fromHex("#ecfdf5"))
87-
.withOpacity(Opacity.High),
85+
.withThemeOverride(
86+
_.rounded
87+
.solidBorder(BorderWidth.Medium, RGBA.fromHex("#10b981"))
88+
.shadowMedium(RGBA.fromHex("#00000040"))
89+
.withBackgroundColor(RGBA.fromHex("#ecfdf5"))
90+
.withOpacity(Opacity.High)
91+
),
8892
Image(
8993
"https://raw.githubusercontent.com/PurpleKingdomGames/roguelike-starterkit/417f4e372b4792972ef62aea0c917088a9fc82fd/roguelike.gif",
9094
"Roguelike"
91-
)
92-
.withSize(Extent.px(300), Extent.px(100))
95+
).withSize(Extent.px(300), Extent.px(100))
9396
.scaleDown
94-
.rounded
95-
.solidBorder(BorderWidth.Medium, RGBA.fromHex("#2563eb"))
96-
.shadowLarge(RGBA.fromHex("#00000080"))
97-
.withBackgroundColor(RGBA.fromHex("#fbbf24"))
98-
.withOpacity(Opacity.Medium)
97+
.withThemeOverride(
98+
_.rounded
99+
.solidBorder(BorderWidth.Medium, RGBA.fromHex("#2563eb"))
100+
.shadowLarge(RGBA.fromHex("#00000080"))
101+
.withBackgroundColor(RGBA.fromHex("#fbbf24"))
102+
.withOpacity(Opacity.Medium)
103+
)
99104
)
100105
)
101106
.withSpacing(Spacing.Large)

tyrian-next/src/main/scala/tyrian/next/HtmlFragment.scala

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,14 +111,14 @@ object HtmlFragment:
111111
/** Creates an HtmlFragment containing only top-level ui elements, with no marker inserts. The elements will be
112112
* rendered directly as part of the fragment's markup when resolved by an HtmlRoot.
113113
*/
114-
def apply(markup: Batch[UIElement[?]])(using Theme): HtmlFragment =
115-
HtmlFragment(markup.map(_.view), emptyInserts)
114+
def apply(markup: Batch[UIElement[?, ?]])(using Theme): HtmlFragment =
115+
HtmlFragment(markup.map(_.toElem), emptyInserts)
116116

117117
/** Creates an HtmlFragment from a variable number of ui elements, converting them into a Batch internally. This is
118118
* the most common way to create fragments when you have individual elements to include.
119119
*/
120-
def apply(markup: UIElement[?]*)(using Theme): HtmlFragment =
121-
HtmlFragment(Batch.fromSeq(markup).map(_.view), emptyInserts)
120+
def apply(markup: UIElement[?, ?]*)(using Theme): HtmlFragment =
121+
HtmlFragment(Batch.fromSeq(markup).map(_.toElem), emptyInserts)
122122

123123
/** Creates an HtmlFragment that contains only insert data for a specific marker, with no top-level markup. When
124124
* resolved, these elements will replace any Marker with the matching MarkerId in the final DOM tree.
@@ -136,12 +136,12 @@ object HtmlFragment:
136136
* markup. When resolved, these elements will replace any Marker (or Placeholder if using Tyrian-UI) with the
137137
* matching MarkerId in the final DOM tree.
138138
*/
139-
def insert(at: MarkerId, elements: Batch[UIElement[?]])(using Theme): HtmlFragment =
140-
HtmlFragment(Batch.empty, Map(at -> elements.map(_.view)))
139+
def insert(at: MarkerId, elements: Batch[UIElement[?, ?]])(using Theme): HtmlFragment =
140+
HtmlFragment(Batch.empty, Map(at -> elements.map(_.toElem)))
141141

142142
/** Creates an HtmlFragment that contains only insert data for a specific marker / placeholder, with no top-level
143143
* markup. When resolved, these elements will replace any Marker (or Placeholder if using Tyrian-UI) with the
144144
* matching MarkerId in the final DOM tree.
145145
*/
146-
def insert(at: MarkerId, elements: UIElement[?]*)(using Theme): HtmlFragment =
147-
insert(at, Batch.fromSeq(elements).map(_.view))
146+
def insert(at: MarkerId, elements: UIElement[?, ?]*)(using Theme): HtmlFragment =
147+
insert(at, Batch.fromSeq(elements).map(_.toElem))

tyrian-next/src/main/scala/tyrian/ui/UIElement.scala

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,53 @@ package tyrian.ui
22

33
import tyrian.next.GlobalMsg
44
import tyrian.next.Outcome
5+
import tyrian.ui.utils.Lens
56

6-
trait UIElement[T]:
7+
trait UIElement[Component, ComponentTheme]:
78

89
def classNames: Set[String]
9-
def withClassNames(classes: Set[String]): T
10-
def withClassNames(classes: String*): T = withClassNames(classes.toSet)
11-
def addClassNames(classes: Set[String]): T = withClassNames(classNames ++ classes)
12-
def addClassNames(classes: String*): T = addClassNames(classes.toSet)
10+
def withClassNames(classes: Set[String]): Component
11+
def withClassNames(classes: String*): Component = withClassNames(classes.toSet)
12+
def addClassNames(classes: Set[String]): Component = withClassNames(classNames ++ classes)
13+
def addClassNames(classes: String*): Component = addClassNames(classes.toSet)
14+
15+
def themeOverride: Option[ComponentTheme => ComponentTheme]
16+
def themeLens: Lens[Theme, ComponentTheme]
17+
def withThemeOverride(f: ComponentTheme => ComponentTheme): Component
18+
19+
/** *Should not be called directly.* User provided implementation of a function to render the UIElement into a Tyrian
20+
* Elem[GlobalMsg] with the given theme, however, the correct way to render a UIElement is to call `toElem`, which
21+
* applies the theme overrides.
22+
*/
23+
def view: Theme ?=> tyrian.Elem[GlobalMsg]
1324

14-
def overrideLocalTheme: Option[Theme => Theme]
15-
def withThemeOverride(f: Theme => Theme): T
25+
/** Renders the current element to into a Tyrian Elem[GlobalMsg] with the given theme and theme overrides.
26+
*/
27+
def toElem: Theme ?=> tyrian.Elem[GlobalMsg] =
28+
val overriddenTheme =
29+
applyThemeOverrides(summon[Theme])
1630

17-
def view: Theme ?=> tyrian.Elem[GlobalMsg]
31+
view(using overriddenTheme)
32+
33+
/** An implementation detail, left open for testing purposes. Allows you to see how a given Theme will be modified by
34+
* the UIElement
35+
*/
36+
def applyThemeOverrides(theme: Theme): Theme =
37+
themeOverride match
38+
case Some(g) =>
39+
themeLens.set(theme, g(themeLens.get(theme)))
40+
41+
case None =>
42+
theme
1843

1944
object UIElement:
2045

21-
trait Stateful[T] extends UIElement[T]:
46+
trait Stateful[Component, ComponentTheme] extends UIElement[Component, ComponentTheme]:
2247

2348
def key: UIKey
24-
def withKey(value: UIKey): T
49+
def withKey(value: UIKey): Component
2550

26-
def update: GlobalMsg => Outcome[T]
51+
def update: GlobalMsg => Outcome[Component]
2752

2853
/*
2954

tyrian-next/src/main/scala/tyrian/ui/elements/stateful/input/Input.scala

Lines changed: 12 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,8 @@ import tyrian.next.Outcome
88
import tyrian.ui
99
import tyrian.ui.UIElement
1010
import tyrian.ui.UIKey
11-
import tyrian.ui.datatypes.Border
12-
import tyrian.ui.datatypes.BorderRadius
13-
import tyrian.ui.datatypes.BorderWidth
14-
import tyrian.ui.datatypes.FontSize
15-
import tyrian.ui.datatypes.FontWeight
16-
import tyrian.ui.datatypes.RGBA
17-
import tyrian.ui.datatypes.Spacing
1811
import tyrian.ui.theme.Theme
12+
import tyrian.ui.utils.Lens
1913

2014
final case class Input(
2115
key: UIKey,
@@ -24,8 +18,8 @@ final case class Input(
2418
isReadOnly: Boolean,
2519
value: String,
2620
classNames: Set[String],
27-
overrideLocalTheme: Option[Theme => Theme]
28-
) extends UIElement.Stateful[Input]:
21+
themeOverride: Option[InputTheme => InputTheme]
22+
) extends UIElement.Stateful[Input, InputTheme]:
2923

3024
def withPlaceholder(placeholder: String): Input =
3125
this.copy(placeholder = placeholder)
@@ -53,69 +47,14 @@ final case class Input(
5347
def withClassNames(classes: Set[String]): Input =
5448
this.copy(classNames = classes)
5549

56-
def withThemeOverride(f: Theme => Theme): Input =
57-
val h =
58-
overrideLocalTheme match
59-
case Some(g) => f andThen g
60-
case None => f
61-
62-
this.copy(overrideLocalTheme = Some(h))
63-
64-
def overrideInputTheme(f: InputTheme => InputTheme): Input =
65-
val g: Theme => Theme = theme => theme.copy(input = f(theme.input))
66-
withThemeOverride(g)
67-
68-
def withFontSize(size: FontSize): Input =
69-
overrideInputTheme(_.withFontSize(size))
70-
71-
def withFontWeight(weight: FontWeight): Input =
72-
overrideInputTheme(_.withFontWeight(weight))
73-
74-
def withTextColor(color: RGBA): Input =
75-
overrideInputTheme(_.withTextColor(color))
76-
77-
def withBackgroundColor(color: RGBA): Input =
78-
overrideInputTheme(_.withBackgroundColor(color))
79-
80-
def withBorder(border: Border): Input =
81-
overrideInputTheme(_.withBorder(border))
82-
def noBorder: Input =
83-
overrideInputTheme(_.noBorder)
84-
def modifyBorder(f: Border => Border): Input =
85-
overrideInputTheme(_.modifyBorder(f))
86-
def solidBorder(width: BorderWidth, color: RGBA): Input =
87-
overrideInputTheme(_.solidBorder(width, color))
88-
def dashedBorder(width: BorderWidth, color: RGBA): Input =
89-
overrideInputTheme(_.dashedBorder(width, color))
90-
91-
def withBorderColor(color: RGBA): Input =
92-
overrideInputTheme(_.modifyBorder(_.withColor(color)))
93-
94-
def withBorderRadius(radius: BorderRadius): Input =
95-
overrideInputTheme(_.withBorderRadius(radius))
96-
97-
def square: Input =
98-
overrideInputTheme(_.square)
99-
def rounded: Input =
100-
overrideInputTheme(_.rounded)
101-
def roundedSmall: Input =
102-
overrideInputTheme(_.roundedSmall)
103-
def roundedLarge: Input =
104-
overrideInputTheme(_.roundedLarge)
105-
def circular: Input =
106-
overrideInputTheme(_.circular)
107-
108-
def withPadding(padding: Spacing): Input =
109-
overrideInputTheme(_.withPadding(padding))
110-
111-
def withDisabledBackgroundColor(value: RGBA): Input =
112-
overrideInputTheme(_.withDisabledBackgroundColor(value))
113-
114-
def withDisabledTextColor(value: RGBA): Input =
115-
overrideInputTheme(_.withDisabledTextColor(value))
50+
def themeLens: Lens[Theme, InputTheme] =
51+
Lens(
52+
_.input,
53+
(t, i) => t.copy(input = i)
54+
)
11655

117-
def withDisabledBorderColor(value: RGBA): Input =
118-
overrideInputTheme(_.withDisabledBorderColor(value))
56+
def withThemeOverride(f: InputTheme => InputTheme): Input =
57+
this.copy(themeOverride = Some(f))
11958

12059
def update: GlobalMsg => Outcome[Input] =
12160
case TextInputMsg.Changed(_key, v) if _key == key =>
@@ -128,11 +67,8 @@ final case class Input(
12867
Outcome(this)
12968

13069
def view: Theme ?=> Elem[GlobalMsg] =
131-
val theme = summon[Theme]
132-
133-
val inputTheme = overrideLocalTheme match
134-
case Some(f) => f(theme).input
135-
case None => theme.input
70+
val theme = summon[Theme]
71+
val inputTheme = theme.input
13672

13773
val disabledAttr =
13874
if isDisabled then attribute("disabled", "true")

tyrian-next/src/main/scala/tyrian/ui/elements/stateful/input/InputTheme.scala

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,21 +53,24 @@ final case class InputTheme(
5353
def dashedBorder(width: BorderWidth, color: RGBA): InputTheme =
5454
modifyBorder(_.withStyle(BorderStyle.Dashed).withWidth(width).withColor(color))
5555

56-
def withBorderRadius(radius: BorderRadius): InputTheme =
57-
withBorder(
58-
border match
59-
case Some(b) => b.withRadius(radius)
60-
case None => Border.default.withRadius(radius)
61-
)
6256
def square: InputTheme = withBorderRadius(BorderRadius.None)
6357
def rounded: InputTheme = withBorderRadius(BorderRadius.Medium)
6458
def roundedSmall: InputTheme = withBorderRadius(BorderRadius.Small)
6559
def roundedLarge: InputTheme = withBorderRadius(BorderRadius.Large)
6660
def circular: InputTheme = withBorderRadius(BorderRadius.Full)
6761

62+
def withBorderRadius(radius: BorderRadius): InputTheme =
63+
modifyBorder(_.withRadius(radius))
64+
6865
def withBorderColor(value: RGBA): InputTheme =
6966
modifyBorder(_.withColor(value))
7067

68+
def withBorderWidth(value: BorderWidth): InputTheme =
69+
modifyBorder(_.withWidth(value))
70+
71+
def withBorderStyle(value: BorderStyle): InputTheme =
72+
modifyBorder(_.withStyle(value))
73+
7174
def withPadding(value: Spacing): InputTheme =
7275
this.copy(padding = value)
7376

tyrian-next/src/main/scala/tyrian/ui/elements/stateless/html/HtmlElement.scala

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,25 @@ package tyrian.ui.elements.stateless.html
33
import tyrian.next.GlobalMsg
44
import tyrian.ui.UIElement
55
import tyrian.ui.theme.Theme
6+
import tyrian.ui.utils.Lens
67

78
final case class HtmlElement(
89
html: tyrian.Elem[GlobalMsg],
910
classNames: Set[String],
10-
overrideLocalTheme: Option[Theme => Theme]
11-
) extends UIElement[HtmlElement]:
11+
themeOverride: Option[Unit => Unit]
12+
) extends UIElement[HtmlElement, Unit]:
1213

1314
def withHtml(html: tyrian.Elem[GlobalMsg]): HtmlElement =
1415
this.copy(html = html)
1516

1617
def withClassNames(classes: Set[String]): HtmlElement =
1718
this.copy(classNames = classes)
1819

19-
def withThemeOverride(f: Theme => Theme): HtmlElement =
20-
this.copy(overrideLocalTheme = Some(f))
20+
def themeLens: Lens[Theme, Unit] =
21+
Lens.unit
22+
23+
def withThemeOverride(f: Unit => Unit): HtmlElement =
24+
this
2125

2226
def view: Theme ?=> tyrian.Elem[GlobalMsg] =
2327
HtmlElement.toHtml(this)
@@ -31,21 +35,21 @@ object HtmlElement:
3135
HtmlElement(
3236
html = html,
3337
classNames = Set.empty,
34-
overrideLocalTheme = None
38+
themeOverride = None
3539
)
3640

3741
def raw(htmlString: String): HtmlElement =
3842
HtmlElement(
3943
html = div().innerHtml(htmlString),
4044
classNames = Set.empty,
41-
overrideLocalTheme = None
45+
themeOverride = None
4246
)
4347

4448
def text(content: String): HtmlElement =
4549
HtmlElement(
4650
html = span(content),
4751
classNames = Set.empty,
48-
overrideLocalTheme = None
52+
themeOverride = None
4953
)
5054

5155
def toHtml(element: HtmlElement): tyrian.Elem[GlobalMsg] =

0 commit comments

Comments
 (0)