Render dynamic weather for ASCII landscapes. Inspired and powered by ~iajrz's climate program.
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 202 lines 4.5 kB view raw
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}