aboutsummaryrefslogtreecommitdiff
path: root/internal/render/table.go
blob: 3219e9d3d74f93ad8cb017eec1719917f5607eab (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
package render

import (
	"fmt"
	"strings"

	"github.com/lukaszkasprzak/prognosis/internal/config"
	"github.com/lukaszkasprzak/prognosis/internal/openmeteo"
)

// cell is one rendered table cell: text, its colour, and how it is aligned.
type cell struct {
	text  string
	style string
	left  bool
}

// custom returns the config's definition of a column, if it has one.
func (x ctx) custom(name string) (config.CustomColumn, bool) {
	cc, ok := x.cfg.Custom[name]
	return cc, ok
}

// customLabel is the header for a custom column: what the user asked for, or
// the column's own name.
func customLabel(name string, cc config.CustomColumn) string {
	if cc.Label != "" {
		return cc.Label
	}
	return name
}

// colWidth is the reserved display width per column.
func (x ctx) colWidth(name string) int {
	if cc, ok := x.custom(name); ok {
		if cc.Width > 0 {
			return cc.Width
		}
		// Wide enough for the header, and for a value of a few digits.
		w := DisplayWidth(customLabel(name, cc))
		if w < 5 {
			w = 5
		}
		return w
	}
	switch name {
	case "hour":
		// Three, not two: the Python leaves a double space after the hour.
		return 3
	case "icon":
		return IconWidth(x.cfg.Icons)
	case "temp":
		return 5
	case "feels":
		return 6
	case "conditions":
		return 16
	case "mm", "rain", "wind", "gusts", "humidity", "dew", "uv", "cloud", "visibility":
		return 5
	case "dir":
		return 3
	case "pressure":
		return 6
	}
	return 6
}

func (x ctx) leftAligned(name string) bool {
	if _, ok := x.custom(name); ok {
		return false // custom columns are numeric
	}
	switch name {
	case "hour", "icon", "temp", "feels", "conditions":
		return true
	}
	return false
}

// visible drops columns that have nothing to say: the icon column when icons
// are off, and the rain pair on a dry window.
func (x ctx) visible() []string {
	var out []string
	for _, name := range x.cfg.Columns {
		if name == "icon" && x.cfg.Icons == "none" {
			continue
		}
		if (name == "mm" || name == "rain") && !x.wet {
			continue
		}
		out = append(out, name)
	}
	return out
}

// row assembles one line from cells, padding each to its column width BEFORE
// colouring it. Escape sequences carry no display width, so padding a coloured
// string misaligns every column to its right -- invisible when piped, obvious
// in a terminal.
func (x ctx) row(cells map[string]cell) string {
	var b strings.Builder
	for _, name := range x.visible() {
		c := cells[name]
		w := x.colWidth(name)
		padded := PadLeft(c.text, w)
		if x.leftAligned(name) {
			padded = Pad(c.text, w)
		}
		b.WriteString(" ")
		b.WriteString(x.c(c.style, padded))
	}
	return strings.TrimRight(b.String(), " ")
}

func (x ctx) table(v View) []string {
	out := []string{""}

	headers := map[string]cell{}
	for _, name := range x.visible() {
		text := x.cat.Header(name)
		if cc, ok := x.custom(name); ok {
			// A user-declared label is not ours to translate.
			text = customLabel(name, cc)
		}
		headers[name] = cell{text: text, style: ""}
	}
	out = append(out, x.c(Underline, x.rowPlain(headers)))

	day := v.Rows[0].When.Format("2006-01-02")
	prevCode := -1
	for i, r := range v.Rows {
		if d := r.When.Format("2006-01-02"); d != day {
			day = d
			sep := " -- " + x.cat.Date(r.When) + " --"
			if sun, ok := v.Sun[d]; ok {
				sep += fmt.Sprintf("  %s %s / %s", x.cat.Word("sun"), sun[0], sun[1])
			}
			out = append(out, x.c(Dim, sep))
			prevCode = -1 // repeat the conditions once per day for context
		}
		out = append(out, x.row(x.cells(r, i == 0, &prevCode)))
	}
	return out
}

// rowPlain is the header row: padded like the data but never coloured per cell,
// so the underline runs unbroken across it.
func (x ctx) rowPlain(cells map[string]cell) string {
	var b strings.Builder
	for _, name := range x.visible() {
		w := x.colWidth(name)
		text := cells[name].text
		padded := PadLeft(text, w)
		if x.leftAligned(name) {
			padded = Pad(text, w)
		}
		b.WriteString(" ")
		b.WriteString(padded)
	}
	return b.String()
}

func (x ctx) cells(r openmeteo.Row, isNow bool, prevCode *int) map[string]cell {
	out := map[string]cell{}
	temp, hasTemp := r.Val("temperature_2m")

	for _, name := range x.visible() {
		if cc, ok := x.custom(name); ok {
			out[name] = x.customCell(r, name, cc)
			continue
		}
		switch name {
		case "hour":
			style := Reset
			if isNow {
				style = Bold
			}
			out[name] = cell{text: r.When.Format("15"), style: style}

		case "icon":
			out[name] = cell{text: Icon(x.cfg.Icons, r.Code)}

		case "temp":
			style := TempStyle(x.celsius(temp))
			// The current hour keeps its emphasis on top of the heat colour.
			if isNow {
				style = Bold + ";" + style
			}
			out[name] = cell{text: fmt.Sprintf("%d°", Deg(temp)), style: style}

		case "feels":
			text := ""
			if feels, ok := r.Val("apparent_temperature"); ok && hasTemp {
				// Only shown when it differs; otherwise it is a column of noise.
				if abs(feels-temp) >= 1 {
					text = fmt.Sprintf("(%d)", Deg(feels))
				}
			}
			out[name] = cell{text: text, style: Dim}

		case "conditions":
			text := ""
			// Only when they change: an unbroken column of "overcast" hides the
			// hour it stops being overcast, which is the only interesting part.
			if r.Code != *prevCode {
				text = Truncate(x.cat.Condition(r.Code), x.colWidth(name))
			}
			out[name] = cell{text: text}

		case "mm":
			mm, _ := r.Val("precipitation")
			style := Dim
			if mm > 0 {
				style = Cyan
			}
			out[name] = cell{text: round1(mm), style: style}

		case "rain":
			p, _ := r.Val("precipitation_probability")
			style := Dim
			if p >= 50 {
				style = Yellow
			}
			out[name] = cell{text: fmt.Sprintf("%d%%", int(p)), style: style}

		case "wind":
			v, _ := r.Val("wind_speed_10m")
			out[name] = cell{text: fmt.Sprintf("%d", int(v))}

		case "gusts":
			v, _ := r.Val("wind_gusts_10m")
			style := ""
			if v >= 60 {
				style = Yellow
			}
			out[name] = cell{text: fmt.Sprintf("%d", int(v)), style: style}

		case "dir":
			v, _ := r.Val("wind_direction_10m")
			out[name] = cell{text: compass(v)}

		case "humidity":
			v, _ := r.Val("relative_humidity_2m")
			out[name] = cell{text: fmt.Sprintf("%d%%", int(v)), style: Dim}

		case "dew":
			v, _ := r.Val("dew_point_2m")
			out[name] = cell{text: fmt.Sprintf("%d°", Deg(v)), style: Dim}

		case "uv":
			v, _ := r.Val("uv_index")
			style := Dim
			if v >= 6 {
				style = Yellow
			}
			out[name] = cell{text: round1(v), style: style}

		case "cloud":
			v, _ := r.Val("cloud_cover")
			out[name] = cell{text: fmt.Sprintf("%d%%", int(v)), style: Dim}

		case "pressure":
			v, _ := r.Val("pressure_msl")
			out[name] = cell{text: fmt.Sprintf("%d", int(v)), style: Dim}

		case "visibility":
			v, _ := r.Val("visibility")
			out[name] = cell{text: fmt.Sprintf("%.0fkm", v/1000), style: Dim}
		}
	}
	*prevCode = r.Code
	return out
}

func abs(f float64) float64 {
	if f < 0 {
		return -f
	}
	return f
}

// CustomKey is where a custom column's value lives in a Row.
//
// Prefixed so a column called "temp" or "visibility" can never shadow the API
// field of the same name that a built-in column reads.
func CustomKey(name string) string { return "x:" + name }

// customCell formats one user-declared column.
//
// A value the API did not supply renders blank rather than as zero: for an
// allergen or a soil reading, "no data" and "none" are different claims.
func (x ctx) customCell(r openmeteo.Row, name string, cc config.CustomColumn) cell {
	key := cc.Field
	if cc.Source == "air" {
		key = CustomKey(name)
	}
	v, ok := r.Val(key)
	if !ok {
		return cell{text: "", style: Dim}
	}
	return cell{
		text:  fmt.Sprintf("%.*f%s", cc.Decimals, v, cc.Suffix),
		style: Dim,
	}
}