moire: give each time-of-day phase its own pattern as well as theme

be2c05b · Gabriel Morell · 2026-09-29 21:52

11 files +208 -148
Message
{commit_body(@commit)}

Files changed

modified README.md
+8 −5
@@ -69,11 +69,14 @@ Everything drifts slowly and never visibly repeats.
69 69 Its colours come from the waxx themes (dark, light, her, afterdark,
70 70 forest, sky, clays, stones, blueprint): the ground is `base-100`
71 71 fading to `base-300`, the moire `accent`, the halftone `primary`. It
72 −can hold one theme, or follow the time of day through night, sunrise,
73 −day and sunset themes, either by clock times or by the real sun at a
74 −latitude and longitude. Between phases it blends by the sun's
75 −elevation, turning the hue so the transitions pass through the purples
76 −of a real twilight rather than grey.
72 +can hold one theme and pattern, or follow the time of day through
73 +night, sunrise, day and sunset, each phase with its own theme and
74 +pattern (by default a starfield at night, bokeh at sunrise, moire and
75 +halftone by day, moire at sunset), either by clock times or by the real
76 +sun at a latitude and longitude. Between phases both blend by the sun's
77 +elevation: the patterns crossfade, and the colours turn the hue so the
78 +transitions pass through the purples of a real twilight rather than
79 +grey.
77 80
78 81 A checkbox writes "NEIAM" in the bottom right corner, in the theme's
79 82 accent colour, in B612 Mono Bold at 12% of the screen height; the package pulls
modified moire/theme/contents/config/main.xml
+14 −1
@@ -57,7 +57,7 @@
57 57 </entry>
58 58
59 59 <entry name="ThemeMode" type="String">
60 − <label>static, or sun to follow the time of day</label>
60 + <label>static, or sun to follow the time of day with each phase's theme and pattern</label>
61 61 <default>static</default>
62 62 </entry>
63 63 <entry name="Theme" type="String">
@@ -76,6 +76,19 @@
76 76 <entry name="SunsetTheme" type="String">
77 77 <default>her</default>
78 78 </entry>
79 + <!-- Each phase's pattern: moire, halftone, both, bokeh, or stars. -->
80 + <entry name="NightPattern" type="String">
81 + <default>stars</default>
82 + </entry>
83 + <entry name="SunrisePattern" type="String">
84 + <default>bokeh</default>
85 + </entry>
86 + <entry name="DayPattern" type="String">
87 + <default>both</default>
88 + </entry>
89 + <entry name="SunsetPattern" type="String">
90 + <default>moire</default>
91 + </entry>
79 92
80 93 <entry name="UseLocation" type="Bool">
81 94 <label>Follow the real sun at Latitude/Longitude, rather than SunriseTime/SunsetTime</label>
modified moire/theme/contents/ui/Field.qml
+12 −3
@@ -11,9 +11,11 @@ Item {
11 11 id: field
12 12
13 13 // "moire", "halftone", "both" (moire over halftone), "bokeh",
14 − // "stars", or "alternate": fading between fadeFrom and fadeTo, which
15 − // are any of the others, every few minutes.
14 + // "stars", "alternate": fading between fadeFrom and fadeTo, which are
15 + // any of the others, every few minutes, or "mix": the weighted blend
16 + // of patterns in mixture, as [[pattern, weight], ...].
16 17 property string pattern: "both"
18 + property var mixture: []
17 19 property string fadeFrom: "moire"
18 20 property string fadeTo: "halftone"
19 21 property int bokehLights: 14
@@ -60,6 +62,12 @@ Item {
60 62 }
61 63
62 64 function amount(layer) {
65 + if (pattern === "mix") {
66 + var sum = 0
67 + for (var i = 0; i < mixture.length; i++)
68 + sum += layers(mixture[i][0])[layer] * mixture[i][1]
69 + return sum
70 + }
63 71 if (pattern !== "alternate")
64 72 return layers(pattern)[layer]
65 73 var t = 0.5 + 0.5 * sweep(360, 0)
@@ -135,6 +143,7 @@ Item {
135 143 // B612 Mono comes from fonts-b612 / firstyear-b612-fonts, which the
136 144 // package depends on.
137 145 Text {
146 + id: mark
138 147 visible: field.wordmark
139 148 anchors { right: parent.right; bottom: parent.bottom; margins: field.height * 0.04 }
140 149 text: "NEIAM"
@@ -142,7 +151,7 @@ Item {
142 151 font { family: "B612 Mono"; bold: true; pixelSize: Math.max(1, Math.round(field.height * 0.12)) }
143 152 // Sit the capitals, not the line box, on the margin.
144 153 anchors.bottomMargin: field.height * 0.04 - fontMetrics.descent
145 − FontMetrics { id: fontMetrics; font: parent.font }
154 + FontMetrics { id: fontMetrics; font: mark.font }
146 155 }
147 156
148 157 Timer {
modified moire/theme/contents/ui/config.qml
+143 −120
@@ -29,6 +29,10 @@ Kirigami.FormLayout {
29 29 property string cfg_SunriseTheme
30 30 property string cfg_DayTheme
31 31 property string cfg_SunsetTheme
32 + property string cfg_NightPattern
33 + property string cfg_SunrisePattern
34 + property string cfg_DayPattern
35 + property string cfg_SunsetPattern
32 36 property bool cfg_UseLocation
33 37 property double cfg_Latitude
34 38 property double cfg_Longitude
@@ -45,9 +49,11 @@ Kirigami.FormLayout {
45 49 ]
46 50 readonly property var patterns: layers.concat([{ value: "alternate", text: "Fade between two…" }])
47 51
48 − readonly property bool fading: cfg_Pattern === "alternate"
49 − readonly property bool showsBokeh: cfg_Pattern === "bokeh"
50 − || (fading && (cfg_FadeFrom === "bokeh" || cfg_FadeTo === "bokeh"))
52 + readonly property bool followsSun: cfg_ThemeMode === "sun"
53 + readonly property bool fading: !followsSun && cfg_Pattern === "alternate"
54 + readonly property bool showsBokeh: followsSun
55 + ? [cfg_NightPattern, cfg_SunrisePattern, cfg_DayPattern, cfg_SunsetPattern].indexOf("bokeh") >= 0
56 + : cfg_Pattern === "bokeh" || (fading && (cfg_FadeFrom === "bokeh" || cfg_FadeTo === "bokeh"))
51 57
52 58 component PatternBox: QQC2.ComboBox {
53 59 property var choices
@@ -68,6 +74,17 @@ Kirigami.FormLayout {
68 74 onActivated: index => picked(Themes.names[index])
69 75 }
70 76
77 + // One phase of the day: its theme and its pattern side by side.
78 + component PhaseRow: RowLayout {
79 + property string theme
80 + property string pattern
81 + signal themePicked(string name)
82 + signal patternPicked(string value)
83 + visible: root.followsSun
84 + ThemeBox { value: parent.theme; onPicked: name => parent.themePicked(name) }
85 + PatternBox { choices: root.layers; value: parent.pattern; onPicked: v => parent.patternPicked(v) }
86 + }
87 +
71 88 component TimeField: QQC2.TextField {
72 89 inputMask: "99:99"
73 90 validator: RegularExpressionValidator { regularExpression: /([01]\d|2[0-3]):[0-5]\d/ }
@@ -81,10 +98,32 @@ Kirigami.FormLayout {
81 98 onEditingFinished: edited(Number(text))
82 99 }
83 100
84 − // ------------------------------------------------------------ pattern
101 + // --------------------------------------------------------------- mode
102 +
103 + QQC2.RadioButton {
104 + Kirigami.FormData.label: "Look:"
105 + text: "Always the same"
106 + checked: !root.followsSun
107 + onToggled: if (checked) root.cfg_ThemeMode = "static"
108 + }
109 + QQC2.RadioButton {
110 + text: "Follow the time of day"
111 + checked: root.followsSun
112 + onToggled: if (checked) root.cfg_ThemeMode = "sun"
113 + }
114 +
115 + // ------------------------------------------------------------- static
116 +
117 + ThemeBox {
118 + Kirigami.FormData.label: "Theme:"
119 + visible: !root.followsSun
120 + value: root.cfg_Theme
121 + onPicked: name => root.cfg_Theme = name
122 + }
85 123
86 124 PatternBox {
87 125 Kirigami.FormData.label: "Pattern:"
126 + visible: !root.followsSun
88 127 choices: root.patterns
89 128 value: root.cfg_Pattern
90 129 onPicked: v => root.cfg_Pattern = v
@@ -106,113 +145,31 @@ Kirigami.FormLayout {
106 145 onPicked: v => root.cfg_FadeTo = v
107 146 }
108 147
109 − QQC2.SpinBox {
110 − Kirigami.FormData.label: "Bokeh lights:"
111 − visible: root.showsBokeh
112 − from: 4; to: 64
113 − value: root.cfg_BokehLights
114 − onValueModified: root.cfg_BokehLights = value
115 − }
116 −
117 − RowLayout {
118 − Kirigami.FormData.label: "Speed:"
119 − QQC2.Slider {
120 − id: speed
121 − from: 0; to: 4; stepSize: 0.25
122 − value: root.cfg_Speed
123 − onMoved: root.cfg_Speed = value
124 − }
125 − QQC2.Label { text: speed.value === 0 ? "still" : speed.value + "×" }
126 − }
127 −
128 − QQC2.SpinBox {
129 − Kirigami.FormData.label: "Frame rate:"
130 − from: 5; to: 60
131 − value: root.cfg_FrameRate
132 − onValueModified: root.cfg_FrameRate = value
133 − enabled: root.cfg_Speed > 0
134 − }
135 −
136 − QQC2.CheckBox {
137 − Kirigami.FormData.label: "Effects:"
138 − text: "Light glinting across the pattern"
139 − checked: root.cfg_Glints
140 − onToggled: root.cfg_Glints = checked
141 − }
142 − QQC2.CheckBox {
143 − text: "Breathing"
144 − checked: root.cfg_Breathing
145 − onToggled: root.cfg_Breathing = checked
146 − }
147 − QQC2.CheckBox {
148 − text: "Large lights drifting past in front"
149 − checked: root.cfg_ForegroundBokeh
150 − onToggled: root.cfg_ForegroundBokeh = checked
151 − }
152 − QQC2.CheckBox {
153 − text: "Film grain"
154 − checked: root.cfg_Grain
155 − onToggled: root.cfg_Grain = checked
156 − }
157 − QQC2.CheckBox {
158 − text: "Dither (smooths banding in gradients)"
159 − checked: root.cfg_Dither
160 − onToggled: root.cfg_Dither = checked
161 − }
162 −
163 − QQC2.CheckBox {
164 − Kirigami.FormData.label: "Wordmark:"
165 − text: "Write NEIAM in the corner"
166 − checked: root.cfg_Wordmark
167 − onToggled: root.cfg_Wordmark = checked
168 − }
169 −
170 − // ------------------------------------------------------------- theme
171 −
172 − Item { Kirigami.FormData.isSection: true }
173 −
174 − QQC2.RadioButton {
175 − Kirigami.FormData.label: "Theme:"
176 − text: "Always the same"
177 − checked: root.cfg_ThemeMode !== "sun"
178 − onToggled: if (checked) root.cfg_ThemeMode = "static"
179 − }
180 − QQC2.RadioButton {
181 − text: "Follow the time of day"
182 − checked: root.cfg_ThemeMode === "sun"
183 − onToggled: if (checked) root.cfg_ThemeMode = "sun"
184 − }
185 −
186 − ThemeBox {
187 − Kirigami.FormData.label: "Theme:"
188 − visible: root.cfg_ThemeMode !== "sun"
189 − value: root.cfg_Theme
190 − onPicked: name => root.cfg_Theme = name
191 − }
148 + // ---------------------------------------------------- time of day
192 149
193 − ThemeBox {
150 + PhaseRow {
194 151 Kirigami.FormData.label: "Night:"
195 − visible: root.cfg_ThemeMode === "sun"
196 − value: root.cfg_NightTheme
197 − onPicked: name => root.cfg_NightTheme = name
152 + theme: root.cfg_NightTheme; pattern: root.cfg_NightPattern
153 + onThemePicked: name => root.cfg_NightTheme = name
154 + onPatternPicked: v => root.cfg_NightPattern = v
198 155 }
199 − ThemeBox {
156 + PhaseRow {
200 157 Kirigami.FormData.label: "Sunrise:"
201 − visible: root.cfg_ThemeMode === "sun"
202 − value: root.cfg_SunriseTheme
203 − onPicked: name => root.cfg_SunriseTheme = name
158 + theme: root.cfg_SunriseTheme; pattern: root.cfg_SunrisePattern
159 + onThemePicked: name => root.cfg_SunriseTheme = name
160 + onPatternPicked: v => root.cfg_SunrisePattern = v
204 161 }
205 − ThemeBox {
162 + PhaseRow {
206 163 Kirigami.FormData.label: "Day:"
207 − visible: root.cfg_ThemeMode === "sun"
208 − value: root.cfg_DayTheme
209 − onPicked: name => root.cfg_DayTheme = name
164 + theme: root.cfg_DayTheme; pattern: root.cfg_DayPattern
165 + onThemePicked: name => root.cfg_DayTheme = name
166 + onPatternPicked: v => root.cfg_DayPattern = v
210 167 }
211 − ThemeBox {
168 + PhaseRow {
212 169 Kirigami.FormData.label: "Sunset:"
213 − visible: root.cfg_ThemeMode === "sun"
214 − value: root.cfg_SunsetTheme
215 − onPicked: name => root.cfg_SunsetTheme = name
170 + theme: root.cfg_SunsetTheme; pattern: root.cfg_SunsetPattern
171 + onThemePicked: name => root.cfg_SunsetTheme = name
172 + onPatternPicked: v => root.cfg_SunsetPattern = v
216 173 }
217 174
218 175 // ---------------------------------------------------------- the sun
@@ -254,32 +211,98 @@ Kirigami.FormLayout {
254 211 QQC2.Label {
255 212 id: now
256 213 Kirigami.FormData.label: "Now:"
257 − visible: root.cfg_ThemeMode === "sun"
214 + visible: root.followsSun
215 +
216 + // Bumped each minute; the binding below also re-reads every
217 + // setting describe() touches, so edits show straight away.
218 + property int tick: 0
219 + Timer {
220 + interval: 60000; running: now.visible; repeat: true
221 + onTriggered: now.tick++
222 + }
258 223
259 224 function describe() {
260 − var p = Schedule.palette({
225 + var cfg = {
261 226 ThemeMode: "sun", UseLocation: root.cfg_UseLocation,
262 227 Latitude: root.cfg_Latitude, Longitude: root.cfg_Longitude,
263 228 SunriseTime: root.cfg_SunriseTime, SunsetTime: root.cfg_SunsetTime,
264 229 NightTheme: root.cfg_NightTheme, SunriseTheme: root.cfg_SunriseTheme,
265 230 DayTheme: root.cfg_DayTheme, SunsetTheme: root.cfg_SunsetTheme,
266 − }, new Date())
267 − return p.label + (root.cfg_UseLocation
268 − ? ", sun at " + p.elevation.toFixed(1) + "°" : "")
231 + NightPattern: root.cfg_NightPattern, SunrisePattern: root.cfg_SunrisePattern,
232 + DayPattern: root.cfg_DayPattern, SunsetPattern: root.cfg_SunsetPattern,
233 + }
234 + var p = Schedule.palette(cfg, new Date())
235 + var pattern = root.layers.find(l => l.value === cfg[Schedule.key(p.label, "Pattern")])
236 + return p.label + ": " + cfg[Schedule.key(p.label, "Theme")]
237 + + ", " + (pattern ? pattern.text.toLowerCase() : "")
238 + + (root.cfg_UseLocation ? " (sun at " + p.elevation.toFixed(1) + "°)" : "")
269 239 }
270 240
271 − text: describe()
272 − Timer {
273 − interval: 60000; running: now.visible; repeat: true
274 − onTriggered: now.text = now.describe()
275 − }
276 − Connections {
277 − target: root
278 − function onCfg_UseLocationChanged() { now.text = now.describe() }
279 − function onCfg_LatitudeChanged() { now.text = now.describe() }
280 − function onCfg_LongitudeChanged() { now.text = now.describe() }
281 − function onCfg_SunriseTimeChanged() { now.text = now.describe() }
282 − function onCfg_SunsetTimeChanged() { now.text = now.describe() }
241 + text: { tick; return describe() }
242 + }
243 +
244 + // ------------------------------------------------------------ shared
245 +
246 + Item { Kirigami.FormData.isSection: true }
247 +
248 + QQC2.SpinBox {
249 + Kirigami.FormData.label: "Bokeh lights:"
250 + visible: root.showsBokeh
251 + from: 4; to: 64
252 + value: root.cfg_BokehLights
253 + onValueModified: root.cfg_BokehLights = value
254 + }
255 +
256 + RowLayout {
257 + Kirigami.FormData.label: "Speed:"
258 + QQC2.Slider {
259 + id: speed
260 + from: 0; to: 4; stepSize: 0.25
261 + value: root.cfg_Speed
262 + onMoved: root.cfg_Speed = value
283 263 }
264 + QQC2.Label { text: speed.value === 0 ? "still" : speed.value + "×" }
265 + }
266 +
267 + QQC2.SpinBox {
268 + Kirigami.FormData.label: "Frame rate:"
269 + from: 5; to: 60
270 + value: root.cfg_FrameRate
271 + onValueModified: root.cfg_FrameRate = value
272 + enabled: root.cfg_Speed > 0
273 + }
274 +
275 + QQC2.CheckBox {
276 + Kirigami.FormData.label: "Effects:"
277 + text: "Light glinting across the pattern"
278 + checked: root.cfg_Glints
279 + onToggled: root.cfg_Glints = checked
280 + }
281 + QQC2.CheckBox {
282 + text: "Breathing"
283 + checked: root.cfg_Breathing
284 + onToggled: root.cfg_Breathing = checked
285 + }
286 + QQC2.CheckBox {
287 + text: "Large lights drifting past in front"
288 + checked: root.cfg_ForegroundBokeh
289 + onToggled: root.cfg_ForegroundBokeh = checked
290 + }
291 + QQC2.CheckBox {
292 + text: "Film grain"
293 + checked: root.cfg_Grain
294 + onToggled: root.cfg_Grain = checked
295 + }
296 + QQC2.CheckBox {
297 + text: "Dither (smooths banding in gradients)"
298 + checked: root.cfg_Dither
299 + onToggled: root.cfg_Dither = checked
300 + }
301 +
302 + QQC2.CheckBox {
303 + Kirigami.FormData.label: "Wordmark:"
304 + text: "Write NEIAM in the corner"
305 + checked: root.cfg_Wordmark
306 + onToggled: root.cfg_Wordmark = checked
284 307 }
285 308 }
modified moire/theme/contents/ui/main.qml
+4 −1
@@ -28,7 +28,10 @@ WallpaperItem {
28 28
29 29 Field {
30 30 anchors.fill: parent
31 − pattern: root.cfg.Pattern
31 + // Following the sun, the phases' patterns blend as their themes
32 + // do; the mixture moves once a minute and Field eases the steps.
33 + pattern: root.cfg.ThemeMode === "sun" ? "mix" : root.cfg.Pattern
34 + mixture: root.scheme.patterns
32 35 fadeFrom: root.cfg.FadeFrom
33 36 fadeTo: root.cfg.FadeTo
34 37 bokehLights: root.cfg.BokehLights
modified moire/theme/contents/ui/schedule.js
+27 −18
@@ -1,5 +1,5 @@
1 −// Which palette to paint with: a fixed theme, or one that follows the
2 −// sun through night, sunrise, day and sunset.
1 +// What to paint: a fixed theme and pattern, or ones that follow the sun
2 +// through night, sunrise, day and sunset, each phase with its own.
3 3 //
4 4 // Following the sun blends between the four phase themes by the sun's
5 5 // elevation, so the change is continuous rather than a switch at fixed
@@ -113,33 +113,42 @@ function smooth(t) {
113 113 return t * t * (3 - 2 * t)
114 114 }
115 115
116 −// Night below -12 degrees (nautical dusk), the twilight theme across
117 −// -4 to 2, day above 10, crossfading in between. Whether the twilight
118 −// theme is sunrise's or sunset's depends on which way the sun is going.
119 −function weights(elevation, rising, night, sunrise, day, sunset) {
120 − var twilight = rising ? sunrise : sunset
116 +// Night below -12 degrees (nautical dusk), twilight across -4 to 2,
117 +// day above 10, crossfading in between. Whether twilight is sunrise or
118 +// sunset depends on which way the sun is going. Returns the phases in
119 +// play, as [["night", weight], ...].
120 +function weights(elevation, rising) {
121 + var twilight = rising ? "sunrise" : "sunset"
121 122 if (elevation < -12)
122 − return { label: "night", weights: [[night, 1]] }
123 + return { label: "night", weights: [["night", 1]] }
123 124 if (elevation < -4) {
124 125 var t = smooth((elevation + 12) / 8)
125 − return { label: t < 0.5 ? "night" : (rising ? "sunrise" : "sunset"),
126 − weights: [[night, 1 - t], [twilight, t]] }
126 + return { label: t < 0.5 ? "night" : twilight,
127 + weights: [["night", 1 - t], [twilight, t]] }
127 128 }
128 129 if (elevation < 2)
129 − return { label: rising ? "sunrise" : "sunset", weights: [[twilight, 1]] }
130 + return { label: twilight, weights: [[twilight, 1]] }
130 131 if (elevation < 10) {
131 132 var u = smooth((elevation - 2) / 8)
132 − return { label: u < 0.5 ? (rising ? "sunrise" : "sunset") : "day",
133 − weights: [[twilight, 1 - u], [day, u]] }
133 + return { label: u < 0.5 ? twilight : "day",
134 + weights: [[twilight, 1 - u], ["day", u]] }
134 135 }
135 − return { label: "day", weights: [[day, 1]] }
136 + return { label: "day", weights: [["day", 1]] }
137 +}
138 +
139 +// The config key for a phase's setting: ("sunrise", "Theme") is
140 +// SunriseTheme.
141 +function key(phase, what) {
142 + return phase.charAt(0).toUpperCase() + phase.slice(1) + what
136 143 }
137 144
138 145 // cfg is the wallpaper configuration (or anything with the same keys).
139 −// Returns { base, deep, ink, tone, label, elevation }.
146 +// Returns { base, deep, ink, tone, patterns, label, elevation }, where
147 +// patterns is [[pattern, weight], ...] for Field's "mix" pattern.
140 148 function palette(cfg, date) {
141 149 if (cfg.ThemeMode !== "sun") {
142 150 var fixed = blend([[cfg.Theme, 1]])
151 + fixed.patterns = [[cfg.Pattern, 1]]
143 152 fixed.label = cfg.Theme
144 153 return fixed
145 154 }
@@ -150,9 +159,9 @@ function palette(cfg, date) {
150 159 var elevation = elevationAt(date)
151 160 var rising = elevationAt(new Date(date.getTime() + 600000)) > elevation
152 161
153 − var phase = weights(elevation, rising,
154 − cfg.NightTheme, cfg.SunriseTheme, cfg.DayTheme, cfg.SunsetTheme)
155 − var result = blend(phase.weights)
162 + var phase = weights(elevation, rising)
163 + var result = blend(phase.weights.map(function (w) { return [cfg[key(w[0], "Theme")], w[1]] }))
164 + result.patterns = phase.weights.map(function (w) { return [cfg[key(w[0], "Pattern")], w[1]] })
156 165 result.label = phase.label
157 166 result.elevation = elevation
158 167 return result

Parents: c2e4eb1