From 534eab9fac14d2738cc132d392fc12e5294a8e84 Mon Sep 17 00:00:00 2001 From: Diff Date: Fri, 28 May 2021 22:58:08 -0500 Subject: [PATCH] Initial commit --- .gitignore | 1 + forecast.go | 131 ++++++++++++++++++++ main.go | 202 +++++++++++++++++++++++++++++++ scene.go | 271 ++++++++++++++++++++++++++++++++++++++++++ scene1/afternoon.txt | 31 +++++ scene1/depth.txt | 31 +++++ scene1/early.txt | 31 +++++ scene1/foreground.txt | 31 +++++ scene1/morning.txt | 31 +++++ scene1/night.txt | 31 +++++ scene1/wind.txt | 31 +++++ 11 files changed, 822 insertions(+) create mode 100644 .gitignore create mode 100644 forecast.go create mode 100644 main.go create mode 100644 scene.go create mode 100644 scene1/afternoon.txt create mode 100644 scene1/depth.txt create mode 100644 scene1/early.txt create mode 100644 scene1/foreground.txt create mode 100644 scene1/morning.txt create mode 100644 scene1/night.txt create mode 100644 scene1/wind.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cd62297 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +atmosphere \ No newline at end of file diff --git a/forecast.go b/forecast.go new file mode 100644 index 0000000..e2dd015 --- /dev/null +++ b/forecast.go @@ -0,0 +1,131 @@ +package main + +import ( + "os/exec" + "strings" +) + +// Forecast is a representation of the current state of the weather. +type Forecast struct { + raw string + time TimeOfDay + cloudiness Cloudiness + raininess Raininess + visibility Visibility + windiness Windiness +} + +// NewForecast parses the output of ~iajrz's climate program. +// TODO?: boolean randomize option to generate completely random weather as a demo mode? +func NewForecast() (Forecast, error) { + out, err := exec.Command("/home/iajrz/climate").Output() + if err != nil { + return Forecast{}, err + } + rawWeather := string(out) + return Forecast{ + raw: rawWeather, + time: TimeOfDay(findSubstring(rawWeather, timeStrings)), + cloudiness: Cloudiness(findSubstring(rawWeather, cloudStrings)), + raininess: Raininess(findSubstring(rawWeather, rainStrings)), + visibility: Visibility(findSubstring(rawWeather, visibilityStrings)), + windiness: Windiness(findSubstring(rawWeather, windStrings)), + }, nil +} + +func (f Forecast) String() string { + return f.raw +} + +func findSubstring(s string, substrings []string) int { + for i := range substrings { + if strings.Contains(s, substrings[i]) { + return i + } + } + return 0 +} + +type TimeOfDay int + +const ( + EarlyMorning TimeOfDay = iota + Morning + Afternoon + Night +) + +var timeStrings = []string{ + "early morning", + "morning", + "afternoon", + "night", +} + +type Cloudiness int + +const ( + ClearSky Cloudiness = iota + AlmostClear + PartlyCloudy + MostlyCloudy + Cloudy +) + +var cloudStrings = []string{ + "clear", + "almost clear", + "partly cloudy", + "mostly cloudy", + "cloudy", +} + +type Raininess int + +const ( + NoRain Raininess = iota + Drizzle + LightShower + Shower + HeavyShower +) + +var rainStrings = []string{ + "no rain", + "a drizzle", + "a light shower", + "a shower", + "a heavy shower", +} + +type Visibility int + +const ( + NoFog Visibility = iota + Haze + Mist + Fog + HeavyFog +) + +var visibilityStrings = []string{ + "visibility", + "There's haze", + "There's mist", + "There's fog", + "There's heavy fog", +} + +type Windiness int + +const ( + NoWind Windiness = iota + Breeze + StiffWind +) + +var windStrings = []string{ + "no breeze", + "light breeze", + "stiff wind", +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..24bc6e3 --- /dev/null +++ b/main.go @@ -0,0 +1,202 @@ +package main + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/muesli/reflow/wordwrap" + "github.com/muesli/termenv" + "strings" + "time" +) + +func main() { + app := tea.NewProgram(model{startTime: time.Now()}) + if err := app.Start(); err != nil { + panic(err) + } +} + +// Queues the initial loading of the forecast and +func (m model) Init() tea.Cmd { + return tea.Batch(updateForecast, renderOften) +} + +var updateForecast = func() tea.Msg { + forecast, err := NewForecast() + if err != nil { + return err + } + return forecast +} + +var updateForecastOften = tea.Tick(20*time.Second, func(t time.Time) tea.Msg { + return updateForecast() +}) + +var makeSceneMsg = func(f Forecast) func() tea.Msg { + return func() tea.Msg { + scene, err := NewScene("scene1", f) + if err != nil { + return err + } + return scene + } +} + +type fadeOut float32 +type fadeIn float32 + +// Used to update the progress of a fade out transition. +var tickFadeOut = tea.Every(time.Millisecond*100, func(t time.Time) tea.Msg { + return fadeOut(0.05) +}) + +// Used to update the progress of a fade in transition. +var tickFadeIn = tea.Every(time.Millisecond*100, func(t time.Time) tea.Msg { + return fadeIn(0.05) +}) + +type renderTick struct{} + +// Used to update the screen at least once per second. +var renderOften = tea.Tick(time.Second, func(t time.Time) tea.Msg { + return renderTick{} +}) + +// Update updates the internal state of the model and queues any events that +// need to be scheduled. +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + cmds := []tea.Cmd{} + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "ctrl+d", "q": + return m, tea.Quit + case "esc": + // TODO: Menu for options. Maybe 100% randomized weather? + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + case Forecast: + // Re-queue the forecast polling. + cmds = append(cmds, updateForecastOften) + + // No action required if the forecast hasn't changed. + if msg == m.Forecast { + break + } + + // If this isn't the initial startup forecast, we need to fade out the scene. + if m.Forecast != (Forecast{}) { + m.fading = true + cmds = append(cmds, tickFadeOut) + } + + // In all cases, we need to load a scene with the forecast. + m.Forecast = msg + cmds = append(cmds, makeSceneMsg(m.Forecast)) + + case Scene: + if m.Scene.foreground == nil { + m.Scene = msg + } else { + m.nextScene = msg + } + + case fadeOut: + m.fadeProgress += float32(msg) + if m.fadeProgress >= 1.0 { + m.fadeProgress = 1.0 + // To unfade, we need a Scene ready to rock. If we don't have one, stall. + if m.nextScene.foreground == nil { + cmds = append(cmds, tickFadeOut) + break + } + + m.Scene = m.nextScene + m.nextScene = Scene{} + cmds = append(cmds, tickFadeIn) + break + } + cmds = append(cmds, tickFadeOut) + + case fadeIn: + m.fadeProgress -= float32(msg) + if m.fadeProgress <= 0.0 { + m.fadeProgress = 0.0 + m.fading = false + break + } + cmds = append(cmds, tickFadeIn) + + case error: + m.err = msg + + case renderTick: + cmds = append(cmds, renderOften) + } + + return m, tea.Batch(cmds...) +} + +// View lays out the screen and queries the Scene for its contents. +// It also handles transitions when changing from Scene to Scene. +func (m model) View() string { + if m.err != nil { + return m.err.Error() + } + + weather := wordwrap.String(m.Forecast.String(), m.width) + m.height -= strings.Count(weather, "\n") + weather = strings.TrimSpace(weather) // Trim trailing newline + + output := "" + lastStyle := Style{} + profile := termenv.EnvColorProfile() + + // Center scene in terminal + xOff := (m.Scene.Width - m.width) / 2 + yOff := (m.Scene.Height - m.height) / 2 + + for y := yOff; y < yOff+m.height; y++ { + for x := xOff; x < xOff+m.width; x++ { + char, style := m.Scene.GetCell(x, y, int(time.Since(m.startTime).Seconds())) + style = style.Convert(profile) + + // For wipe transitions, replace character with blank space. + if float32((x-xOff)/2+y-yOff) < m.fadeProgress*float32(m.width/2+m.height) { + char = ' ' + style = Style{} + } + + // Only output formatting escape codes if the style's changed since the last cell. + if lastStyle.String() != style.String() { + output += style.String() + lastStyle = style + } + + output += string(char) + } + output += "\n" + } + + output += termenv.CSI + termenv.ResetSeq + "m" + output += weather + + return output +} + +type model struct { + Forecast + Scene + err error + nextScene Scene + fading bool + fadeProgress float32 + width int + height int + startTime time.Time +} diff --git a/scene.go b/scene.go new file mode 100644 index 0000000..8fc2697 --- /dev/null +++ b/scene.go @@ -0,0 +1,271 @@ +package main + +import ( + "bufio" + "fmt" + "github.com/muesli/termenv" + "github.com/ojrac/opensimplex-go" + "io" + "os" + "path" + "unicode" +) + +// Style represents a pair of colors, foreground and background. +type Style struct { + fg termenv.Color + bg termenv.Color +} + +// Convert is a wrapper for termenv.Profile.Convert, it converts the foreground +// and background colors of a Style to be within a given terminal's Profile. +func (s Style) Convert(profile termenv.Profile) Style { + return Style{ + profile.Convert(s.fg), + profile.Convert(s.bg), + } +} + +// String fills the Stringer interface and returns an ANSI escape code +// formatted to produce text in the specified Style. +func (s Style) String() string { + if s.fg == nil { + s.fg = termenv.NoColor{} + } + if s.bg == nil { + s.bg = termenv.NoColor{} + } + // TODO: Looks like the background color is overriding the foreground color. What's the proper way to do this? + return fmt.Sprintf("%s%s;%sm", termenv.CSI, s.fg.Sequence(false), s.bg.Sequence(true)) +} + +// Scene holds all the necessary information to make a dynamic, weather-y ASCII +// landscape. +type Scene struct { + foreground [][]rune + background [][]rune + windground [][]rune + depth [][]rune + forecast Forecast + + generator opensimplex.Noise + + Width int + Height int +} + +const ( + fgPath = "foreground.txt" + depthPath = "depth.txt" + windPath = "wind.txt" + + earlyPath = "early.txt" + morningPath = "morning.txt" + afternoonPath = "afternoon.txt" + nightPath = "night.txt" +) + +var timeToPath = []string{ + earlyPath, + morningPath, + afternoonPath, + nightPath, +} + +// NewScene accepts a path to a folder containing foreground, windground, +// depth, and background files and loads them from disk as needed to generate +// imagery for the given forecast. +func NewScene(scenePath string, forecast Forecast) (Scene, error) { + var s Scene + var err error + + s.forecast = forecast + s.generator = opensimplex.NewNormalized(0) + + s.foreground, err = readRunesFromFile(path.Join(scenePath, fgPath)) + if err != nil { + return s, err + } + s.depth, err = readRunesFromFile(path.Join(scenePath, depthPath)) + if err != nil { + return s, err + } + + s.windground, err = readRunesFromFile(path.Join(scenePath, windPath)) + if err != nil { + return s, err + } + + bgPath := timeToPath[forecast.time] + s.background, err = readRunesFromFile(path.Join(scenePath, bgPath)) + if err != nil { + return s, err + } + + s.normalize() + + /*m.depthData = make([][]uint8, 0) + for i := range depthRunes { + m.depthData = append(m.depthData, make([]uint8, 0)) + for j := range depthRunes[i] { + m.depthData[i] = append(m.depthData[i], uint8(depthRunes[i][j])-48) + } + }*/ + return s, nil +} + +// normalize adjusts the foreground, windground, depth map, and background to +// have matching widths and heights. It adjusts by cropping to the shortest +// number of lines between the four and adjusts each line to the shortest of +// any lines in any of the four maps. +func (s *Scene) normalize() { + scenes := [...][][]rune{s.foreground, s.windground, s.depth, s.background} + + s.Height = 999999999 + for i := range scenes { + if len(scenes[i]) < s.Height { + s.Height = len(scenes[i]) + } + } + + for i := range scenes { + scenes[i] = scenes[i][:s.Height] + } + + s.Width = 999999999 + for j := 0; j < s.Height; j++ { + for i := range scenes { + if len(scenes[i][j]) < s.Width { + s.Width = len(scenes[i][j]) + } + } + + for i := range scenes { + scenes[i][j] = scenes[i][j][:s.Width] + } + } + + // TODO: Change this to a map? Map can be iterated over and still referenced by name. + s.foreground = scenes[0] + s.windground = scenes[1] + s.depth = scenes[2] + s.background = scenes[3] +} + +func (s Scene) GetCell(x, y, time int) (rune, Style) { + fx := float64(x) + fy := float64(y) + ftime := float64(time) + fcloud := float64(s.forecast.cloudiness) + fwind := float64(s.forecast.windiness) + frain := float64(s.forecast.raininess) + + char := ' ' + style := Style{ + termenv.ANSI256Color(255), + termenv.ANSI256Color(232), + } + + // Out of bounds + if y >= s.Height || y < 0 { + return char, Style{} + } + if x >= s.Width || x < 0 { + return char, Style{} + } + + depth := uint8(s.depth[y][x] - 48) + + // Char selection + char = s.foreground[y][x] + // Pull from wind map if the current cell is windswept. + if fwind > 0.0 && s.generator.Eval3(fx/40+fwind/8*ftime*2, fy/20, ftime/5+fwind/10*ftime/2) > 0.6 { + char = s.windground[y][x] + } + // Depth 9 is considered "transparent," so use the char from the background. + if depth == 9 { + char = s.background[y][x] + } + if frain > 0.0 && 0.1+frain/20 > s.generator.Eval3(fx+ftime*fwind*3, fy-ftime*5, ftime/15) { + char = '|' + if fwind > 1.0 { + char = '/' + } + } + + // Style selection + // Calculate fog + fog := (depth - 1) + uint8(s.forecast.visibility-1)*2 + if s.forecast.visibility == 0 { + fog = (depth + uint8(s.forecast.visibility)) / 2 + } + // Don't show fog in the sky during the night. + if depth == 9 && (s.forecast.time == Night || s.forecast.time == EarlyMorning) { + fog = 0 + } + + // Add clouds + cloudiness := 0 + if depth > 8 && fcloud/5 > s.generator.Eval3(fx/50+ftime/24+ftime*(fwind/8), fy/12, 1000+ftime/80) { + cloudiness += 10 + } + if depth > 6 && fcloud/7 > s.generator.Eval3(fx/20+ftime/14+ftime*(fwind/8), fy/6, 0+ftime/80) { + cloudiness += 10 + } + + // At night, fog obscures distant objects with darkness. + if s.forecast.time == Night || s.forecast.time == EarlyMorning { + style.fg = termenv.ANSI256Color(255 - fog - fog/2) + } else { + // Merge fog with clouds during daytime. + cloudiness += int(fog) + } + + if cloudiness > 23 { + cloudiness = 23 + } + + if cloudiness > 0 { + style.bg = termenv.ANSI256Color(232 + cloudiness) + } else { + style.bg = termenv.ANSI256Color(232 + fog) + } + return char, style +} + +// readRunesFromFile reads the file at the given path and reads it character by +// character, line by line into a slice of slices of runes. +func readRunesFromFile(filepath string) ([][]rune, error) { + file, err := os.Open(filepath) + if err != nil { + return nil, err + } + + br := bufio.NewReader(file) + img := make([][]rune, 0) + img = append(img, make([]rune, 0)) + + for { + r, s, err := br.ReadRune() + if err != nil { + if err == io.EOF { + break + } + return nil, err + } + + // Invalid unicode characters are skipped. + if r == unicode.ReplacementChar && s == 1 { + continue + } + + // Start a new slice on newline, otherwise append character to current slice. + if r == '\n' { + img = append(img, make([]rune, 0)) + } else { + img[len(img)-1] = append(img[len(img)-1], r) + } + } + + return img, nil +} diff --git a/scene1/afternoon.txt b/scene1/afternoon.txt new file mode 100644 index 0000000..4336108 --- /dev/null +++ b/scene1/afternoon.txt @@ -0,0 +1,31 @@ + ---___ | __------'''' + ---__ ,.---., __--- + -__ ;/-'''-\; --' + :|' `|: + :|. ,|; --__ ___ + __--- ;\-___-/; ''-- --___ + '' , `'---'` -----____ + ___---'' / '' + '' ' \ + ____-- , \ + '' / + / \ + __-- ' \ + \ + / + / + / + ' + + + + + + + + + + + + + \ No newline at end of file diff --git a/scene1/depth.txt b/scene1/depth.txt new file mode 100644 index 0000000..3f9c68a --- /dev/null +++ b/scene1/depth.txt @@ -0,0 +1,31 @@ +9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 +9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999899 +9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999988999999999999988888 +9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999889999999999999999999999999999999998888888888899999888888888888 +9999999999999999999999999999999999998999999999999999999999999999999999999999999998888888888889999999999999999999999999888888888888888889998888888888888 +9999999999999999999999999999999999888889999988888888999999988889999998889998888888888888888888888888899999888889999888888888888888888888888888888888888 +9999999999999999999999999999999998888888888888888888888988888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 +9999999999999999999999999999998888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888555555555888 +9999999999999999999999999999988888888888888888888888888888888888888888888888888888888888888888888888855555555555888888888855555555555555555555555555555 +9999999999999999999999999988888888888888888888888888888888888888888888888888888888888888888888555555555555555555555535555555555555555555555555555555555 +9999999999999998889999999888888888888888888888888888888888888888888888888888888888888888885555555555555555555555555355555555555555555555555555555555555 +9999999999999988888899998888888888888888888888888888888888888888888888888888888888888885555555555555555555555555553555555555555555555555555555555555555 +9999988889999888888888888888888888888888888888888888888888888888888888888888555558888833333333333333333333553333335555555555555555555555555555555555555 +9888888888888888888888888888888888888888888888888888888888888888888888855555555555555333333333333333333333355555533555555555555555555555555555555555555 +8888888888888888888888888888888888888555555558888888888888555555555555555555555555533333333333333333333333335555535355555555555555533333333333333333333 +8888888888888888888888555588885555555555555555555555555555555555555555555555555555333333333333333333333333344444434434444444333333333333333333333333333 +8888888888888888855555555555555555555555555555555555555555555555555555555555555444433333333333333333333333344444434444444433333333333333333333333333333 +8888888855555555555555555555555555555555555555555555544444444444444444444444444444433333333333333333333333344433333333333333333333333333333333333333333 +5555555555555555555555555555555555555555555544444444444444443333333333333333333333333333333333333333333333333333333333333333333333333111111111111111111 +5555555555555555555555555555555555544444444444444444444444333333333333333333333333333333333333333333333333333333333333331111111111111111111111111111111 +5555555555555555544444444444444444444444444444444444443333333333333333333333333333333333333333333333333333333311111111111111111111111111111111111111111 +4444444444444444444444444444444444444444444444444333333333333333333333333333333333333333333333333333333331111111111111111111111111111111111111111111111 +3333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333111111111111111111111111111111111111111111111111 +3333333333333333333333333333333333333333333333333333333333333333333333333333333333332222222222222221111111111111111111111111111111111111111111111111111 +2222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222111111111111111111111111111111111111111111111111111111111111 +2222222222222222222222222222222222221212222222222222222222222222222222222211111111111111111111111111111111111111111111111111111111111111111111111111111 +1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 +1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 +1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 +1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 \ No newline at end of file diff --git a/scene1/early.txt b/scene1/early.txt new file mode 100644 index 0000000..1a74d15 --- /dev/null +++ b/scene1/early.txt @@ -0,0 +1,31 @@ + . * + ` * . ` + * ` .: TWA. * + * . . / 0 Vm * + , . 'B . + * * * |' D + ` '| O P + \\ +. ' `=____-' + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/scene1/foreground.txt b/scene1/foreground.txt new file mode 100644 index 0000000..928a590 --- /dev/null +++ b/scene1/foreground.txt @@ -0,0 +1,31 @@ + + + . + __ ./ \. + __ ___.--'' \ _.--'\/ + . ___,.-" '-, _--'' \ / / + ./ \. /''\_.-. _-". .-. __.--'" '--.____ ___.. _-' '-/ + / \_.-' \-. ,-" \_-""" \-" "---' \--" + __/ ' ,----.___ + / _._________ _______..------'' ''- + .-' _.--''' ''--/-'''' + .__ / _-'` / + / '- / .-' __ / + __.. / '--" _.--. /-----""----,-,_____ -----O ' + _.-' '--' __.-' `-__//-\___-' .' _ '. \ |\ ' +' _,-.,___ ____,,,,..../ ,-'|+|,- .' |+| '-,-" | \ + ____ ,.----" \___,----''`` -,-- ---| | | \ .= + ..--' '--/ . - | == == | _ _ | | + .--.___.' __ | === | |+| |+| | | ' +-'-.___/ ' _ - _ _ __ ___ __|__|||___|_____________|___ _|_ _ + ' .- - ' + ' -' - ' + ' . + _ . - - " ' && ' + ' _..= - ' + ' _.' - ' + ' ' * * ' ' ' + *|*|* @&& , && + ' &&&@& ' ' + ' ' + \ No newline at end of file diff --git a/scene1/morning.txt b/scene1/morning.txt new file mode 100644 index 0000000..a6fde3a --- /dev/null +++ b/scene1/morning.txt @@ -0,0 +1,31 @@ + '--__ | __------ + --__ ,.---., __--- + -__ ;/-'''-\; --' + :|' `|: + :|. ,|; --__ ___ + ;\-___-/; ''-- ____ + , `'---'` '----_____ + / -------____ + ' \ ____ + , \ '''-------- + / + / \ + / \ + ' \ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/scene1/night.txt b/scene1/night.txt new file mode 100644 index 0000000..995e3c2 --- /dev/null +++ b/scene1/night.txt @@ -0,0 +1,31 @@ + . * + ` * . ` + * .: TWA. ` * + / 0 Vm * . . * + , . 'B * . + |' D * * * + '| O P + \\ +. `=____-' ' + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/scene1/wind.txt b/scene1/wind.txt new file mode 100644 index 0000000..6040f02 --- /dev/null +++ b/scene1/wind.txt @@ -0,0 +1,31 @@ + + + . + __ ./ \. + __ ___.--'' \ _.--'\/ + . ___,.-" '-, _--'' \ / / + ./ \. /''\_.-. _-". .-. __.--'" '--.____ ___.. _-' '-/ + / \_.-' \-. ,-" \_-""" \-" "---' \--" + __/ ' ,----.___ + / _._________ _______..------'' ''- + .-' _.--''' ''--/-'''' + .__ / _-'` / + / '- / .-' __ / + __.. / '--" _.--. /-----""----,-,_____ -----O ` + _.-' '--' __.-' `-__//-\___-' .' _ '. \ |\ ` +' _,-.,___ ____,,,,..../ ,-'|+|,- .' |+| '-,-" | \ + ____ ,.----" \___,----''`` -,-- ---| | | \ .= + ..--' '--/ . - | == == | _ _ | | + .--.___.' __ | === | |+| |+| | | ` +-'-.___/ ` _ - _ _ __ ___ __|__|||___|_____________|___ _|_ _ + ` .- - ' + ` -' - ' + ' . + _ . - - " ` && ' + ' _..= - ` + ' _.` - ' + ` ` + + ` ` ` + +|+|+ @&& , && + ' &&&@& ` ` + ` ` + \ No newline at end of file