Skip to content

Commit 72b190c

Browse files
committed
buttons: long-press detector with staged thresholds
Closes the §6.7 reset-button countdown TODO from the bubble-hwd commit. NewLongPress wraps a Reader and emits Stage events as a held press crosses configured durations. Releases before a threshold are silent; releases after at least one threshold emit a Released stage so countdown UIs can revert cleanly. Wires onto the §6.7 'hold reset to factory-reset' flow: NewLongPress(ctx, r, Reset, []time.Duration{ 2*time.Second, // amber LED 5*time.Second, // red LED 10*time.Second, // factory reset }) The watcher runs as a goroutine, drops Stage events when a slow consumer would block (we never want to stall the input loop on UI rendering), and closes its output channel on ctx cancellation. Tests cover staged emission, Up-before-threshold (silent), Up-after- threshold (Released), wrong-button filtering, ctx cancellation, and empty-thresholds. 6 new tests, all green; vet + gofmt clean.
1 parent aa716e3 commit 72b190c

2 files changed

Lines changed: 342 additions & 0 deletions

File tree

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package buttons
2+
3+
import (
4+
"context"
5+
"sync"
6+
"time"
7+
)
8+
9+
// LongPress is a stateful watcher that consumes raw Down/Up events
10+
// from a Reader and emits "the user has been holding this button for
11+
// at least this long" notifications at configurable thresholds.
12+
//
13+
// DESIGN.md §6.7 needs this for the reset-button factory-reset
14+
// countdown: 2-5 s shows amber LED feedback, 5-10 s shows red,
15+
// >10 s triggers the actual reset. We model that as a list of
16+
// thresholds; the watcher emits a Stage event each time the press
17+
// crosses one. Releases (Up) before a threshold cancel the press.
18+
//
19+
// The watcher runs as a background goroutine; callers consume from
20+
// Stages() until ctx is cancelled. Multi-button setups instantiate
21+
// one LongPress per button.
22+
type LongPress struct {
23+
button Button
24+
thresholds []time.Duration
25+
26+
out chan Stage
27+
now func() time.Time
28+
stop chan struct{}
29+
30+
mu sync.Mutex
31+
closed bool
32+
}
33+
34+
// Stage is emitted when a held press crosses a configured threshold.
35+
// Released indicates the user let go before reaching this Stage —
36+
// always emitted with the highest reached threshold so callers can
37+
// undo any countdown UI they were showing.
38+
type Stage struct {
39+
Button Button
40+
Index int // 0-based position in the configured thresholds
41+
Hold time.Duration // how long the press has been active
42+
Released bool // true if the user let go before/at this stage
43+
}
44+
45+
// NewLongPress wires a LongPress watcher to the given Reader. The
46+
// watcher reads from r.Events() in a goroutine and writes Stage
47+
// events to the returned channel. Stop the watcher by cancelling
48+
// ctx; the channel is closed afterward.
49+
//
50+
// Thresholds must be in strictly ascending order. An empty list
51+
// disables the watcher (it forwards no Stages but still cleans up
52+
// on ctx cancellation).
53+
func NewLongPress(ctx context.Context, r Reader, button Button, thresholds []time.Duration) *LongPress {
54+
lp := &LongPress{
55+
button: button,
56+
thresholds: thresholds,
57+
out: make(chan Stage, 16),
58+
now: time.Now,
59+
stop: make(chan struct{}),
60+
}
61+
go lp.loop(ctx, r)
62+
return lp
63+
}
64+
65+
// Stages returns the channel of emitted Stage events. Closed when
66+
// the watcher exits (ctx cancellation).
67+
func (l *LongPress) Stages() <-chan Stage { return l.out }
68+
69+
// SetClock overrides the time source for tests.
70+
func (l *LongPress) SetClock(f func() time.Time) {
71+
l.mu.Lock()
72+
l.now = f
73+
l.mu.Unlock()
74+
}
75+
76+
func (l *LongPress) loop(ctx context.Context, r Reader) {
77+
defer l.close()
78+
79+
var (
80+
pressedAt time.Time
81+
lastStage = -1
82+
timer *time.Timer
83+
timerCh <-chan time.Time
84+
)
85+
86+
resetTimer := func() {
87+
if timer != nil {
88+
timer.Stop()
89+
timer = nil
90+
timerCh = nil
91+
}
92+
}
93+
scheduleNext := func() {
94+
resetTimer()
95+
next := lastStage + 1
96+
if next >= len(l.thresholds) {
97+
return
98+
}
99+
l.mu.Lock()
100+
now := l.now()
101+
l.mu.Unlock()
102+
fireAt := pressedAt.Add(l.thresholds[next])
103+
delay := fireAt.Sub(now)
104+
if delay < 0 {
105+
delay = 0
106+
}
107+
timer = time.NewTimer(delay)
108+
timerCh = timer.C
109+
}
110+
111+
emit := func(s Stage) {
112+
select {
113+
case l.out <- s:
114+
default:
115+
// Drop if consumer is slow; we don't want to block the loop.
116+
}
117+
}
118+
119+
for {
120+
select {
121+
case <-ctx.Done():
122+
return
123+
case ev, ok := <-r.Events():
124+
if !ok {
125+
return
126+
}
127+
if ev.Button != l.button {
128+
continue
129+
}
130+
switch ev.Kind {
131+
case Down:
132+
if !pressedAt.IsZero() {
133+
// Already pressed (event repeat?). Ignore.
134+
continue
135+
}
136+
l.mu.Lock()
137+
pressedAt = l.now()
138+
l.mu.Unlock()
139+
lastStage = -1
140+
scheduleNext()
141+
case Up:
142+
if pressedAt.IsZero() {
143+
continue
144+
}
145+
if lastStage >= 0 {
146+
// Notify caller of release at the highest reached stage
147+
// so UI countdowns can revert.
148+
l.mu.Lock()
149+
hold := l.now().Sub(pressedAt)
150+
l.mu.Unlock()
151+
emit(Stage{
152+
Button: l.button,
153+
Index: lastStage,
154+
Hold: hold,
155+
Released: true,
156+
})
157+
}
158+
resetTimer()
159+
pressedAt = time.Time{}
160+
lastStage = -1
161+
}
162+
case <-timerCh:
163+
if pressedAt.IsZero() {
164+
resetTimer()
165+
continue
166+
}
167+
next := lastStage + 1
168+
l.mu.Lock()
169+
hold := l.now().Sub(pressedAt)
170+
l.mu.Unlock()
171+
emit(Stage{
172+
Button: l.button,
173+
Index: next,
174+
Hold: hold,
175+
})
176+
lastStage = next
177+
scheduleNext()
178+
}
179+
}
180+
}
181+
182+
func (l *LongPress) close() {
183+
l.mu.Lock()
184+
defer l.mu.Unlock()
185+
if l.closed {
186+
return
187+
}
188+
l.closed = true
189+
close(l.out)
190+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package buttons
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
)
8+
9+
func TestLongPressEmitsStagesInOrder(t *testing.T) {
10+
ctx, cancel := context.WithCancel(context.Background())
11+
defer cancel()
12+
13+
r := NewMock()
14+
defer r.Stop()
15+
16+
lp := NewLongPress(ctx, r, Reset, []time.Duration{
17+
50 * time.Millisecond,
18+
120 * time.Millisecond,
19+
250 * time.Millisecond,
20+
})
21+
22+
r.Down(Reset)
23+
24+
var got []Stage
25+
deadline := time.After(800 * time.Millisecond)
26+
loop:
27+
for len(got) < 3 {
28+
select {
29+
case s := <-lp.Stages():
30+
got = append(got, s)
31+
case <-deadline:
32+
break loop
33+
}
34+
}
35+
r.Up(Reset)
36+
37+
if len(got) != 3 {
38+
t.Fatalf("got %d stages, want 3: %+v", len(got), got)
39+
}
40+
for i, s := range got {
41+
if s.Released {
42+
t.Errorf("stage %d should not be released", i)
43+
}
44+
if s.Index != i {
45+
t.Errorf("stage %d Index = %d, want %d", i, s.Index, i)
46+
}
47+
if s.Button != Reset {
48+
t.Errorf("stage %d Button = %s", i, s.Button)
49+
}
50+
}
51+
}
52+
53+
func TestLongPressUpBeforeFirstThresholdEmitsNothing(t *testing.T) {
54+
ctx, cancel := context.WithCancel(context.Background())
55+
defer cancel()
56+
r := NewMock()
57+
defer r.Stop()
58+
59+
lp := NewLongPress(ctx, r, Reset, []time.Duration{500 * time.Millisecond})
60+
r.Down(Reset)
61+
time.Sleep(20 * time.Millisecond)
62+
r.Up(Reset)
63+
64+
select {
65+
case s := <-lp.Stages():
66+
t.Fatalf("expected no stages, got %+v", s)
67+
case <-time.After(200 * time.Millisecond):
68+
// Good.
69+
}
70+
}
71+
72+
func TestLongPressReleaseAfterStageEmitsReleased(t *testing.T) {
73+
ctx, cancel := context.WithCancel(context.Background())
74+
defer cancel()
75+
r := NewMock()
76+
defer r.Stop()
77+
78+
lp := NewLongPress(ctx, r, Reset, []time.Duration{
79+
50 * time.Millisecond,
80+
500 * time.Millisecond,
81+
})
82+
r.Down(Reset)
83+
84+
// Wait for the first stage to fire.
85+
first := <-lp.Stages()
86+
if first.Released {
87+
t.Fatal("first stage should not be Released")
88+
}
89+
90+
r.Up(Reset)
91+
92+
released := <-lp.Stages()
93+
if !released.Released {
94+
t.Fatalf("expected Released=true, got %+v", released)
95+
}
96+
if released.Index != 0 {
97+
t.Errorf("Released Index = %d, want 0 (highest reached)", released.Index)
98+
}
99+
}
100+
101+
func TestLongPressIgnoresOtherButtons(t *testing.T) {
102+
ctx, cancel := context.WithCancel(context.Background())
103+
defer cancel()
104+
r := NewMock()
105+
defer r.Stop()
106+
107+
lp := NewLongPress(ctx, r, Reset, []time.Duration{30 * time.Millisecond})
108+
r.Down(Mode1)
109+
r.Up(Mode1)
110+
111+
select {
112+
case s := <-lp.Stages():
113+
t.Fatalf("unexpected stage from non-Reset button: %+v", s)
114+
case <-time.After(80 * time.Millisecond):
115+
}
116+
}
117+
118+
func TestLongPressCancelClosesChannel(t *testing.T) {
119+
ctx, cancel := context.WithCancel(context.Background())
120+
r := NewMock()
121+
defer r.Stop()
122+
123+
lp := NewLongPress(ctx, r, Reset, []time.Duration{50 * time.Millisecond})
124+
cancel()
125+
126+
// Channel should close within a beat.
127+
select {
128+
case _, ok := <-lp.Stages():
129+
if ok {
130+
t.Error("expected closed channel after cancel")
131+
}
132+
case <-time.After(200 * time.Millisecond):
133+
t.Fatal("Stages channel did not close after cancel")
134+
}
135+
}
136+
137+
func TestLongPressEmptyThresholdsIsHarmless(t *testing.T) {
138+
ctx, cancel := context.WithCancel(context.Background())
139+
defer cancel()
140+
r := NewMock()
141+
defer r.Stop()
142+
143+
lp := NewLongPress(ctx, r, Reset, nil)
144+
r.Down(Reset)
145+
r.Up(Reset)
146+
147+
select {
148+
case s := <-lp.Stages():
149+
t.Fatalf("expected no stages, got %+v", s)
150+
case <-time.After(60 * time.Millisecond):
151+
}
152+
}

0 commit comments

Comments
 (0)