Render dynamic weather for ASCII landscapes. Inspired and powered by ~iajrz's
climate program.
1package main
2
3import (
4 tea "github.com/charmbracelet/bubbletea"
5 "github.com/muesli/reflow/wordwrap"
6 "github.com/muesli/termenv"
7 "strings"
8 "time"
9)
10
11func main() {
12 app := tea.NewProgram(model{startTime: time.Now()})
13 if err := app.Start(); err != nil {
14 panic(err)
15 }
16}
17
18// Queues the initial loading of the forecast and
19func (m model) Init() tea.Cmd {
20 return tea.Batch(updateForecast, renderOften)
21}
22
23var updateForecast = func() tea.Msg {
24 forecast, err := NewForecast()
25 if err != nil {
26 return err
27 }
28 return forecast
29}
30
31var updateForecastOften = tea.Tick(20*time.Second, func(t time.Time) tea.Msg {
32 return updateForecast()
33})
34
35var makeSceneMsg = func(f Forecast) func() tea.Msg {
36 return func() tea.Msg {
37 scene, err := NewScene("scene1", f)
38 if err != nil {
39 return err
40 }
41 return scene
42 }
43}
44
45type fadeOut float32
46type fadeIn float32
47
48// Used to update the progress of a fade out transition.
49var tickFadeOut = tea.Every(time.Millisecond*100, func(t time.Time) tea.Msg {
50 return fadeOut(0.05)
51})
52
53// Used to update the progress of a fade in transition.
54var tickFadeIn = tea.Every(time.Millisecond*100, func(t time.Time) tea.Msg {
55 return fadeIn(0.05)
56})
57
58type renderTick struct{}
59
60// Used to update the screen at least once per second.
61var renderOften = tea.Tick(time.Second, func(t time.Time) tea.Msg {
62 return renderTick{}
63})
64
65// Update updates the internal state of the model and queues any events that
66// need to be scheduled.
67func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
68 cmds := []tea.Cmd{}
69
70 switch msg := msg.(type) {
71 case tea.KeyMsg:
72 switch msg.String() {
73 case "ctrl+c", "ctrl+d", "q":
74 return m, tea.Quit
75 case "esc":
76 // TODO: Menu for options. Maybe 100% randomized weather?
77 }
78
79 case tea.WindowSizeMsg:
80 m.width = msg.Width
81 m.height = msg.Height
82
83 case Forecast:
84 // Re-queue the forecast polling.
85 cmds = append(cmds, updateForecastOften)
86
87 // No action required if the forecast hasn't changed.
88 if msg == m.Forecast {
89 break
90 }
91
92 // If this isn't the initial startup forecast, we need to fade out the scene.
93 if m.Forecast != (Forecast{}) {
94 m.fading = true
95 cmds = append(cmds, tickFadeOut)
96 }
97
98 // In all cases, we need to load a scene with the forecast.
99 m.Forecast = msg
100 cmds = append(cmds, makeSceneMsg(m.Forecast))
101
102 case Scene:
103 if m.Scene.foreground == nil {
104 m.Scene = msg
105 } else {
106 m.nextScene = msg
107 }
108
109 case fadeOut:
110 m.fadeProgress += float32(msg)
111 if m.fadeProgress >= 1.0 {
112 m.fadeProgress = 1.0
113 // To unfade, we need a Scene ready to rock. If we don't have one, stall.
114 if m.nextScene.foreground == nil {
115 cmds = append(cmds, tickFadeOut)
116 break
117 }
118
119 m.Scene = m.nextScene
120 m.nextScene = Scene{}
121 cmds = append(cmds, tickFadeIn)
122 break
123 }
124 cmds = append(cmds, tickFadeOut)
125
126 case fadeIn:
127 m.fadeProgress -= float32(msg)
128 if m.fadeProgress <= 0.0 {
129 m.fadeProgress = 0.0
130 m.fading = false
131 break
132 }
133 cmds = append(cmds, tickFadeIn)
134
135 case error:
136 m.err = msg
137
138 case renderTick:
139 cmds = append(cmds, renderOften)
140 }
141
142 return m, tea.Batch(cmds...)
143}
144
145// View lays out the screen and queries the Scene for its contents.
146// It also handles transitions when changing from Scene to Scene.
147func (m model) View() string {
148 if m.err != nil {
149 return m.err.Error()
150 }
151
152 weather := wordwrap.String(m.Forecast.String(), m.width)
153 m.height -= strings.Count(weather, "\n")
154 weather = strings.TrimSpace(weather) // Trim trailing newline
155
156 output := ""
157 lastStyle := Style{}
158 profile := termenv.EnvColorProfile()
159
160 // Center scene in terminal
161 xOff := (m.Scene.Width - m.width) / 2
162 yOff := (m.Scene.Height - m.height) / 2
163
164 for y := yOff; y < yOff+m.height; y++ {
165 for x := xOff; x < xOff+m.width; x++ {
166 char, style := m.Scene.GetCell(x, y, int(time.Since(m.startTime).Seconds()))
167 style = style.Convert(profile)
168
169 // For wipe transitions, replace character with blank space.
170 if float32((x-xOff)/2+y-yOff) < m.fadeProgress*float32(m.width/2+m.height) {
171 char = ' '
172 style = Style{}
173 }
174
175 // Only output formatting escape codes if the style's changed since the last cell.
176 if lastStyle.String() != style.String() {
177 output += style.String()
178 lastStyle = style
179 }
180
181 output += string(char)
182 }
183 output += "\n"
184 }
185
186 output += termenv.CSI + termenv.ResetSeq + "m"
187 output += weather
188
189 return output
190}
191
192type model struct {
193 Forecast
194 Scene
195 err error
196 nextScene Scene
197 fading bool
198 fadeProgress float32
199 width int
200 height int
201 startTime time.Time
202}