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, } }