From 051d71545770b18a2a8b13b38cdd9fe464209e16 Mon Sep 17 00:00:00 2001 From: Borna Lang Date: Sat, 3 May 2025 12:50:26 +0200 Subject: [PATCH] Overlays, example overlay plugins --- cmd/micro/initlua.go | 21 + cmd/micro/micro.go | 2 + internal/display/bufwindow.go | 4 + internal/lua/lua.go | 1 + internal/overlay/overlay.go | 130 ++++++ runtime/help/plugins.md | 26 ++ runtime/plugins/completebox/completebox.lua | 144 +++++++ .../plugins/completebox/help/completebox.md | 5 + runtime/plugins/quickmenu/help/quickmenu.md | 21 + runtime/plugins/quickmenu/quickmenu.lua | 369 ++++++++++++++++++ 10 files changed, 723 insertions(+) create mode 100644 internal/overlay/overlay.go create mode 100644 runtime/plugins/completebox/completebox.lua create mode 100644 runtime/plugins/completebox/help/completebox.md create mode 100644 runtime/plugins/quickmenu/help/quickmenu.md create mode 100644 runtime/plugins/quickmenu/quickmenu.lua diff --git a/cmd/micro/initlua.go b/cmd/micro/initlua.go index 7eac563763..ce3316cd66 100644 --- a/cmd/micro/initlua.go +++ b/cmd/micro/initlua.go @@ -4,6 +4,7 @@ import ( "log" "time" + "github.com/micro-editor/tcell/v2" lua "github.com/yuin/gopher-lua" luar "layeh.com/gopher-luar" @@ -12,6 +13,7 @@ import ( "github.com/zyedidia/micro/v2/internal/config" "github.com/zyedidia/micro/v2/internal/display" ulua "github.com/zyedidia/micro/v2/internal/lua" + "github.com/zyedidia/micro/v2/internal/overlay" "github.com/zyedidia/micro/v2/internal/screen" "github.com/zyedidia/micro/v2/internal/shell" "github.com/zyedidia/micro/v2/internal/util" @@ -35,6 +37,8 @@ func LuaImport(pkg string) *lua.LTable { return luaImportMicroConfig() case "micro/util": return luaImportMicroUtil() + case "micro/overlay": + return luaImportMicroOverlay() default: return ulua.Import(pkg) } @@ -163,3 +167,20 @@ func luaImportMicroUtil() *lua.LTable { return pkg } + +func luaImportMicroOverlay() *lua.LTable { + pkg := ulua.L.NewTable() + + ulua.L.SetField(pkg, "CreateOverlay", luar.New(ulua.L, overlay.CreateOverlay)) + ulua.L.SetField(pkg, "DestroyOverlay", luar.New(ulua.L, overlay.DestroyOverlay)) + ulua.L.SetField(pkg, "DrawText", luar.New(ulua.L, overlay.DrawText)) + ulua.L.SetField(pkg, "DrawRect", luar.New(ulua.L, overlay.DrawRect)) + ulua.L.SetField(pkg, "BufPaneScreenRect", luar.New(ulua.L, overlay.BufPaneScreenRect)) + ulua.L.SetField(pkg, "BufPaneScreenLoc", luar.New(ulua.L, overlay.BufPaneScreenLoc)) + ulua.L.SetField(pkg, "Style", luar.New(ulua.L, func() tcell.Style { return tcell.Style{} })) + ulua.L.SetField(pkg, "GetColor", luar.New(ulua.L, config.GetColor)) + ulua.L.SetField(pkg, "StringToStyle", luar.New(ulua.L, config.StringToStyle)) + ulua.L.SetField(pkg, "Redraw", luar.New(ulua.L, screen.Redraw)) + + return pkg +} diff --git a/cmd/micro/micro.go b/cmd/micro/micro.go index e43073b49f..7b010f33f8 100644 --- a/cmd/micro/micro.go +++ b/cmd/micro/micro.go @@ -24,6 +24,7 @@ import ( "github.com/zyedidia/micro/v2/internal/buffer" "github.com/zyedidia/micro/v2/internal/clipboard" "github.com/zyedidia/micro/v2/internal/config" + "github.com/zyedidia/micro/v2/internal/overlay" "github.com/zyedidia/micro/v2/internal/screen" "github.com/zyedidia/micro/v2/internal/shell" "github.com/zyedidia/micro/v2/internal/util" @@ -469,6 +470,7 @@ func DoEvent() { } action.MainTab().Display() action.InfoBar.Display() + overlay.DisplayOverlays() screen.Screen.Show() // Check for new events diff --git a/internal/display/bufwindow.go b/internal/display/bufwindow.go index 0d83041bdb..d6913853ba 100644 --- a/internal/display/bufwindow.go +++ b/internal/display/bufwindow.go @@ -109,6 +109,10 @@ func (w *BufWindow) BufView() View { } } +func (w *BufWindow) GutterOffset() int { + return w.gutterOffset +} + func (w *BufWindow) updateDisplayInfo() { b := w.Buf diff --git a/internal/lua/lua.go b/internal/lua/lua.go index 58347f2f30..62caa8bc10 100644 --- a/internal/lua/lua.go +++ b/internal/lua/lua.go @@ -455,6 +455,7 @@ func importStrings() *lua.LTable { L.SetField(pkg, "ContainsAny", luar.New(L, strings.ContainsAny)) L.SetField(pkg, "ContainsRune", luar.New(L, strings.ContainsRune)) L.SetField(pkg, "Count", luar.New(L, strings.Count)) + L.SetField(pkg, "Cut", luar.New(L, strings.Cut)) L.SetField(pkg, "EqualFold", luar.New(L, strings.EqualFold)) L.SetField(pkg, "Fields", luar.New(L, strings.Fields)) L.SetField(pkg, "FieldsFunc", luar.New(L, strings.FieldsFunc)) diff --git a/internal/overlay/overlay.go b/internal/overlay/overlay.go new file mode 100644 index 0000000000..5da04a124d --- /dev/null +++ b/internal/overlay/overlay.go @@ -0,0 +1,130 @@ +package overlay + +import ( + "github.com/mattn/go-runewidth" + "github.com/micro-editor/tcell/v2" + "github.com/zyedidia/micro/v2/internal/action" + "github.com/zyedidia/micro/v2/internal/buffer" + "github.com/zyedidia/micro/v2/internal/config" + "github.com/zyedidia/micro/v2/internal/display" + "github.com/zyedidia/micro/v2/internal/screen" + "github.com/zyedidia/micro/v2/internal/util" +) + +type OverlayHandle int +type OverlayFunction func() + +type Rect struct { + X, Y, W, H int +} + +var overlay_handle = OverlayHandle(0) +var overlays = make(map[OverlayHandle]OverlayFunction) + +func DisplayOverlays() { + // Should an OverlayFunction create or destroy an overlay, that would modify + // the overlays map while we are iterating through it. + // For this reason, we copy the overlays map into temp_overlays. + + temp_overlays := make(map[OverlayHandle]OverlayFunction, len(overlays)) + + for h, o := range overlays { + temp_overlays[h] = o + } + + for _, draw_fn := range temp_overlays { + draw_fn() + } +} + +// CreateOverlay creates and registers a new overlay, and returns +// the OverlayHandle associated with it. +func CreateOverlay(draw OverlayFunction) OverlayHandle { + overlay_handle++ + overlays[overlay_handle] = draw + return overlay_handle +} + +// DestroyOverlay destroys/deregisters an existing overlay via its handle. +func DestroyOverlay(overlay OverlayHandle) { + delete(overlays, overlay) +} + +// DrawRect draws a flat styled rectangle to the provided screen coordinates. +func DrawRect(x, y, w, h int, style tcell.Style) { + for yy := 0; yy < h; yy++ { + for xx := 0; xx < w; xx++ { + screen.SetContent(x+xx, y+yy, ' ', nil, style) + } + } +} + +// DrawText draws styled clipped text to the provided screen coordinates. +func DrawText(text string, x, y, w, h int, style tcell.Style) { + DrawRect(x, y, w, h, style) + + tabsize := util.IntOpt(config.GlobalSettings["tabsize"]) + text_bytes := []byte(text) + xx := 0 + yy := 0 + + for len(text_bytes) > 0 { + r, combc, size := util.DecodeCharacter(text_bytes) + text_bytes = text_bytes[size:] + width := 0 + + switch r { + case '\t': + width = tabsize - (xx % tabsize) + case '\n': + xx = 0 + yy++ + continue + default: + width = runewidth.RuneWidth(r) + } + + if yy > h { + break + } + + if xx+width <= w { + screen.SetContent(x+xx, y+yy, r, combc, style) + } + + xx += width + } +} + +// BufPaneScreenRect returns the bounds of a BufPane in screen coordinates. +func BufPaneScreenRect(bp *action.BufPane) Rect { + // NOTE: This function is a very thin wrapper around bp.GetView(). As such, + // it is maybe a candidate for removal? + v := bp.GetView() + return Rect{ + X: v.X, + Y: v.Y, + W: v.Width, + H: v.Height, + } +} + +// BufPaneScreenLoc converts a Loc in the buffer displayed in +// a bufpane to screen coordinates. +func BufPaneScreenLoc(bp *action.BufPane, loc buffer.Loc) buffer.Loc { + gutter := 0 + bw, ok := bp.BWindow.(*display.BufWindow) + if ok { + gutter = bw.GutterOffset() + } + + v := bp.GetView() + vloc := bp.VLocFromLoc(loc) + top := v.StartLine + yoff := bp.Diff(top, vloc.SLoc) + + return buffer.Loc{ + X: v.X + gutter + vloc.VisualX, + Y: v.Y + yoff, + } +} diff --git a/runtime/help/plugins.md b/runtime/help/plugins.md index 0411fcff40..1e6a2a4d5f 100644 --- a/runtime/help/plugins.md +++ b/runtime/help/plugins.md @@ -364,6 +364,32 @@ The packages and their contents are listed below (in Go type signatures): Relevant links: [Rune](https://pkg.go.dev/builtin#rune) +* `micro/overlay` + - `CreateOverlay(draw func()) OverlayHandle`: creates and registers a new + overlay, and returns the OverlayHandle associated with it. + - `DestroyOverlay(handle OverlayHandle)`: deregisters an existing overlay + via its handle. + - `DrawText(text string, x, y, w, h int, style tcell.Style)`: draws styled + text clipped to the bounds of the provided screen rectangle. + - `DrawRect(x, y, w, h int, style tcell.Style)`: draws a rectangle to the + provided screen coordinates. + - `BufPaneScreenRect(bp BufPane) overlay.Rect`: returns the bounds of a + BufPane in screen coordinates. + - `BufPaneScreenLoc(bp BufPane, l Loc) Loc`: converts from line/column + coordinates to screen coordinates. + - `Style() tcell.Style`: returns a default (empty) tcell.Style. + - `GetColor(name string) tcell.Style`: takes in a syntax group and returns + the colorscheme's style for that group. + - `StringToStyle(str string) tcell.Style`: returns a style from a string. + The string must be in the format "extra foregroundcolor,backgroundcolor". + The "extra" can be bold, reverse, italic or underline. + - `Redraw()`: schedules a redraw of the entire screen. + + Relevant links: + [BufPane](https://pkg.go.dev/github.com/zyedidia/micro/v2/internal/action#BufPane) + [tcell.Style](https://pkg.go.dev/github.com/micro-editor/tcell/v2#Style) + + This may seem like a small list of available functions, but some of the objects returned by the functions have many methods. The Lua plugin may access any public methods of an object returned by any of the functions above. diff --git a/runtime/plugins/completebox/completebox.lua b/runtime/plugins/completebox/completebox.lua new file mode 100644 index 0000000000..b77993a212 --- /dev/null +++ b/runtime/plugins/completebox/completebox.lua @@ -0,0 +1,144 @@ +VERSION = "1.0.0" + +local micro = import("micro") +local config = import("micro/config") +local buffer = import("micro/buffer") +local overlay = import("micro/overlay") + + +-- Immediate-mode event handling + +local overlay_handle = nil +local event_count = 0 +local events = {} +local tracked_events = {} + +function track_event(name, block) + -- Registers a global handler for an event + -- If "no_block" is passed as the second argument, + -- the event will not be prevented. + + local full_name = "pre" .. name + if block=="no_block" then + full_name = "on"..name + end + + if not tracked_events[full_name] then + tracked_events[full_name] = true + + if block~="no_block" then + _G[full_name] = function() + if overlay_handle then + events[name] = true + event_count = event_count + 1 + end + end + else + _G[full_name] = function() + if overlay_handle then + events[name] = true + event_count = event_count + 1 + return false + end + end + end + end +end + +function untrack_events() + -- Removes all global event handlers + for e, _ in pairs(tracked_events) do + _G[e] = nil + end + tracked_events = {} +end + +function reset_events() + -- Resets tracked events between redraws + events = {} + event_count = 0 +end + +function event(event_name, block) + -- Returns true if the event has occured. + track_event(event_name, block) + return events[event_name] or false +end + +function close_overlay() + -- Closes the overlay and untracks all events. + untrack_events() + overlay.DestroyOverlay(overlay_handle) + overlay_handle = nil +end + +function max_len(iter) + -- Returns the length of the longest string in iterable + local max = 0 + for _, item in iter do + max = math.max(max, #item) + end + return max +end + +function draw_autocomplete_overlay() + local bp = micro.CurPane() + local buf = bp.Buf + + if not buf.HasSuggestions then + -- If there are no suggestions, we close the overlay. + close_overlay() + return + end + + -- These events should not close the menu, so we track them, but + -- we do not block them, because we want autocomplete cycling to work. + event("CycleAutocomplete", "no_block") + event("CycleAutocompleteBack", "no_block") + + -- Positioning adjustment - show the menu below where the cursor + -- was when autocomplete was initiated by subtracting the length + -- of the currently applied completion. + local compl_len = #buf.Completions[buf.CurSuggestion+1] + 1 + + -- Note: The minus dereferences the Loc pointer + local l = -buf:GetActiveCursor().Loc + l = overlay.BufPaneScreenLoc(bp, l) + + local x = l.X-compl_len + local y = l.Y+1 + + -- Calculate the maximum text width of the options, + -- add 2 cells of padding + local w = max_len(buf.Suggestions())+2 + + -- Draw each option, highlight the current option + local yoff = 0 + local style = overlay.GetColor("cursor-line") + for i, option in buf.Suggestions() do + local style = overlay.Style() + if i == buf.CurSuggestion+1 then + style = overlay.GetColor("statusline") + end + + overlay.DrawText(" "..option, x, y+yoff, w, 1, style) + yoff = yoff+1 + end + + reset_events() +end + +function init() + config.AddRuntimeFile("completebox", config.RTHelp, "help/completebox.md") +end + +function deinit() + close_overlay() + untrack_events() +end + +function onAutocomplete() + if overlay_handle then return end + reset_events() + overlay_handle = overlay.CreateOverlay(draw_autocomplete_overlay) +end diff --git a/runtime/plugins/completebox/help/completebox.md b/runtime/plugins/completebox/help/completebox.md new file mode 100644 index 0000000000..0408831f94 --- /dev/null +++ b/runtime/plugins/completebox/help/completebox.md @@ -0,0 +1,5 @@ +# CompleteBox Plugin + +The completebox plugin demonstrates a simple way to hook +into micro's autocomplete mechanism to display the list of +available completions as an overlay at the cursor. diff --git a/runtime/plugins/quickmenu/help/quickmenu.md b/runtime/plugins/quickmenu/help/quickmenu.md new file mode 100644 index 0000000000..2f0ddc68b4 --- /dev/null +++ b/runtime/plugins/quickmenu/help/quickmenu.md @@ -0,0 +1,21 @@ +# QuickMenu Plugin + +The quickmenu plugin is a slightly more involved example of what micro's new +overlay system can do. + +The plugin exposes a palette-like quickmenu that can be used to quickly find +files by name (via 'find') or by content (via 'grep'). + +It exposes two new commands, and a single global option. + +Commands: +* `quicksearch`: Opens the find-by-name menu. +* `quickopen`: Opens the find-by-contents menu. + +By default, quicksearch will be bound to `Alt-f`, and quickopen to `Alt-o` + +Options: +* `quickmenu.newtab`: when a file is opened via the quickmenu, it will be opened + in a new tab. + + default value: `true` diff --git a/runtime/plugins/quickmenu/quickmenu.lua b/runtime/plugins/quickmenu/quickmenu.lua new file mode 100644 index 0000000000..34c018bac4 --- /dev/null +++ b/runtime/plugins/quickmenu/quickmenu.lua @@ -0,0 +1,369 @@ +local micro = import("micro") +local config = import("micro/config") +local buffer = import("micro/buffer") +local overlay = import("micro/overlay") +local shell = import("micro/shell") +local strings = import("strings") +local pathlib = import("path") + +local last_job = nil +local results = {} + +function wrap_int(val, min, max) + if min==max then return min end + local range = max - min + 1 + return min + (val - min) % range +end + +function clamp(val, min, max) + if val < min then return min end + if val > max then return max end + return val +end + +function array_get(arr, idx) + if idx <= #arr then + return arr[idx] + else + return nil + end +end + +function cancel_job(job) + if job and not job.ProcessState then + shell.JobStop(job) + end +end + +function find(query) + cancel_job(last_job) + local job = nil + results = {} + + local parts = strings.Fields(query) + local args = {".", "-type", "f"} + + for i, part in parts() do + if i>1 then + args[#args+1] = "-and" + end + args[#args+1] = "-ipath" + args[#args+1] = "*"..part.."*" + end + + function on_stdout(data) + if job~=last_job then + cancel_job(job) + return + end + + local new_results = strings.Split(data, "\n") + for _, path in new_results() do + if #path>0 then + results[#results+1] = {type="file",path=path} + end + end + + overlay.Redraw() + if #results>20 then + cancel_job() + end + end + + function on_stderr() + cancel_job(job) + end + + job = shell.JobSpawn( + "find", args, on_stdout, on_stderr, nil + ) + last_job = job +end + +function grep(query) + cancel_job(last_job) + local job = nil + results = {} + + function on_stdout(data) + if job~=last_job then + cancel_job(job) + return + end + + local new_results = strings.Split(data, "\n") + for _, res in new_results() do + local path, line, content, ok + + path, res, ok = strings.Cut(res, ":") + if ok then + line, content, ok = strings.Cut(res, ":") + + if ok then + results[#results+1] = { + type="line", + path=path, + line=line, + content=content + } + end + end + end + + overlay.Redraw() + if #results>10 then + cancel_job(job) + end + end + + function on_stderr() + cancel_job(job) + end + + job = shell.JobSpawn( + "grep", {"-rn", query, "."}, + on_stdout, on_stderr, nil + ) + last_job = job +end + +-- Immediate-mode event handling + +local overlay_handle = nil +local event_count = 0 +local events = {} +local tracked_events = {} + +function track_event(name, block) + -- Registers a global handler for an event + -- If "no_block" is passed as the second argument, + -- the event will not be prevented. + + local full_name = "pre" .. name + if block=="no_block" then + full_name = "on"..name + end + + if not tracked_events[full_name] then + tracked_events[full_name] = true + + if block~="no_block" then + _G[full_name] = function(...) + if overlay_handle then + events[name] = {...} + event_count = event_count + 1 + return false + end + end + else + _G[full_name] = function(...) + if overlay_handle then + events[name] = {...} + event_count = event_count + 1 + end + end + end + end +end + +function untrack_events() + -- Removes all global event handlers + for e, _ in pairs(tracked_events) do + _G[e] = nil + end + tracked_events = {} +end + +function reset_events() + -- Resets tracked events between redraws + events = {} + event_count = 0 +end + +function dispatch(event_name, ...) + -- Lets us dispatch our own custom events + local pre_event = _G["pre"..event_name] + local on_event = _G["on"..event_name] + + if pre_event then + local res = pre_event(...) + if not res then + return false + end + end + + if on_event then + on_event(...) + end +end + +function event(event_name, block) + -- Returns event arguments if the event has occurred, or nil otherwise. + track_event(event_name, block) + return events[event_name] +end + +function close_finder() + -- Closes the overlay and untracks all events. + untrack_events() + overlay.DestroyOverlay(overlay_handle) + overlay_handle = nil +end + +local mode = "quicksearch" +local query = "" +local current_result = 1 + +function rerun_query(query) + if mode == "quicksearch" then + grep(query) + elseif mode == "quickopen" then + find(query) + end +end + +function preRune(_, r) + -- Note: We handle rune events like this because we could + -- get more than one rune event per render (for example, + -- if the redraw is slow for whatever reason and the + -- user is typing fast). + if overlay_handle then + query = query .. r + current_result = 1 + rerun_query(query) + return false + end +end + +function draw_finder() + local bp = micro.CurPane() + + if event("Escape") then + close_finder() + return + end + + if event("Backspace") then + query = query:sub(1, -2) + current_result = 1 + rerun_query(query) + end + + if event("InsertNewline") then + local result = results[current_result] + + if result then + local buf_path = pathlib.Clean(bp.Buf.Path) + result.path = pathlib.Clean(result.path) + + if config.GetGlobalOption("quickmenu.newtab") and buf_path~=result.path then + bp:NewTabCmd{result.path} + bp = micro.CurPane() + else + bp:OpenCmd{result.path} + end + + if result.type == "line" then + bp:GotoLoc{X=0, Y=tonumber(result.line)-1} + end + end + + close_finder() + return + end + + -- TODO: Make the Left and Right arrow keys work too! + if event("CursorUp") then current_result = current_result-1 end + if event("CursorDown") then current_result = current_result+1 end + local result_count = clamp(#results, 1, 10) + current_result = wrap_int(current_result, 1, result_count+1) + + local r = overlay.BufPaneScreenRect(bp) + + local x = math.floor(r.X + r.W*0.15) + local w = math.ceil(r.W*0.7) + local y = r.Y + 2 + + -- Draw the input box + local input_style = overlay.GetColor("line-number") + + overlay.DrawRect(x-1, y, w+2, 1, input_style) + overlay.DrawText(query, x, y, w, 1, input_style) + + if query=="" then + if mode=="quicksearch" then + overlay.DrawText("Search code...", x, y, w, 1, input_style:Dim(true)) + elseif mode=="quickopen" then + overlay.DrawText("Find file...", x, y, w, 1, input_style:Dim(true)) + end + end + + -- Draw the results + local normal = overlay.GetColor("line-number") + local highlight = overlay.GetColor("selection") + + for i, result in pairs(results) do + local style = normal + if i==current_result then + style = highlight + end + + if result.type=="line" then + y = y+1 + overlay.DrawText(result.path..":"..result.line, x-1, y, w+2, 1, style:Bold(true)) + + y = y+1 + overlay.DrawRect(x-1, y, w+2, 1, style) + overlay.DrawText(" " .. result.content, x, y, w, 1, style) + + elseif result.type=="file" then + y = y+1 + overlay.DrawText(result.path, x, y, w, 1, style:Bold(true)) + + end + + if i>10 then break end + end + + reset_events() +end + +function open_finder(q) + if overlay_handle then return end + reset_events() + + if q then + query = q + else + query = "" + end + + results = {} + current_result = 1 + overlay_handle = overlay.CreateOverlay(draw_finder) +end + +function open_quickopen(_, args) + mode = "quickopen" + open_finder(array_get(args, 1)) +end + +function open_quicksearch(_, args) + mode = "quicksearch" + open_finder(array_get(args, 1)) +end + +function init() + config.AddRuntimeFile("quickmenu", config.RTHelp, "help/quickmenu.md") + + config.RegisterGlobalOption("quickmenu", "newtab", true) + config.MakeCommand("quicksearch", open_quicksearch, config.NoComplete) + config.MakeCommand("quickopen", open_quickopen, config.NoComplete) + config.TryBindKey("Alt-f", "command:quicksearch", false) + config.TryBindKey("Alt-o", "command:quickopen", false) +end + +function deinit() + close_finder() + untrack_events() +end