mode_frame.go RawHistoryFolder

1package ui
2
3import (
4	"strings"
5
6	"github.com/bnema/dumber/internal/domain/entity"
7	"github.com/bnema/dumber/internal/ui/input"
8	"github.com/bnema/dumber/internal/ui/layout"
9	"github.com/bnema/puregotk/v4/gdk"
10	"github.com/bnema/puregotk/v4/glib"
11	"github.com/bnema/puregotk/v4/gobject"
12	"github.com/bnema/puregotk/v4/graphene"
13	"github.com/bnema/puregotk/v4/gtk"
14)
15
16const (
17	legendColumnSpacing  = 8
18	legendMaxWidth       = 700
19	legendCompactWidth   = 460
20	legendTwoColumnWidth = 650
21	legendInset          = 16
22	legendHeaderSpace    = 80
23	legendRefreshMs      = 100
24)
25
26// modeFrame owns the per-window mode border and its non-interactive legend.
27// GTK access is restricted to the main thread, as with the rest of the UI.
28type modeFrame struct {
29	mode               input.Mode
30	tabID              entity.TabID
31	target             layout.Widget
32	frameClass         string
33	root               *gtk.Box
34	border             *gtk.Box
35	borderStyle        cssClassTarget // border's CSS classes; a seam for tests
36	borderRect         func() (x, y, width, height int, ok bool)
37	panel              *gtk.Box
38	content            *gtk.FlowBox
39	scroller           *gtk.ScrolledWindow
40	heading            *gtk.Label
41	rows               map[string][]*gtk.Widget
42	keycaps            map[string][]*gtk.Widget
43	pending            string
44	visible            bool
45	style              string
46	modeClass          string
47	config             entity.WorkspaceStylingConfig
48	showTimer          uint
49	flashTimer         uint
50	generation         uint64
51	placement          func() (x, y, width, height int, ok bool)
52	refreshSource      uint
53	lastColumns        uint
54	lastPanelWidth     int
55	lastMaxHeight      int
56	overlay            *gtk.Overlay
57	window             *gtk.ApplicationWindow
58	pressInFlight      bool
59	pressIdle          uint
60	positionHandlerID  uint
61	pulseCycle         bool
62	lingering          bool
63	pointerX, pointerY float64
64	pointerInside      bool
65	motionController   *gtk.EventControllerMotion
66	keyController      *gtk.EventControllerKey
67	clickController    *gtk.GestureClick
68	motionCb           func(gtk.EventControllerMotion, float64, float64)
69	enterCb            func(gtk.EventControllerMotion, float64, float64)
70	leaveCb            func(gtk.EventControllerMotion)
71	keyCb              func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
72	clickCb            func(gtk.GestureClick, int, float64, float64)
73}
74
75func newModeFrame(overlay *gtk.Overlay, window *gtk.ApplicationWindow) *modeFrame {
76	if overlay == nil || window == nil {
77		return nil
78	}
79	f := &modeFrame{mode: input.ModeNormal, overlay: overlay, window: window}
80	f.root = gtk.NewBox(gtk.OrientationVerticalValue, 0)
81	f.root.SetHalign(gtk.AlignStartValue)
82	f.root.SetValign(gtk.AlignStartValue)
83	f.root.SetCanFocus(false)
84	f.root.SetCanTarget(false)
85	f.root.SetVisible(false)
86	f.root.AddCssClass("mode-legend-anchor")
87	f.panel = gtk.NewBox(gtk.OrientationVerticalValue, 0)
88	f.panel.SetCanFocus(false)
89	f.panel.SetCanTarget(false)
90	f.panel.AddCssClass("omnibox-container")
91	f.panel.AddCssClass("mode-legend-panel")
92	f.root.Append(&f.panel.Widget)
93	title := ""
94	f.heading = gtk.NewLabel(&title)
95	f.heading.SetCanTarget(false)
96	f.heading.SetCanFocus(false)
97	f.heading.SetHalign(gtk.AlignStartValue)
98	f.heading.AddCssClass("mode-legend-title")
99	f.panel.Append(&f.heading.Widget)
100	f.initLegendContent()
101	// The border is its own overlay: inset shadows on pane containers are
102	// painted below web content and would be invisible.
103	f.border = gtk.NewBox(gtk.OrientationVerticalValue, 0)
104	f.border.SetCanFocus(false)
105	f.border.SetCanTarget(false)
106	f.border.SetVisible(false)
107	f.border.AddCssClass("mode-frame-border")
108	f.borderStyle = f.border
109	overlay.AddOverlay(&f.border.Widget)
110	overlay.SetClipOverlay(&f.border.Widget, false)
111	overlay.SetMeasureOverlay(&f.border.Widget, false)
112	overlay.AddOverlay(&f.root.Widget)
113	overlay.SetClipOverlay(&f.root.Widget, false)
114	overlay.SetMeasureOverlay(&f.root.Widget, false)
115	// A single positioned overlay tracks the active pane without reparenting it.
116	cb := func(_ gtk.Overlay, widgetPtr uintptr, allocation *uintptr) bool {
117		placement := f.placement
118		if f.border != nil && widgetPtr == f.border.GoPointer() {
119			placement = f.borderRect
120		} else if widgetPtr != f.root.GoPointer() {
121			return false
122		}
123		if placement == nil {
124			return false
125		}
126		x, y, w, h, ok := placement()
127		if !ok {
128			return false
129		}
130		return writeOverlayAllocation(allocation, x, y, w, h)
131	}
132	f.positionHandlerID = overlay.ConnectGetChildPosition(&cb)
133	f.installDismissControllers()
134	return f
135}
136
137func (f *modeFrame) installDismissControllers() {
138	overlay := f.overlay
139	window := f.window
140	f.motionController = gtk.NewEventControllerMotion()
141	f.motionCb = func(_ gtk.EventControllerMotion, x, y float64) {
142		f.pointerX, f.pointerY, f.pointerInside = x, y, true
143	}
144	f.leaveCb = func(_ gtk.EventControllerMotion) { f.pointerInside = false }
145	f.enterCb = func(_ gtk.EventControllerMotion, x, y float64) {
146		f.pointerX, f.pointerY, f.pointerInside = x, y, true
147	}
148	f.motionController.ConnectEnter(&f.enterCb)
149	f.motionController.ConnectMotion(&f.motionCb)
150	f.motionController.ConnectLeave(&f.leaveCb)
151	overlay.AddController(&f.motionController.EventController)
152	f.keyController = gtk.NewEventControllerKey()
153	f.keyController.SetPropagationPhase(gtk.PhaseCaptureValue)
154	f.keyCb = func(_ gtk.EventControllerKey, _, _ uint, _ gdk.ModifierType) bool {
155		f.dismissLinger()
156		return false
157	}
158	f.keyController.ConnectKeyPressed(&f.keyCb)
159	window.AddController(&f.keyController.EventController)
160	f.clickController = gtk.NewGestureClick()
161	f.clickController.SetButton(0)
162	f.clickController.SetPropagationPhase(gtk.PhaseCaptureValue)
163	f.clickCb = func(_ gtk.GestureClick, _ int, _, _ float64) {
164		f.dismissLinger()
165		f.pressInFlight = true
166		if f.pressIdle != 0 {
167			glib.SourceRemove(f.pressIdle)
168		}
169		cb := glib.SourceFunc(func(_ uintptr) bool {
170			f.pressInFlight = false
171			f.pressIdle = 0
172			return false
173		})
174		f.pressIdle = glib.IdleAdd(&cb, 0)
175	}
176	f.clickController.ConnectPressed(&f.clickCb)
177	window.AddController(&f.clickController.EventController)
178}
179
180func (f *modeFrame) initLegendContent() {
181	f.content = gtk.NewFlowBox()
182	f.content.SetCanFocus(false)
183	f.content.SetCanTarget(false)
184	f.content.SetOrientation(gtk.OrientationHorizontalValue)
185	f.content.SetSelectionMode(gtk.SelectionNoneValue)
186	f.content.SetMinChildrenPerLine(1)
187	f.content.SetMaxChildrenPerLine(3)
188	f.content.SetColumnSpacing(legendColumnSpacing)
189	f.content.SetRowSpacing(4)
190	f.content.AddCssClass("mode-legend-content")
191	f.scroller = gtk.NewScrolledWindow()
192	f.scroller.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
193	f.scroller.SetPropagateNaturalHeight(true)
194	f.scroller.SetHasFrame(false)
195	f.scroller.SetCanFocus(false)
196	f.scroller.SetCanTarget(false)
197	f.scroller.SetChild(&f.content.Widget)
198	f.panel.Append(&f.scroller.Widget)
199}
200
201func (f *modeFrame) destroy() {
202	if f == nil {
203		return
204	}
205	f.cancelTimers()
206	f.dismissLinger()
207	f.stopGeometryRefresh()
208	if f.pressIdle != 0 {
209		glib.SourceRemove(f.pressIdle)
210		f.pressIdle = 0
211	}
212	if f.overlay != nil {
213		f.overlay.RemoveController(&f.motionController.EventController)
214	}
215	if f.window != nil {
216		f.window.RemoveController(&f.keyController.EventController)
217		f.window.RemoveController(&f.clickController.EventController)
218	}
219	f.setTarget(nil, "")
220	if f.overlay != nil && f.positionHandlerID != 0 {
221		gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(f.overlay.GoPointer()), f.positionHandlerID)
222		f.positionHandlerID = 0
223	}
224	f.placement = nil
225	f.borderRect = nil
226	if f.overlay != nil && f.root != nil {
227		f.overlay.RemoveOverlay(&f.root.Widget)
228	}
229	if f.overlay != nil && f.border != nil {
230		f.overlay.RemoveOverlay(&f.border.Widget)
231	}
232	f.overlay = nil
233	f.window = nil
234	f.root = nil
235	f.border = nil
236	f.borderStyle = nil
237}
238
239type cssClassTarget interface {
240	AddCssClass(string)
241	RemoveCssClass(string)
242}
243
244func (f *modeFrame) setTarget(target layout.Widget, class string) {
245	if f == nil {
246		return
247	}
248	if target == f.target && class == f.frameClass {
249		return
250	}
251	if f.borderStyle != nil && f.frameClass != "" {
252		f.borderStyle.RemoveCssClass(f.frameClass)
253		for _, pulseClass := range vimPulseClasses {
254			f.borderStyle.RemoveCssClass(pulseClass)
255		}
256	}
257	f.target, f.frameClass = target, class
258	if f.borderStyle != nil && target != nil && class != "" {
259		f.borderStyle.AddCssClass(class)
260	}
261	if f.border == nil {
262		return
263	}
264	if target != nil && class != "" {
265		f.startGeometryRefresh()
266		return
267	}
268	f.stopGeometryRefresh()
269	f.borderRect = nil
270	f.border.SetVisible(false)
271}
272
273var vimPulseClasses = []string{"vim-mode-pulse", "vim-mode-pulse-fast", "vim-mode-pulse-cycle-a", "vim-mode-pulse-cycle-b"}
274
275func (f *modeFrame) pulse(fast bool) {
276	if f == nil || f.mode != input.ModeVim || f.target == nil || f.borderStyle == nil {
277		return
278	}
279	for _, class := range vimPulseClasses {
280		f.borderStyle.RemoveCssClass(class)
281	}
282	class := "vim-mode-pulse"
283	if fast {
284		class = "vim-mode-pulse-fast"
285	}
286	f.borderStyle.AddCssClass(class)
287	cycle := "vim-mode-pulse-cycle-a"
288	if f.pulseCycle {
289		cycle = "vim-mode-pulse-cycle-b"
290	}
291	f.pulseCycle = !f.pulseCycle
292	f.borderStyle.AddCssClass(cycle)
293}
294
295func modeFrameClass(mode input.Mode) string {
296	switch mode {
297	case input.ModePane:
298		return "pane-mode-active"
299	case input.ModeResize:
300		return "resize-mode-active"
301	case input.ModeVim:
302		return "vim-mode-active"
303	case input.ModeTab:
304		return "tab-mode-active"
305	case input.ModeSession:
306		return "session-mode-active"
307	case input.ModePrefix:
308		return "prefix-mode-active"
309	default:
310		return ""
311	}
312}
313
314func (f *modeFrame) setMode(
315	mode input.Mode, target layout.Widget, cfg entity.WorkspaceStylingConfig,
316	style string, actions map[string]entity.ActionBinding,
317) {
318	if f == nil {
319		return
320	}
321	f.cancelTimers()
322	// Normal-mode refreshes must preserve an already lingering legend.
323	keepLinger := mode == input.ModeNormal && f.mode == input.ModeNormal && f.lingering
324	linger := shouldStartLinger(mode == input.ModeNormal && f.visible, f.pressInFlight,
325		cfg.ModeLegendLinger, f.pointerInside, f.pointerX, f.pointerY, f.placement)
326	if !keepLinger {
327		f.dismissLinger()
328	}
329	f.generation++
330	f.mode, f.config = mode, cfg
331	f.pending = ""
332	f.pulseCycle = false
333	if linger {
334		f.lingering = true
335		f.visible = false
336		f.clearLegendFeedback()
337		f.panel.AddCssClass("mode-legend-lingering")
338	} else if !keepLinger {
339		f.hide()
340	}
341	f.setTarget(target, modeFrameClass(mode))
342	f.setLegendStyle(style, mode, linger || keepLinger)
343	if mode == input.ModeNormal || cfg.ModeLegend == "off" || target == nil {
344		if !linger && !keepLinger {
345			f.content.RemoveAll()
346		}
347		f.rows = nil
348		f.keycaps = nil
349		return
350	}
351	f.build(actions)
352	if cfg.ModeLegendAnimations {
353		f.panel.RemoveCssClass("mode-legend-no-motion")
354	} else {
355		f.panel.AddCssClass("mode-legend-no-motion")
356	}
357	if cfg.ModeLegend == "delay" && cfg.ModeLegendDelayMs > 0 {
358		generation := f.generation
359		cb := glib.SourceFunc(func(_ uintptr) bool {
360			f.showTimer = 0
361			if generation == f.generation {
362				f.show()
363			}
364			return false
365		})
366		f.showTimer = glib.TimeoutAdd(uint(cfg.ModeLegendDelayMs), &cb, 0)
367		return
368	}
369	f.show()
370}
371
372func (f *modeFrame) setLegendStyle(style string, mode input.Mode, preserveModeClass bool) {
373	if f.style != style {
374		if f.style != "" {
375			f.panel.RemoveCssClass("omnibox-style-" + f.style)
376		}
377		f.style = style
378		if style != "" {
379			f.panel.AddCssClass("omnibox-style-" + style)
380		}
381	}
382	if f.modeClass != "" && !preserveModeClass {
383		f.panel.RemoveCssClass(f.modeClass)
384	}
385	if !preserveModeClass {
386		f.modeClass = "mode-legend-" + mode.String()
387		f.panel.AddCssClass(f.modeClass)
388	}
389}
390
391func (f *modeFrame) clearLegendFeedback() {
392	for _, rows := range f.rows {
393		for _, row := range rows {
394			row.RemoveCssClass("mode-legend-dim")
395		}
396	}
397	for _, caps := range f.keycaps {
398		for _, cap := range caps {
399			cap.RemoveCssClass("mode-legend-flash")
400		}
401	}
402}
403
404func (f *modeFrame) cancelTimers() {
405	if f == nil {
406		return
407	}
408	if f.showTimer != 0 {
409		glib.SourceRemove(f.showTimer)
410		f.showTimer = 0
411	}
412	if f.flashTimer != 0 {
413		glib.SourceRemove(f.flashTimer)
414		f.flashTimer = 0
415	}
416}
417
418func pointInRect(x, y float64, rx, ry, width, height int) bool {
419	return x >= float64(rx) && y >= float64(ry) && x < float64(rx+width) && y < float64(ry+height)
420}
421
422func shouldLinger(enabled, inside bool, x, y float64, placement func() (int, int, int, int, bool)) bool {
423	if !enabled || !inside || placement == nil {
424		return false
425	}
426	rx, ry, w, h, ok := placement()
427	return ok && pointInRect(x, y, rx, ry, w, h)
428}
429
430func shouldStartLinger(exiting, pressInFlight, enabled, inside bool, x, y float64, placement func() (int, int, int, int, bool)) bool {
431	return exiting && !pressInFlight && shouldLinger(enabled, inside, x, y, placement)
432}
433
434func (f *modeFrame) dismissLinger() {
435	if f == nil || !f.lingering {
436		return
437	}
438	f.lingering = false
439	f.panel.RemoveCssClass("mode-legend-lingering")
440	f.hide()
441}
442
443func (f *modeFrame) hide() {
444	if f == nil || f.root == nil {
445		return
446	}
447	f.visible = false
448	f.placement = nil
449	f.resetLegendHeight()
450	f.root.SetVisible(false)
451}
452
453func (f *modeFrame) stopGeometryRefresh() {
454	if f == nil || f.refreshSource == 0 {
455		return
456	}
457	glib.SourceRemove(f.refreshSource)
458	f.refreshSource = 0
459}
460
461func (f *modeFrame) show() {
462	if f == nil || f.mode == input.ModeNormal || f.target == nil {
463		return
464	}
465	f.visible = true
466	f.startGeometryRefresh()
467}
468
469// Measure outside GTK allocation while the legend is active, including window
470// resizes and splitter drags after the initial layout has settled.
471func (f *modeFrame) startGeometryRefresh() {
472	f.stopGeometryRefresh()
473	f.refreshGeometry()
474	cb := glib.SourceFunc(func(_ uintptr) bool {
475		if f.target == nil || f.root == nil || f.lingering {
476			f.refreshSource = 0
477			return false
478		}
479		f.refreshGeometry()
480		return true
481	})
482	f.refreshSource = glib.TimeoutAdd(legendRefreshMs, &cb, 0)
483}
484
485func (f *modeFrame) resetLegendHeight() {
486	if f.lastMaxHeight != 0 {
487		f.scroller.SetSizeRequest(-1, -1)
488		f.scroller.SetPropagateNaturalHeight(true)
489		f.lastMaxHeight = 0
490	}
491}
492
493// clearPlacement hides both the border and the legend: the target is gone.
494func (f *modeFrame) clearPlacement() {
495	if f.borderRect != nil {
496		f.borderRect = nil
497		f.border.SetVisible(false)
498	}
499	f.clearLegend()
500}
501
502// clearLegend hides only the legend; the border keeps tracking its target.
503func (f *modeFrame) clearLegend() {
504	if f.placement == nil {
505		return
506	}
507	f.placement = nil
508	f.root.SetVisible(false)
509	f.root.QueueAllocate()
510}
511
512func (f *modeFrame) measureLegendHeight(panelWidth int) int {
513	maxHeight := f.overlay.GetAllocatedHeight() - legendInset
514	if f.lastMaxHeight != 0 && maxHeight > f.lastMaxHeight {
515		f.resetLegendHeight()
516	}
517	// Measure the panel: GTK reports 0 for the root while it is still hidden.
518	_, naturalHeight := 0, 0
519	f.panel.Measure(gtk.OrientationVerticalValue, panelWidth, nil, &naturalHeight, nil, nil)
520	if naturalHeight > maxHeight && maxHeight > 0 {
521		if f.lastMaxHeight != maxHeight {
522			f.scroller.SetPropagateNaturalHeight(false)
523			f.scroller.SetSizeRequest(-1, max(maxHeight-legendHeaderSpace, 1))
524			f.lastMaxHeight = maxHeight
525		}
526		return maxHeight
527	}
528	return naturalHeight
529}
530
531// refreshGeometry measures the legend outside GTK's positioning callback.
532// A narrow target falls back to the window width, keeping the legend reachable.
533func (f *modeFrame) refreshGeometry() {
534	if f.lingering {
535		return
536	}
537	previous := f.placement
538	if f.target == nil || f.overlay == nil {
539		f.clearPlacement()
540		return
541	}
542	overlayWidget := &f.overlay.Widget
543	point := &graphene.Point{X: 0, Y: 0}
544	out := &graphene.Point{}
545	target := f.target.GtkWidget()
546	if target == nil || !target.ComputePoint(overlayWidget, point, out) {
547		f.clearPlacement()
548		return
549	}
550	width, height := target.GetAllocatedWidth(), target.GetAllocatedHeight()
551	if width <= 0 || height <= 0 {
552		f.clearPlacement()
553		return
554	}
555	x, bottom := int(out.X), int(out.Y)+height
556	f.placeBorder(x, int(out.Y), width, height)
557	if !f.visible {
558		f.clearLegend()
559		return
560	}
561	// Keep every keycap visible even in a narrow split. No webview resize.
562	panelWidth := min(width-legendInset, legendMaxWidth)
563	columns := uint(3)
564	if panelWidth < legendCompactWidth {
565		panelWidth = min(f.overlay.GetAllocatedWidth()-legendInset, legendCompactWidth)
566		x = (f.overlay.GetAllocatedWidth() - panelWidth) / 2
567		columns = 1
568	} else {
569		if panelWidth < legendTwoColumnWidth {
570			columns = 2
571		}
572		x += (width - panelWidth) / 2
573	}
574	if panelWidth <= 0 {
575		f.clearLegend()
576		return
577	}
578	f.sizeLegendColumns(columns, panelWidth)
579	naturalHeight := f.measureLegendHeight(panelWidth)
580	if naturalHeight <= 0 {
581		f.clearLegend()
582		return
583	}
584	y := bottom - naturalHeight - 4
585	if y < 0 {
586		y = 0
587	}
588	f.placement = func() (int, int, int, int, bool) { return x, y, panelWidth, naturalHeight, true }
589	f.root.SetVisible(true)
590	if previous != nil {
591		oldX, oldY, oldWidth, oldHeight, _ := previous()
592		if oldX == x && oldY == y && oldWidth == panelWidth && oldHeight == naturalHeight {
593			return
594		}
595	}
596	f.root.QueueAllocate()
597}
598
599func (f *modeFrame) placeBorder(x, y, width, height int) {
600	if f.borderRect != nil {
601		if ox, oy, ow, oh, _ := f.borderRect(); ox == x && oy == y && ow == width && oh == height {
602			return
603		}
604	}
605	f.borderRect = func() (int, int, int, int, bool) { return x, y, width, height, true }
606	f.border.SetVisible(true)
607	f.border.QueueAllocate()
608}
609
610func (f *modeFrame) sizeLegendColumns(columns uint, width int) {
611	if columns != f.lastColumns {
612		f.content.SetMaxChildrenPerLine(columns)
613		f.lastColumns = columns
614	}
615	if width != f.lastPanelWidth {
616		f.panel.SetSizeRequest(width, -1)
617		f.lastPanelWidth = width
618	}
619}
620
621func modeLegendGroup(mode input.Mode, name string) string {
622	switch {
623	case name == "confirm" || name == "cancel":
624		return "EXIT"
625	case strings.HasPrefix(name, "split-"):
626		return "SPLIT"
627	case strings.HasPrefix(name, "enter-"):
628		return "MODES"
629	case strings.HasPrefix(name, "focus-"):
630		return "FOCUS"
631	case strings.HasPrefix(name, "resize-"):
632		return "RESIZE"
633	case strings.HasPrefix(name, "vim-scroll-") || strings.HasPrefix(name, "half-page-"):
634		return "SCROLL"
635	case mode == input.ModeVim && (strings.Contains(name, "next") || strings.Contains(name, "prev") || name == "outline"):
636		return "JUMP"
637	case (mode == input.ModeTab || mode == input.ModePrefix) && (name == "next-tab" || name == "previous-tab"):
638		return "SWITCH"
639	default:
640		return "MANAGE"
641	}
642}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close