diff --git a/README.md b/README.md index 995b800..98feab3 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,40 @@ corresponding icons with correct names need to be placed in `~/.local/share/deckmaster/themes/[theme]`. The default icons with their respective names can be found [here](https://github.com/muesli/deckmaster/tree/master/assets/weather). +#### Timer + +A flexible widget that can display a timer/countdown and displays its remaining time. + +```toml +[keys.widget] + id = "timer" + [keys.widget.config] + times = "5s;10m;30m;1h5m" # optional + font = "bold;regular;thin" # optional + color = "#fefefe;#0f0f0f;#00ff00;" # optional + underflow = "false" # optional + underflowColor = "#ff0000;#ff0000;#ff0000" # optional +``` + +With `layout` custom layouts can be definded in the format `[posX]x[posY]+[width]x[height]`. + +Values for `format` are: + +| % | gets replaced with | +| --- | ------------------------------------------------------------------ | +| %h | 12-hour format of an hour with leading zeros | +| %H | 24-hour format of an hour with leading zeros | +| %i | Minutes with leading zeros | +| %I | Minutes without leading zeros | +| %s | Seconds with leading zeros | +| %S | Seconds without leading zeros | +| %a | Lowercase Ante meridiem and Post meridiem | + +The timer can be started and paused by short pressing the button. +When triggering the hold action the next timer in the times list is selected if +no timer is running. If the timer is paused, it will be reset. +The setting underflow determines whether the timer keeps ticking after exceeding its deadline. + ### Background Image You can configure each deck to display an individual wallpaper behind its diff --git a/config.go b/config.go index ad9d17a..694f576 100644 --- a/config.go +++ b/config.go @@ -8,6 +8,7 @@ import ( "reflect" "strconv" "strings" + "time" "github.com/BurntSushi/toml" colorful "github.com/lucasb-eyer/go-colorful" @@ -136,6 +137,14 @@ func ConfigValue(v interface{}, dst interface{}) error { default: return fmt.Errorf("unhandled type %+v for color.Color conversion", reflect.TypeOf(vt)) } + case *time.Duration: + switch vt := v.(type) { + case string: + x, _ := time.ParseDuration(vt) + *d = x + default: + return fmt.Errorf("unhandled type %+v for time.Duration conversion", reflect.TypeOf(vt)) + } case *[]string: switch vt := v.(type) { @@ -158,6 +167,19 @@ func ConfigValue(v interface{}, dst interface{}) error { default: return fmt.Errorf("unhandled type %+v for []color.Color conversion", reflect.TypeOf(vt)) } + case *[]time.Duration: + switch vt := v.(type) { + case string: + durationsString := strings.Split(vt, ";") + var durations []time.Duration + for _, durationString := range durationsString { + duration, _ := time.ParseDuration(durationString) + durations = append(durations, duration) + } + *d = durations + default: + return fmt.Errorf("unhandled type %+v for []time.Duration conversion", reflect.TypeOf(vt)) + } default: return fmt.Errorf("unhandled dst type %+v", reflect.TypeOf(dst)) diff --git a/decks/main.deck b/decks/main.deck index c73c985..a7f1ef0 100644 --- a/decks/main.deck +++ b/decks/main.deck @@ -60,6 +60,19 @@ [keys.action_hold] keycode = "Mute" +[[keys]] + index = 7 + [keys.widget] + id = "timer" + [keys.widget.config] + times = "5s;10m;30m;1h5m" # optional + format = "%Hh;%Im;%Ss" + font = "bold;regular;thin" # optional + #color = "#fefefe" # optional + underflow = "false" # optional + underflowColor = "#ff0000;#ff0000;#ff0000" # optional + + [[keys]] index = 8 [keys.widget] diff --git a/widget.go b/widget.go index 60764b1..a3175e5 100644 --- a/widget.go +++ b/widget.go @@ -124,6 +124,9 @@ func NewWidget(dev *streamdeck.Device, base string, kc KeyConfig, bg image.Image case "weather": return NewWeatherWidget(bw, kc.Widget) + + case "timer": + return NewTimerWidget(bw, kc.Widget), nil } // unknown widget ID diff --git a/widget_timer.go b/widget_timer.go new file mode 100644 index 0000000..bcde168 --- /dev/null +++ b/widget_timer.go @@ -0,0 +1,186 @@ +package main + +import ( + "image" + "image/color" + "strings" + "time" +) + +// TimerWidget is a widget displaying a timer +type TimerWidget struct { + *BaseWidget + + times []time.Duration + + formats []string + fonts []string + colors []color.Color + frames []image.Rectangle + + underflow bool + underflowColors []color.Color + currIndex int + + data TimerData +} + +type TimerData struct { + startTime time.Time + pausedTime time.Time +} + +func (d *TimerData) IsPaused() bool { + return !d.pausedTime.IsZero() +} + +func (d *TimerData) IsRunning() bool { + return !d.IsPaused() && d.HasDeadline() +} + +func (d *TimerData) HasDeadline() bool { + return !d.startTime.IsZero() +} + +func (d *TimerData) Clear() { + d.startTime = time.Time{} + d.pausedTime = time.Time{} +} + +// NewTimerWidget returns a new TimerWidget +func NewTimerWidget(bw *BaseWidget, opts WidgetConfig) *TimerWidget { + bw.setInterval(time.Duration(opts.Interval)*time.Millisecond, time.Second/2) + + var times []time.Duration + var formats, fonts, frameReps []string + var colors, underflowColors []color.Color + var underflow bool + + _ = ConfigValue(opts.Config["times"], ×) + + _ = ConfigValue(opts.Config["format"], &formats) + _ = ConfigValue(opts.Config["font"], &fonts) + _ = ConfigValue(opts.Config["color"], &colors) + _ = ConfigValue(opts.Config["layout"], &frameReps) + + _ = ConfigValue(opts.Config["underflow"], &underflow) + _ = ConfigValue(opts.Config["underflowColor"], &underflowColors) + + if len(times) == 0 { + defaultDuration, _ := time.ParseDuration("30m") + times = append(times, defaultDuration) + } + + layout := NewLayout(int(bw.dev.Pixels)) + frames := layout.FormatLayout(frameReps, len(formats)) + + for i := 0; i < len(formats); i++ { + if len(fonts) < i+1 { + fonts = append(fonts, "regular") + } + if len(colors) < i+1 { + colors = append(colors, DefaultColor) + } + if len(underflowColors) < i+1 { + underflowColors = append(underflowColors, DefaultColor) + } + } + + return &TimerWidget{ + BaseWidget: bw, + times: times, + formats: formats, + fonts: fonts, + colors: colors, + frames: frames, + underflow: underflow, + underflowColors: underflowColors, + currIndex: 0, + data: TimerData{ + startTime: time.Time{}, + pausedTime: time.Time{}, + }, + } +} + +// Update renders the widget. +func (w *TimerWidget) Update() error { + if w.data.IsPaused() { + return nil + } + size := int(w.dev.Pixels) + img := image.NewRGBA(image.Rect(0, 0, size, size)) + var str string + + for i := 0; i < len(w.formats); i++ { + var fontColor = w.colors[i] + + if !w.data.HasDeadline() { + str = Timespan(w.times[w.currIndex]).Format(w.formats[i]) + } else { + remainingDuration := time.Until(w.data.startTime.Add(w.times[w.currIndex])) + if remainingDuration < 0 && !w.underflow { + str = Timespan(w.times[w.currIndex]).Format(w.formats[i]) + w.data.Clear() + } else if remainingDuration < 0 && w.underflow { + fontColor = w.underflowColors[i] + str = Timespan(remainingDuration * -1).Format(w.formats[i]) + } else { + str = Timespan(remainingDuration).Format(w.formats[i]) + } + } + font := fontByName(w.fonts[i]) + + drawString(img, + w.frames[i], + font, + str, + w.dev.DPI, + -1, + fontColor, + image.Pt(-1, -1)) + } + + return w.render(w.dev, img) +} + +type Timespan time.Duration + +func (t Timespan) Format(format string) string { + tm := map[string]string{ + "%h": "03", + "%H": "15", + "%i": "04", + "%s": "05", + "%I": "4", + "%S": "5", + "%a": "PM", + } + + for k, v := range tm { + format = strings.ReplaceAll(format, k, v) + } + + z := time.Unix(0, 0).UTC() + return z.Add(time.Duration(t)).Format(format) +} + +func (w *TimerWidget) TriggerAction(hold bool) { + if hold { + if w.data.IsPaused() { + w.data.Clear() + } else if !w.data.HasDeadline() { + w.currIndex = (w.currIndex + 1) % len(w.times) + } + } else { + if w.data.IsRunning() { + w.data.pausedTime = time.Now() + } else if w.data.IsPaused() && w.data.HasDeadline() { + pausedDuration := time.Now().Sub(w.data.pausedTime) + w.data.startTime = w.data.startTime.Add(pausedDuration) + w.data.pausedTime = time.Time{} + } else { + w.data.startTime = time.Now() + } + } +}