fix(picker): harden switch and preview cleanup
- commit
- 6f41b4bc6983b973e0e847c71226ed90f3c743fc
- author
- brice · · 2026-07-03 04:24 UTC
- parent
- d8255fc
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +87 -51
M internal/usecase/daemon/daemon.go +75 -21
| @@ -754,16 +754,16 @@ func (s *session) activeTab() *tab { | |||
| 754 | 754 | return s.tabs[s.active] | |
| 755 | 755 | } | |
| 756 | 756 | ||
| 757 | - | func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) { | |
| 757 | + | func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { | |
| 758 | 758 | ac.mouseScan.Scan(data, | |
| 759 | - | func(ev mouse.Event) { d.handleMouse(sess, ac, ev) }, | |
| 759 | + | func(ev mouse.Event) { d.handleMouse(ac, ev) }, | |
| 760 | 760 | func(b []byte) { | |
| 761 | 761 | if ac.pickerActive() { | |
| 762 | - | d.handlePickerInput(sess, ac, b) | |
| 762 | + | d.handlePickerInput(ac, b) | |
| 763 | 763 | return | |
| 764 | 764 | } | |
| 765 | 765 | if ac.copyModeActive() { | |
| 766 | - | d.handleCopyInput(sess, ac, b) | |
| 766 | + | d.handleCopyInput(ac, b) | |
| 767 | 767 | return | |
| 768 | 768 | } | |
| 769 | 769 | ac.keys.Route(b) | |
| @@ -771,7 +771,14 @@ func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) { | |||
| 771 | 771 | ) | |
| 772 | 772 | } | |
| 773 | 773 | ||
| 774 | - | func (d *Daemon) handleMouse(sess *session, ac *attachedClient, ev mouse.Event) { | |
| 774 | + | func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |
| 775 | + | if ac.pickerActive() { | |
| 776 | + | return | |
| 777 | + | } | |
| 778 | + | sess := ac.currentSession() | |
| 779 | + | if sess == nil { | |
| 780 | + | return | |
| 781 | + | } | |
| 775 | 782 | tb := sess.activeTab() | |
| 776 | 783 | if tb == nil { | |
| 777 | 784 | return | |
| @@ -862,7 +869,11 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||
| 862 | 869 | d.paint(sess, ac, true) | |
| 863 | 870 | } | |
| 864 | 871 | ||
| 865 | - | func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte) { | |
| 872 | + | func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |
| 873 | + | sess := ac.currentSession() | |
| 874 | + | if sess == nil { | |
| 875 | + | return | |
| 876 | + | } | |
| 866 | 877 | tb := sess.activeTab() | |
| 867 | 878 | if tb == nil { | |
| 868 | 879 | return | |
| @@ -1017,7 +1028,11 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||
| 1017 | 1028 | return views, curTab | |
| 1018 | 1029 | } | |
| 1019 | 1030 | ||
| 1020 | - | func (d *Daemon) handlePickerInput(sess *session, ac *attachedClient, data []byte) { | |
| 1031 | + | func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) { | |
| 1032 | + | sess := ac.currentSession() | |
| 1033 | + | if sess == nil { | |
| 1034 | + | return | |
| 1035 | + | } | |
| 1021 | 1036 | ac.pickerMu.Lock() | |
| 1022 | 1037 | if ac.picker == nil { | |
| 1023 | 1038 | ac.pickerPending = nil | |
| @@ -1164,6 +1179,25 @@ func (d *Daemon) unregisterPreview(ac *attachedClient) { | |||
| 1164 | 1179 | } | |
| 1165 | 1180 | } | |
| 1166 | 1181 | ||
| 1182 | + | func (d *Daemon) clearDestroyedTabPreview(tb *tab) { | |
| 1183 | + | tb.mu.Lock() | |
| 1184 | + | previewer := tb.previewClient | |
| 1185 | + | tb.mu.Unlock() | |
| 1186 | + | if previewer == nil { | |
| 1187 | + | return | |
| 1188 | + | } | |
| 1189 | + | previewer.pickerMu.Lock() | |
| 1190 | + | if previewer.pickerPreview == tb { | |
| 1191 | + | previewer.pickerPreview = nil | |
| 1192 | + | } | |
| 1193 | + | previewer.pickerMu.Unlock() | |
| 1194 | + | tb.mu.Lock() | |
| 1195 | + | if tb.previewClient == previewer { | |
| 1196 | + | tb.previewClient = nil | |
| 1197 | + | } | |
| 1198 | + | tb.mu.Unlock() | |
| 1199 | + | } | |
| 1200 | + | ||
| 1167 | 1201 | func (d *Daemon) closePicker(ac *attachedClient) { | |
| 1168 | 1202 | ac.pickerMu.Lock() | |
| 1169 | 1203 | ac.picker = nil | |
| @@ -1204,27 +1238,45 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker | |||
| 1204 | 1238 | d.paint(sess, ac, true) | |
| 1205 | 1239 | return | |
| 1206 | 1240 | } | |
| 1207 | - | if !sess.detachIfCurrent(ac) { | |
| 1241 | + | old := d.stealClientForTarget(sess, ac, targetSess, target) | |
| 1242 | + | if ac.currentSession() != targetSess { | |
| 1243 | + | d.paint(sess, ac, true) | |
| 1208 | 1244 | return | |
| 1209 | 1245 | } | |
| 1210 | - | targetSess.stealClient(d, ac, target.TabIndex) | |
| 1211 | - | ac.setSession(targetSess) | |
| 1212 | - | d.firstPaint(targetSess, ac, ac.size) | |
| 1213 | - | } | |
| 1214 | - | ||
| 1215 | - | func (s *session) stealClient(d *Daemon, ac *attachedClient, idx int) { | |
| 1216 | - | s.mu.Lock() | |
| 1217 | - | old := s.client | |
| 1218 | - | s.client = ac | |
| 1219 | - | if idx >= 0 && idx < len(s.tabs) { | |
| 1220 | - | s.active = idx | |
| 1221 | - | } | |
| 1222 | - | s.mu.Unlock() | |
| 1223 | 1246 | if old != nil && old != ac { | |
| 1224 | 1247 | d.unregisterPreview(old) | |
| 1225 | 1248 | old.setSession(nil) | |
| 1226 | 1249 | d.notifyDetachedAsync(old, ports.ReasonDetach) | |
| 1227 | 1250 | } | |
| 1251 | + | d.firstPaint(targetSess, ac, ac.size) | |
| 1252 | + | } | |
| 1253 | + | ||
| 1254 | + | func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient { | |
| 1255 | + | d.mu.Lock() | |
| 1256 | + | if d.sessions[target.Session] != targetSess { | |
| 1257 | + | d.mu.Unlock() | |
| 1258 | + | return nil | |
| 1259 | + | } | |
| 1260 | + | from.mu.Lock() | |
| 1261 | + | if from.client != ac { | |
| 1262 | + | from.mu.Unlock() | |
| 1263 | + | d.mu.Unlock() | |
| 1264 | + | return nil | |
| 1265 | + | } | |
| 1266 | + | from.client = nil | |
| 1267 | + | ac.setSession(nil) | |
| 1268 | + | from.mu.Unlock() | |
| 1269 | + | ||
| 1270 | + | targetSess.mu.Lock() | |
| 1271 | + | old := targetSess.client | |
| 1272 | + | targetSess.client = ac | |
| 1273 | + | if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) { | |
| 1274 | + | targetSess.active = target.TabIndex | |
| 1275 | + | } | |
| 1276 | + | targetSess.mu.Unlock() | |
| 1277 | + | ac.setSession(targetSess) | |
| 1278 | + | d.mu.Unlock() | |
| 1279 | + | return old | |
| 1228 | 1280 | } | |
| 1229 | 1281 | ||
| 1230 | 1282 | type daemonKeyHandler struct { | |
| @@ -1345,6 +1397,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||
| 1345 | 1397 | ac := sess.client | |
| 1346 | 1398 | sess.mu.Unlock() | |
| 1347 | 1399 | ||
| 1400 | + | d.clearDestroyedTabPreview(tb) | |
| 1348 | 1401 | if tb.cancel != nil { | |
| 1349 | 1402 | tb.cancel() | |
| 1350 | 1403 | } | |
| @@ -1833,6 +1886,7 @@ func (d *Daemon) killSession(sess *session, reason uint8) { | |||
| 1833 | 1886 | tabs := append([]*tab(nil), sess.tabs...) | |
| 1834 | 1887 | sess.mu.Unlock() | |
| 1835 | 1888 | for _, tb := range tabs { | |
| 1889 | + | d.clearDestroyedTabPreview(tb) | |
| 1836 | 1890 | _ = tb.pty.Close() | |
| 1837 | 1891 | } | |
| 1838 | 1892 | if empty { | |
| @@ -754,16 +754,16 @@ func (s *session) activeTab() *tab { | |||||
| 754 | return s.tabs[s.active] | 754 | return s.tabs[s.active] | ||
| 755 | } | 755 | } | ||
| 756 | 756 | ||||
| 757 | - | func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) { | 757 | + | func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { |
| 758 | ac.mouseScan.Scan(data, | 758 | ac.mouseScan.Scan(data, | ||
| 759 | - | func(ev mouse.Event) { d.handleMouse(sess, ac, ev) }, | 759 | + | func(ev mouse.Event) { d.handleMouse(ac, ev) }, |
| 760 | func(b []byte) { | 760 | func(b []byte) { | ||
| 761 | if ac.pickerActive() { | 761 | if ac.pickerActive() { | ||
| 762 | - | d.handlePickerInput(sess, ac, b) | 762 | + | d.handlePickerInput(ac, b) |
| 763 | return | 763 | return | ||
| 764 | } | 764 | } | ||
| 765 | if ac.copyModeActive() { | 765 | if ac.copyModeActive() { | ||
| 766 | - | d.handleCopyInput(sess, ac, b) | 766 | + | d.handleCopyInput(ac, b) |
| 767 | return | 767 | return | ||
| 768 | } | 768 | } | ||
| 769 | ac.keys.Route(b) | 769 | ac.keys.Route(b) | ||
| @@ -771,7 +771,14 @@ func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) { | |||||
| 771 | ) | 771 | ) | ||
| 772 | } | 772 | } | ||
| 773 | 773 | ||||
| 774 | - | func (d *Daemon) handleMouse(sess *session, ac *attachedClient, ev mouse.Event) { | 774 | + | func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { |
| 775 | + | if ac.pickerActive() { | |||
| 776 | + | return | |||
| 777 | + | } | |||
| 778 | + | sess := ac.currentSession() | |||
| 779 | + | if sess == nil { | |||
| 780 | + | return | |||
| 781 | + | } | |||
| 775 | tb := sess.activeTab() | 782 | tb := sess.activeTab() | ||
| 776 | if tb == nil { | 783 | if tb == nil { | ||
| 777 | return | 784 | return | ||
| @@ -862,7 +869,11 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||||
| 862 | d.paint(sess, ac, true) | 869 | d.paint(sess, ac, true) | ||
| 863 | } | 870 | } | ||
| 864 | 871 | ||||
| 865 | - | func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte) { | 872 | + | func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { |
| 873 | + | sess := ac.currentSession() | |||
| 874 | + | if sess == nil { | |||
| 875 | + | return | |||
| 876 | + | } | |||
| 866 | tb := sess.activeTab() | 877 | tb := sess.activeTab() | ||
| 867 | if tb == nil { | 878 | if tb == nil { | ||
| 868 | return | 879 | return | ||
| @@ -1017,7 +1028,11 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||||
| 1017 | return views, curTab | 1028 | return views, curTab | ||
| 1018 | } | 1029 | } | ||
| 1019 | 1030 | ||||
| 1020 | - | func (d *Daemon) handlePickerInput(sess *session, ac *attachedClient, data []byte) { | 1031 | + | func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) { |
| 1032 | + | sess := ac.currentSession() | |||
| 1033 | + | if sess == nil { | |||
| 1034 | + | return | |||
| 1035 | + | } | |||
| 1021 | ac.pickerMu.Lock() | 1036 | ac.pickerMu.Lock() | ||
| 1022 | if ac.picker == nil { | 1037 | if ac.picker == nil { | ||
| 1023 | ac.pickerPending = nil | 1038 | ac.pickerPending = nil | ||
| @@ -1164,6 +1179,25 @@ func (d *Daemon) unregisterPreview(ac *attachedClient) { | |||||
| 1164 | } | 1179 | } | ||
| 1165 | } | 1180 | } | ||
| 1166 | 1181 | ||||
| 1182 | + | func (d *Daemon) clearDestroyedTabPreview(tb *tab) { | |||
| 1183 | + | tb.mu.Lock() | |||
| 1184 | + | previewer := tb.previewClient | |||
| 1185 | + | tb.mu.Unlock() | |||
| 1186 | + | if previewer == nil { | |||
| 1187 | + | return | |||
| 1188 | + | } | |||
| 1189 | + | previewer.pickerMu.Lock() | |||
| 1190 | + | if previewer.pickerPreview == tb { | |||
| 1191 | + | previewer.pickerPreview = nil | |||
| 1192 | + | } | |||
| 1193 | + | previewer.pickerMu.Unlock() | |||
| 1194 | + | tb.mu.Lock() | |||
| 1195 | + | if tb.previewClient == previewer { | |||
| 1196 | + | tb.previewClient = nil | |||
| 1197 | + | } | |||
| 1198 | + | tb.mu.Unlock() | |||
| 1199 | + | } | |||
| 1200 | + | ||||
| 1167 | func (d *Daemon) closePicker(ac *attachedClient) { | 1201 | func (d *Daemon) closePicker(ac *attachedClient) { | ||
| 1168 | ac.pickerMu.Lock() | 1202 | ac.pickerMu.Lock() | ||
| 1169 | ac.picker = nil | 1203 | ac.picker = nil | ||
| @@ -1204,27 +1238,45 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker | |||||
| 1204 | d.paint(sess, ac, true) | 1238 | d.paint(sess, ac, true) | ||
| 1205 | return | 1239 | return | ||
| 1206 | } | 1240 | } | ||
| 1207 | - | if !sess.detachIfCurrent(ac) { | 1241 | + | old := d.stealClientForTarget(sess, ac, targetSess, target) |
| 1242 | + | if ac.currentSession() != targetSess { | |||
| 1243 | + | d.paint(sess, ac, true) | |||
| 1208 | return | 1244 | return | ||
| 1209 | } | 1245 | } | ||
| 1210 | - | targetSess.stealClient(d, ac, target.TabIndex) | |||
| 1211 | - | ac.setSession(targetSess) | |||
| 1212 | - | d.firstPaint(targetSess, ac, ac.size) | |||
| 1213 | - | } | |||
| 1214 | - | ||||
| 1215 | - | func (s *session) stealClient(d *Daemon, ac *attachedClient, idx int) { | |||
| 1216 | - | s.mu.Lock() | |||
| 1217 | - | old := s.client | |||
| 1218 | - | s.client = ac | |||
| 1219 | - | if idx >= 0 && idx < len(s.tabs) { | |||
| 1220 | - | s.active = idx | |||
| 1221 | - | } | |||
| 1222 | - | s.mu.Unlock() | |||
| 1223 | if old != nil && old != ac { | 1246 | if old != nil && old != ac { | ||
| 1224 | d.unregisterPreview(old) | 1247 | d.unregisterPreview(old) | ||
| 1225 | old.setSession(nil) | 1248 | old.setSession(nil) | ||
| 1226 | d.notifyDetachedAsync(old, ports.ReasonDetach) | 1249 | d.notifyDetachedAsync(old, ports.ReasonDetach) | ||
| 1227 | } | 1250 | } | ||
| 1251 | + | d.firstPaint(targetSess, ac, ac.size) | |||
| 1252 | + | } | |||
| 1253 | + | ||||
| 1254 | + | func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient { | |||
| 1255 | + | d.mu.Lock() | |||
| 1256 | + | if d.sessions[target.Session] != targetSess { | |||
| 1257 | + | d.mu.Unlock() | |||
| 1258 | + | return nil | |||
| 1259 | + | } | |||
| 1260 | + | from.mu.Lock() | |||
| 1261 | + | if from.client != ac { | |||
| 1262 | + | from.mu.Unlock() | |||
| 1263 | + | d.mu.Unlock() | |||
| 1264 | + | return nil | |||
| 1265 | + | } | |||
| 1266 | + | from.client = nil | |||
| 1267 | + | ac.setSession(nil) | |||
| 1268 | + | from.mu.Unlock() | |||
| 1269 | + | ||||
| 1270 | + | targetSess.mu.Lock() | |||
| 1271 | + | old := targetSess.client | |||
| 1272 | + | targetSess.client = ac | |||
| 1273 | + | if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) { | |||
| 1274 | + | targetSess.active = target.TabIndex | |||
| 1275 | + | } | |||
| 1276 | + | targetSess.mu.Unlock() | |||
| 1277 | + | ac.setSession(targetSess) | |||
| 1278 | + | d.mu.Unlock() | |||
| 1279 | + | return old | |||
| 1228 | } | 1280 | } | ||
| 1229 | 1281 | ||||
| 1230 | type daemonKeyHandler struct { | 1282 | type daemonKeyHandler struct { | ||
| @@ -1345,6 +1397,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||||
| 1345 | ac := sess.client | 1397 | ac := sess.client | ||
| 1346 | sess.mu.Unlock() | 1398 | sess.mu.Unlock() | ||
| 1347 | 1399 | ||||
| 1400 | + | d.clearDestroyedTabPreview(tb) | |||
| 1348 | if tb.cancel != nil { | 1401 | if tb.cancel != nil { | ||
| 1349 | tb.cancel() | 1402 | tb.cancel() | ||
| 1350 | } | 1403 | } | ||
| @@ -1833,6 +1886,7 @@ func (d *Daemon) killSession(sess *session, reason uint8) { | |||||
| 1833 | tabs := append([]*tab(nil), sess.tabs...) | 1886 | tabs := append([]*tab(nil), sess.tabs...) | ||
| 1834 | sess.mu.Unlock() | 1887 | sess.mu.Unlock() | ||
| 1835 | for _, tb := range tabs { | 1888 | for _, tb := range tabs { | ||
| 1889 | + | d.clearDestroyedTabPreview(tb) | |||
| 1836 | _ = tb.pty.Close() | 1890 | _ = tb.pty.Close() | ||
| 1837 | } | 1891 | } | ||
| 1838 | if empty { | 1892 | if empty { | ||
M internal/usecase/daemon/daemon_test.go +2 -2
| @@ -1136,8 +1136,8 @@ func TestPickerOpenCloseNavigationConcurrentWithRenderRace(t *testing.T) { | |||
| 1136 | 1136 | d.enterPicker(sess, ac) | |
| 1137 | 1137 | }) | |
| 1138 | 1138 | wg.Go(func() { | |
| 1139 | - | d.handlePickerInput(sess, ac, []byte("j")) | |
| 1140 | - | d.handlePickerInput(sess, ac, []byte("k")) | |
| 1139 | + | d.handlePickerInput(ac, []byte("j")) | |
| 1140 | + | d.handlePickerInput(ac, []byte("k")) | |
| 1141 | 1141 | }) | |
| 1142 | 1142 | wg.Go(func() { | |
| 1143 | 1143 | d.closePicker(ac) | |
| @@ -1136,8 +1136,8 @@ func TestPickerOpenCloseNavigationConcurrentWithRenderRace(t *testing.T) { | |||||
| 1136 | d.enterPicker(sess, ac) | 1136 | d.enterPicker(sess, ac) | ||
| 1137 | }) | 1137 | }) | ||
| 1138 | wg.Go(func() { | 1138 | wg.Go(func() { | ||
| 1139 | - | d.handlePickerInput(sess, ac, []byte("j")) | 1139 | + | d.handlePickerInput(ac, []byte("j")) |
| 1140 | - | d.handlePickerInput(sess, ac, []byte("k")) | 1140 | + | d.handlePickerInput(ac, []byte("k")) |
| 1141 | }) | 1141 | }) | ||
| 1142 | wg.Go(func() { | 1142 | wg.Go(func() { | ||
| 1143 | d.closePicker(ac) | 1143 | d.closePicker(ac) | ||
M internal/usecase/picker/model.go +3 -23
| @@ -2,6 +2,7 @@ package picker | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 5 | 6 | "github.com/bnema/vev/pkg/renderer" | |
| 6 | 7 | ) | |
| 7 | 8 | ||
| @@ -177,7 +178,8 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect) { | |||
| 177 | 178 | if !r.header { | |
| 178 | 179 | label = " " + label | |
| 179 | 180 | } | |
| 180 | - | drawText(frame, rect.X, rect.Y+y, rect.Width, label, style) | |
| 181 | + | ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style}) | |
| 182 | + | ui.DrawText(frame, rect.X, rect.Y+y, rect.X+rect.Width, label, style) | |
| 181 | 183 | } | |
| 182 | 184 | } | |
| 183 | 185 | ||
| @@ -192,28 +194,6 @@ func (m *Model) scrollOffset(visible int) int { | |||
| 192 | 194 | return min(offset, len(m.rows)-visible) | |
| 193 | 195 | } | |
| 194 | 196 | ||
| 195 | - | func drawText(frame renderer.Frame, x, y, width int, text string, style renderer.Style) { | |
| 196 | - | if y < 0 || y >= frame.Height || x >= frame.Width || width <= 0 { | |
| 197 | - | return | |
| 198 | - | } | |
| 199 | - | limit := min(width, frame.Width-x) | |
| 200 | - | if limit <= 0 { | |
| 201 | - | return | |
| 202 | - | } | |
| 203 | - | row := frame.Row(y) | |
| 204 | - | for i := range limit { | |
| 205 | - | row[x+i] = renderer.Cell{Rune: ' ', Style: style} | |
| 206 | - | } | |
| 207 | - | col := 0 | |
| 208 | - | for _, r := range text { | |
| 209 | - | if col >= limit { | |
| 210 | - | break | |
| 211 | - | } | |
| 212 | - | row[x+col] = renderer.Cell{Rune: r, Style: style} | |
| 213 | - | col++ | |
| 214 | - | } | |
| 215 | - | } | |
| 216 | - | ||
| 217 | 197 | func clamp(n, low, high int) int { | |
| 218 | 198 | if n < low { | |
| 219 | 199 | return low | |
| @@ -2,6 +2,7 @@ package picker | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "github.com/bnema/vev/internal/domain" | 4 | "github.com/bnema/vev/internal/domain" | ||
| 5 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 5 | "github.com/bnema/vev/pkg/renderer" | 6 | "github.com/bnema/vev/pkg/renderer" | ||
| 6 | ) | 7 | ) | ||
| 7 | 8 | ||||
| @@ -177,7 +178,8 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect) { | |||||
| 177 | if !r.header { | 178 | if !r.header { | ||
| 178 | label = " " + label | 179 | label = " " + label | ||
| 179 | } | 180 | } | ||
| 180 | - | drawText(frame, rect.X, rect.Y+y, rect.Width, label, style) | 181 | + | ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style}) |
| 182 | + | ui.DrawText(frame, rect.X, rect.Y+y, rect.X+rect.Width, label, style) | |||
| 181 | } | 183 | } | ||
| 182 | } | 184 | } | ||
| 183 | 185 | ||||
| @@ -192,28 +194,6 @@ func (m *Model) scrollOffset(visible int) int { | |||||
| 192 | return min(offset, len(m.rows)-visible) | 194 | return min(offset, len(m.rows)-visible) | ||
| 193 | } | 195 | } | ||
| 194 | 196 | ||||
| 195 | - | func drawText(frame renderer.Frame, x, y, width int, text string, style renderer.Style) { | |||
| 196 | - | if y < 0 || y >= frame.Height || x >= frame.Width || width <= 0 { | |||
| 197 | - | return | |||
| 198 | - | } | |||
| 199 | - | limit := min(width, frame.Width-x) | |||
| 200 | - | if limit <= 0 { | |||
| 201 | - | return | |||
| 202 | - | } | |||
| 203 | - | row := frame.Row(y) | |||
| 204 | - | for i := range limit { | |||
| 205 | - | row[x+i] = renderer.Cell{Rune: ' ', Style: style} | |||
| 206 | - | } | |||
| 207 | - | col := 0 | |||
| 208 | - | for _, r := range text { | |||
| 209 | - | if col >= limit { | |||
| 210 | - | break | |||
| 211 | - | } | |||
| 212 | - | row[x+col] = renderer.Cell{Rune: r, Style: style} | |||
| 213 | - | col++ | |||
| 214 | - | } | |||
| 215 | - | } | |||
| 216 | - | ||||
| 217 | func clamp(n, low, high int) int { | 197 | func clamp(n, low, high int) int { | ||
| 218 | if n < low { | 198 | if n < low { | ||
| 219 | return low | 199 | return low | ||
M internal/usecase/ui/modal.go +7 -5
| @@ -54,16 +54,18 @@ func (m Modal) Inner(base domain.Size) domain.Rect { | |||
| 54 | 54 | // the inner content rectangle. Cells outside the modal bounds are not changed. | |
| 55 | 55 | func (m Modal) Composite(f renderer.Frame, border renderer.Style) domain.Rect { | |
| 56 | 56 | bounds := m.Bounds(domain.Size{Cols: f.Width, Rows: f.Height}) | |
| 57 | - | inner := m.Inner(domain.Size{Cols: f.Width, Rows: f.Height}) | |
| 57 | + | inner := domain.Rect{ | |
| 58 | + | X: bounds.X + 1, | |
| 59 | + | Y: bounds.Y + 1, | |
| 60 | + | Width: max(0, bounds.Width-2), | |
| 61 | + | Height: max(0, bounds.Height-2), | |
| 62 | + | } | |
| 58 | 63 | DrawBox(f, bounds, border) | |
| 59 | 64 | FillRect(f, inner, renderer.BlankCell()) | |
| 60 | 65 | if bounds.Width > 2 && bounds.Height > 0 && m.Title != "" { | |
| 61 | 66 | left := bounds.X + 1 | |
| 62 | 67 | right := bounds.X + bounds.Width - 1 | |
| 63 | - | start := bounds.X + (bounds.Width-textWidth(m.Title))/2 | |
| 64 | - | if start < left { | |
| 65 | - | start = left | |
| 66 | - | } | |
| 68 | + | start := max(left, bounds.X+(bounds.Width-textWidth(m.Title))/2) | |
| 67 | 69 | DrawText(f, start, bounds.Y, right, m.Title, border) | |
| 68 | 70 | } | |
| 69 | 71 | return inner | |
| @@ -54,16 +54,18 @@ func (m Modal) Inner(base domain.Size) domain.Rect { | |||||
| 54 | // the inner content rectangle. Cells outside the modal bounds are not changed. | 54 | // the inner content rectangle. Cells outside the modal bounds are not changed. | ||
| 55 | func (m Modal) Composite(f renderer.Frame, border renderer.Style) domain.Rect { | 55 | func (m Modal) Composite(f renderer.Frame, border renderer.Style) domain.Rect { | ||
| 56 | bounds := m.Bounds(domain.Size{Cols: f.Width, Rows: f.Height}) | 56 | bounds := m.Bounds(domain.Size{Cols: f.Width, Rows: f.Height}) | ||
| 57 | - | inner := m.Inner(domain.Size{Cols: f.Width, Rows: f.Height}) | 57 | + | inner := domain.Rect{ |
| 58 | + | X: bounds.X + 1, | |||
| 59 | + | Y: bounds.Y + 1, | |||
| 60 | + | Width: max(0, bounds.Width-2), | |||
| 61 | + | Height: max(0, bounds.Height-2), | |||
| 62 | + | } | |||
| 58 | DrawBox(f, bounds, border) | 63 | DrawBox(f, bounds, border) | ||
| 59 | FillRect(f, inner, renderer.BlankCell()) | 64 | FillRect(f, inner, renderer.BlankCell()) | ||
| 60 | if bounds.Width > 2 && bounds.Height > 0 && m.Title != "" { | 65 | if bounds.Width > 2 && bounds.Height > 0 && m.Title != "" { | ||
| 61 | left := bounds.X + 1 | 66 | left := bounds.X + 1 | ||
| 62 | right := bounds.X + bounds.Width - 1 | 67 | right := bounds.X + bounds.Width - 1 | ||
| 63 | - | start := bounds.X + (bounds.Width-textWidth(m.Title))/2 | 68 | + | start := max(left, bounds.X+(bounds.Width-textWidth(m.Title))/2) |
| 64 | - | if start < left { | |||
| 65 | - | start = left | |||
| 66 | - | } | |||
| 67 | DrawText(f, start, bounds.Y, right, m.Title, border) | 69 | DrawText(f, start, bounds.Y, right, m.Title, border) | ||
| 68 | } | 70 | } | ||
| 69 | return inner | 71 | return inner | ||