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

Commit b1b490c

Browse files
More theme management massaging
1 parent f43d460 commit b1b490c

27 files changed

Lines changed: 500 additions & 221 deletions

File tree

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ object Model:
3737
TopNav.initial,
3838
Input(UIKey("name-input"))
3939
.withPlaceholder("Type here...")
40-
.withThemeOverride(
40+
.overrideTheme(
4141
_.withTextColor(RGBA.fromHex("#1f2937"))
4242
.withBackgroundColor(RGBA.fromHex("#f9fafb"))
4343
.solidBorder(BorderWidth.Thin, RGBA.fromHex("#d1d5db"))
@@ -51,7 +51,7 @@ object Model:
5151
Row(
5252
Column(
5353
TextBlock("Welcome to Tyrian UI!").toHeading1
54-
.withThemeOverride(_.withTextColor(RGBA.fromHex("#2563eb"))),
54+
.overrideTheme(_.withTextColor(RGBA.fromHex("#2563eb"))),
5555
Row(
5656
Column(
5757
TextBlock("Your name:"),
@@ -60,12 +60,12 @@ object Model:
6060
)
6161
).withSpacing(Spacing.Small),
6262
Row(
63-
TextBlock("Hello, Tyrian!").withThemeOverride(_.withTextColor(RGBA.Blue)),
64-
TextBlock("More text").withThemeOverride(_.withTextColor(RGBA.Red.mix(RGBA.Blue)))
63+
TextBlock("Hello, Tyrian!").overrideTheme(_.withTextColor(RGBA.Blue)),
64+
TextBlock("More text").overrideTheme(_.withTextColor(RGBA.Red.mix(RGBA.Blue)))
6565
)
6666
.withSpacing(Spacing.Medium),
6767
TextBlock("This is just some text")
68-
.withThemeOverride(_.withTextColor(RGBA.fromHex("#6b7280"))),
68+
.overrideTheme(_.withTextColor(RGBA.fromHex("#6b7280"))),
6969
HtmlElement(
7070
tyrian.Html.div(
7171
tyrian.Html.style := "border: 2px dashed #ccc; padding: 1rem; border-radius: 4px; margin: 1rem 0;"
@@ -82,7 +82,7 @@ object Model:
8282
TextBlock("This is some more text.")
8383
).middle.center
8484
.withPadding(Spacing.Large)
85-
.withThemeOverride(
85+
.overrideTheme(
8686
_.rounded
8787
.solidBorder(BorderWidth.Medium, RGBA.fromHex("#10b981"))
8888
.shadowMedium(RGBA.fromHex("#00000040"))
@@ -94,7 +94,7 @@ object Model:
9494
"Roguelike"
9595
).withSize(Extent.px(300), Extent.px(100))
9696
.scaleDown
97-
.withThemeOverride(
97+
.overrideTheme(
9898
_.rounded
9999
.solidBorder(BorderWidth.Medium, RGBA.fromHex("#2563eb"))
100100
.shadowLarge(RGBA.fromHex("#00000080"))

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

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package tyrian.ui
22

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

78
trait UIElement[Component, ComponentTheme]:
@@ -12,13 +13,19 @@ trait UIElement[Component, ComponentTheme]:
1213
def addClassNames(classes: Set[String]): Component = withClassNames(classNames ++ classes)
1314
def addClassNames(classes: String*): Component = addClassNames(classes.toSet)
1415

15-
def themeOverride: Option[ComponentTheme => ComponentTheme]
16-
def themeLens: Lens[Theme.Styles, ComponentTheme]
17-
def withThemeOverride(f: ComponentTheme => ComponentTheme): Component
16+
def themeOverride: ThemeOverride[ComponentTheme]
17+
def themeLens: Lens[Theme.Default, ComponentTheme]
18+
def withThemeOverride(value: ThemeOverride[ComponentTheme]): Component
19+
def noTheme: Component =
20+
withThemeOverride(ThemeOverride.NoTheme)
21+
def useDefaultTheme: Component =
22+
withThemeOverride(ThemeOverride.NoOverride)
23+
def overrideTheme(modify: ComponentTheme => ComponentTheme): Component =
24+
withThemeOverride(ThemeOverride.Override(modify))
1825

1926
/** *Should not be called directly.* User provided implementation of a function to render the UIElement into a Tyrian
2027
* Elem[GlobalMsg] with the given theme, however, the correct way to render a UIElement is to call `toElem`, which
21-
* applies the theme overrides.
28+
* applies any theme overrides.
2229
*/
2330
def view: Theme ?=> tyrian.Elem[GlobalMsg]
2431

@@ -27,25 +34,25 @@ trait UIElement[Component, ComponentTheme]:
2734
def toElem: Theme ?=> tyrian.Elem[GlobalMsg] =
2835
val overriddenTheme =
2936
summon[Theme] match
30-
case t @ Theme.NoStyles =>
37+
case t @ Theme.None =>
3138
t
3239

33-
case t: Theme.Styles =>
40+
case t: Theme.Default =>
3441
applyThemeOverrides(t)
3542

3643
view(using overriddenTheme)
3744

38-
/** An implementation detail, left open for testing purposes. Allows you to see how a given Theme will be modified by
39-
* the UIElement
40-
*/
41-
def applyThemeOverrides(theme: Theme.Styles): Theme.Styles =
45+
def applyThemeOverrides(theme: Theme.Default): Theme =
4246
themeOverride match
43-
case Some(g) =>
44-
themeLens.set(theme, g(themeLens.get(theme)))
47+
case ThemeOverride.RemoveTheme() =>
48+
Theme.None
4549

46-
case None =>
50+
case ThemeOverride.DoNotOverride() =>
4751
theme
4852

53+
case ThemeOverride.Override[ComponentTheme](modify) =>
54+
themeLens.set(theme, modify(themeLens.get(theme)))
55+
4956
object UIElement:
5057

5158
trait Stateful[Component, ComponentTheme] extends UIElement[Component, ComponentTheme]:
@@ -57,10 +64,19 @@ object UIElement:
5764

5865
/*
5966
67+
TODOs
68+
6069
Theme
6170
6271
- NoStyles
6372
73+
Theme / Style Performance
74+
75+
- Font styles inherit, and they're very chunky. So what we could do is track
76+
what's been set so far down any given rendering branch, and only set the style
77+
tag for the changes. Might mean setting font details on Containers?
78+
79+
- The themes are a bit mixed at the moment. Review which fields should and shouldn't be optional to avoid needlessly writing out default styles. Also some styles might be set, but if they're the default, don't render.
6480
---
6581
6682
Stateless Components
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package tyrian.ui.datatypes
2+
3+
enum FontFamily derives CanEqual:
4+
case Stack(stack: FontStack)
5+
case CSS(value: String)
6+
7+
def toCSSValue: String =
8+
this match
9+
case Stack(fonts) => fonts.toCSSValue
10+
case CSS(value) => value
11+
12+
object FontFamily:
13+
14+
def fromCSS(css: String): FontFamily =
15+
FontFamily.CSS(css)
16+
17+
def apply(stack: FontStack): FontFamily =
18+
FontFamily.Stack(stack)
19+
20+
def apply(primary: FontName, fallbacks: FontName*): FontFamily =
21+
FontFamily.Stack(FontStack(primary, fallbacks.toList))
22+
23+
// Pre-computed sans-serif font family stack
24+
val sansSerif: FontFamily =
25+
FontFamily.CSS(
26+
FontFamily.Stack(FontStack.sansSerif).toCSSValue
27+
)
28+
29+
// Pre-computed serif font family stack
30+
val serif: FontFamily =
31+
FontFamily.CSS(
32+
FontFamily.Stack(FontStack.serif).toCSSValue
33+
)
34+
35+
// Pre-computed monospace font family stack
36+
val monospace: FontFamily =
37+
FontFamily.CSS(
38+
FontFamily.Stack(FontStack.monospace).toCSSValue
39+
)
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package tyrian.ui.datatypes
2+
3+
final case class FontName private (name: String, safeName: String):
4+
def toCSSValue: String =
5+
safeName
6+
7+
object FontName:
8+
9+
def apply(name: String): FontName =
10+
FontName(name, makeSafe(name))
11+
12+
private def makeSafe(s: String): String =
13+
val hasNonStandardChars = """^[a-zA-Z0-9\-]+$""".r
14+
if hasNonStandardChars.findFirstIn(s).isEmpty then s"'${s.replace("'", "\\'")}'"
15+
else s
16+
17+
// Sans-serif
18+
val systemUi: FontName = FontName("system-ui")
19+
val uiSansSerif: FontName = FontName("ui-sans-serif")
20+
val appleSystem: FontName = FontName("-apple-system") // macOS / iOS
21+
val segoeUiVariable: FontName = FontName("Segoe UI Variable")
22+
val segoeUi: FontName = FontName("Segoe UI") // Windows
23+
val roboto: FontName = FontName("Roboto") // Android / ChromeOS
24+
val ubuntu: FontName = FontName("Ubuntu")
25+
val cantarell: FontName = FontName("Cantarell")
26+
val notoSans: FontName = FontName("Noto Sans")
27+
val helveticaNeue: FontName = FontName("Helvetica Neue")
28+
val helvetica: FontName = FontName("Helvetica") // Older macOS
29+
val liberationSans: FontName = FontName("Liberation Sans")
30+
val arial: FontName = FontName("Arial")
31+
val appleColorEmoji: FontName = FontName("Apple Color Emoji")
32+
val segoeUiEmoji: FontName = FontName("Segoe UI Emoji")
33+
val notoColorEmoji: FontName = FontName("Noto Color Emoji")
34+
val sansSerif: FontName = FontName("sans-serif")
35+
36+
// Serif
37+
val uiSerif: FontName = FontName("ui-serif")
38+
val georgia: FontName = FontName("Georgia")
39+
val cambria: FontName = FontName("Cambria")
40+
val timesNewRoman: FontName = FontName("Times New Roman")
41+
val times: FontName = FontName("Times")
42+
val notoSerif: FontName = FontName("Noto Serif")
43+
val liberationSerif: FontName = FontName("Liberation Serif")
44+
val serif: FontName = FontName("serif")
45+
46+
// Monospace
47+
val uiMonospace: FontName = FontName("ui-monospace")
48+
val sfMonoRegular: FontName = FontName("SFMono-Regular") /* macOS */
49+
val menlo: FontName = FontName("Menlo")
50+
val monaco: FontName = FontName("Monaco")
51+
val consolas: FontName = FontName("Consolas")
52+
val cascadiaMono: FontName = FontName("Cascadia Mono")
53+
val liberationMono: FontName = FontName("Liberation Mono")
54+
val dejavuSansMono: FontName = FontName("DejaVu Sans Mono")
55+
val notoMono: FontName = FontName("Noto Mono")
56+
val courierNew: FontName = FontName("Courier New")
57+
val monospace: FontName = FontName("monospace")
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package tyrian.ui.datatypes
2+
3+
final case class FontStack(primary: FontName, fallbacks: List[FontName]):
4+
def toList: List[FontName] =
5+
primary :: fallbacks
6+
7+
def toCSSValue: String =
8+
(primary :: fallbacks).map(_.toCSSValue).mkString(", ")
9+
10+
object FontStack:
11+
def apply(primary: FontName): FontStack =
12+
FontStack(primary, Nil)
13+
14+
def apply(primary: FontName, fallbacks: FontName*): FontStack =
15+
FontStack(primary, fallbacks.toList)
16+
17+
val sansSerif: FontStack =
18+
FontStack(
19+
FontName.systemUi,
20+
FontName.uiSansSerif,
21+
FontName.appleSystem,
22+
FontName.segoeUiVariable,
23+
FontName.segoeUi,
24+
FontName.roboto,
25+
FontName.ubuntu,
26+
FontName.cantarell,
27+
FontName.notoSans,
28+
FontName.helveticaNeue,
29+
FontName.arial,
30+
FontName.appleColorEmoji,
31+
FontName.segoeUiEmoji,
32+
FontName.notoColorEmoji,
33+
FontName.sansSerif
34+
)
35+
36+
val serif: FontStack =
37+
FontStack(
38+
FontName.uiSerif,
39+
FontName.georgia,
40+
FontName.cambria,
41+
FontName.timesNewRoman,
42+
FontName.times,
43+
FontName.notoSerif,
44+
FontName.liberationSerif,
45+
FontName.serif
46+
)
47+
48+
val monospace: FontStack =
49+
FontStack(
50+
FontName.uiMonospace,
51+
FontName.sfMonoRegular,
52+
FontName.menlo,
53+
FontName.monaco,
54+
FontName.consolas,
55+
FontName.cascadiaMono,
56+
FontName.liberationMono,
57+
FontName.dejavuSansMono,
58+
FontName.notoMono,
59+
FontName.courierNew,
60+
FontName.monospace
61+
)
62+
63+
/* Sans-serif
64+
font-family:
65+
system-ui,
66+
-apple-system, /* macOS / iOS */
67+
"Segoe UI", /* Windows */
68+
Roboto, /* Android / ChromeOS */
69+
Ubuntu,
70+
"Noto Sans",
71+
"Helvetica Neue",
72+
Arial,
73+
"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji",
74+
sans-serif;
75+
*/
76+
77+
/*Serif
78+
font-family:
79+
ui-serif,
80+
Georgia,
81+
Cambria,
82+
"Times New Roman",
83+
Times,
84+
"Noto Serif",
85+
serif;
86+
*/
87+
88+
/*Monospace
89+
font-family:
90+
ui-monospace,
91+
SFMono-Regular, /* macOS */
92+
Menlo,
93+
Monaco,
94+
Consolas,
95+
"Liberation Mono",
96+
"DejaVu Sans Mono",
97+
"Noto Mono",
98+
"Courier New",
99+
monospace;
100+
*/

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

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import tyrian.ui
1010
import tyrian.ui.UIElement
1111
import tyrian.ui.UIKey
1212
import tyrian.ui.theme.Theme
13+
import tyrian.ui.theme.ThemeOverride
1314
import tyrian.ui.utils.Lens
1415

1516
final case class Input(
@@ -19,7 +20,7 @@ final case class Input(
1920
isReadOnly: Boolean,
2021
value: String,
2122
classNames: Set[String],
22-
themeOverride: Option[InputTheme => InputTheme]
23+
themeOverride: ThemeOverride[InputTheme]
2324
) extends UIElement.Stateful[Input, InputTheme]:
2425

2526
def withPlaceholder(placeholder: String): Input =
@@ -48,14 +49,14 @@ final case class Input(
4849
def withClassNames(classes: Set[String]): Input =
4950
this.copy(classNames = classes)
5051

51-
def themeLens: Lens[Theme.Styles, InputTheme] =
52+
def themeLens: Lens[Theme.Default, InputTheme] =
5253
Lens(
5354
_.input,
5455
(t, i) => t.copy(input = i)
5556
)
5657

57-
def withThemeOverride(f: InputTheme => InputTheme): Input =
58-
this.copy(themeOverride = Some(f))
58+
def withThemeOverride(value: ThemeOverride[InputTheme]): Input =
59+
this.copy(themeOverride = value)
5960

6061
def update: GlobalMsg => Outcome[Input] =
6162
case TextInputMsg.Changed(_key, v) if _key == key =>
@@ -80,10 +81,10 @@ final case class Input(
8081

8182
val styles =
8283
theme match
83-
case Theme.NoStyles =>
84+
case Theme.None =>
8485
Style.empty
8586

86-
case tt: Theme.Styles =>
87+
case tt: Theme.Default =>
8788
if isDisabled then tt.input.toDisabledStyles(theme)
8889
else tt.input.toStyles(theme)
8990

@@ -114,7 +115,7 @@ object Input:
114115
isReadOnly = false,
115116
value = "",
116117
Set.empty,
117-
None
118+
ThemeOverride.NoOverride
118119
)
119120

120121
enum TextInputMsg extends GlobalMsg:

0 commit comments

Comments
 (0)