1package usecase
2
3import (
4 "context"
5 "fmt"
6 "strings"
7 "sync"
8 "time"
9 "unicode/utf8"
10
11 "github.com/bnema/dumber/internal/application/dto"
12 "github.com/bnema/dumber/internal/application/port"
13 "github.com/bnema/dumber/internal/logging"
14)
15
16const explicitClipboardDedupWindow = 250 * time.Millisecond
17
18// ClipboardTextOrchestratorUseCase applies shared clipboard business rules.
19type ClipboardTextOrchestratorUseCase struct {
20 clipboard port.Clipboard
21 autoCopyConfig port.AutoCopyConfig
22 toast func(textLen int)
23 mu sync.Mutex
24 lastSelection map[clipboardScope]string
25 lastExplicit map[clipboardScope]explicitCopyState
26 now func() time.Time
27}
28
29type clipboardScope struct {
30 source dto.ClipboardSource
31 viewID uint64
32}
33
34type explicitCopyState struct {
35 text string
36 action string
37 at time.Time
38}
39
40var _ port.ClipboardTextOrchestrator = (*ClipboardTextOrchestratorUseCase)(nil)
41
42// NewClipboardTextOrchestrator creates a shared clipboard orchestrator.
43func NewClipboardTextOrchestrator(
44 clipboard port.Clipboard,
45 autoCopyConfig port.AutoCopyConfig,
46 toast func(textLen int),
47) *ClipboardTextOrchestratorUseCase {
48 return &ClipboardTextOrchestratorUseCase{
49 clipboard: clipboard,
50 autoCopyConfig: autoCopyConfig,
51 toast: toast,
52 lastSelection: make(map[clipboardScope]string),
53 lastExplicit: make(map[clipboardScope]explicitCopyState),
54 now: time.Now,
55 }
56}
57
58// HandleSelectionUpdate applies auto-copy selection rules.
59func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate(
60 ctx context.Context,
61 input dto.SelectionClipboardInput,
62) error {
63 if uc == nil || uc.autoCopyConfig == nil || !uc.autoCopyConfig.IsAutoCopyEnabled() {
64 if uc != nil {
65 uc.mu.Lock()
66 delete(uc.lastSelection, selectionScope(input))
67 uc.mu.Unlock()
68 }
69 return nil
70 }
71
72 if input.Text == "" {
73 uc.mu.Lock()
74 delete(uc.lastSelection, selectionScope(input))
75 uc.mu.Unlock()
76 return nil
77 }
78
79 if utf8.RuneCountInString(strings.TrimSpace(input.Text)) < 2 {
80 return nil
81 }
82 textLen := utf8.RuneCountInString(input.Text)
83 scope := selectionScope(input)
84 currentTime := uc.currentTime()
85
86 uc.mu.Lock()
87 if input.Text == uc.lastSelection[scope] {
88 uc.mu.Unlock()
89 return nil
90 }
91 if uc.clipboard == nil {
92 uc.mu.Unlock()
93 return fmt.Errorf("clipboard not available")
94 }
95 selectionState := input.Text
96 explicitInput := dto.ExplicitClipboardInput{SourceEngine: input.SourceEngine, ViewID: input.ViewID, Action: "copy"}
97 explicitState := explicitCopyState{text: input.Text, action: explicitInput.Action, at: currentTime}
98 uc.lastSelection[scope] = selectionState
99 uc.lastExplicit[explicitScope(explicitInput)] = explicitState
100 uc.mu.Unlock()
101
102 if err := uc.clipboard.WriteText(ctx, input.Text); err != nil {
103 uc.mu.Lock()
104 if uc.lastSelection[scope] == selectionState {
105 delete(uc.lastSelection, scope)
106 }
107 if state, ok := uc.lastExplicit[explicitScope(explicitInput)]; ok && state == explicitState {
108 delete(uc.lastExplicit, explicitScope(explicitInput))
109 }
110 uc.mu.Unlock()
111 return fmt.Errorf("clipboard write failed: %w", err)
112 }
113
114 if uc.toast != nil {
115 uc.toast(textLen)
116 }
117 logging.FromContext(ctx).Debug().
118 Int("text_len", textLen).
119 Str("source_engine", string(input.SourceEngine)).
120 Msg("clipboard selection copied")
121 return nil
122}
123
124// HandleExplicitCopy applies explicit copy rules.
125func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy(
126 ctx context.Context,
127 input dto.ExplicitClipboardInput,
128) error {
129 if uc == nil || input.Text == "" {
130 return nil
131 }
132
133 currentTime := uc.currentTime()
134 textLen := utf8.RuneCountInString(input.Text)
135 scope := explicitScope(input)
136
137 state := explicitCopyState{text: input.Text, action: input.Action, at: currentTime}
138
139 uc.mu.Lock()
140 if previous, ok := uc.lastExplicit[scope]; ok &&
141 input.Text == previous.text &&
142 input.Action == previous.action &&
143 currentTime.Sub(previous.at) < explicitClipboardDedupWindow {
144 uc.mu.Unlock()
145 return nil
146 }
147 if !input.NativeHandled && uc.clipboard == nil {
148 uc.mu.Unlock()
149 return fmt.Errorf("clipboard not available")
150 }
151 uc.lastExplicit[scope] = state
152 uc.mu.Unlock()
153
154 if !input.NativeHandled {
155 if err := uc.clipboard.WriteText(ctx, input.Text); err != nil {
156 uc.mu.Lock()
157 if current, ok := uc.lastExplicit[scope]; ok && current == state {
158 delete(uc.lastExplicit, scope)
159 }
160 uc.mu.Unlock()
161 return fmt.Errorf("clipboard write failed: %w", err)
162 }
163 }
164
165 if uc.toast != nil {
166 uc.toast(textLen)
167 }
168 logging.FromContext(ctx).Debug().
169 Int("text_len", textLen).
170 Str("source_engine", string(input.SourceEngine)).
171 Str("action", input.Action).
172 Bool("native_handled", input.NativeHandled).
173 Msg("clipboard explicit copied")
174 return nil
175}
176
177func selectionScope(input dto.SelectionClipboardInput) clipboardScope {
178 return clipboardScope{source: input.SourceEngine, viewID: input.ViewID}
179}
180
181func explicitScope(input dto.ExplicitClipboardInput) clipboardScope {
182 return clipboardScope{source: input.SourceEngine, viewID: input.ViewID}
183}
184
185func (uc *ClipboardTextOrchestratorUseCase) currentTime() time.Time {
186 if uc == nil || uc.now == nil {
187 return time.Now()
188 }
189 return uc.now()
190}