From b6a64d9f723200cee92521f03f6ef0c0668647b7 Mon Sep 17 00:00:00 2001 From: George Sokianos Date: Thu, 26 Dec 2024 18:12:10 +0000 Subject: [PATCH] Added widget library --- .../config/lite-xl/libraries/widget/LICENSE | 21 + .../config/lite-xl/libraries/widget/README.md | 92 + .../lite-xl/libraries/widget/button.lua | 163 ++ .../lite-xl/libraries/widget/checkbox.lua | 142 ++ .../lite-xl/libraries/widget/colorpicker.lua | 745 ++++++++ .../libraries/widget/colorpickerdialog.lua | 77 + .../lite-xl/libraries/widget/dialog.lua | 164 ++ .../libraries/widget/examples/floating.lua | 102 ++ .../libraries/widget/examples/listbox.lua | 53 + .../libraries/widget/examples/messagebox.lua | 51 + .../libraries/widget/examples/notebook.lua | 134 ++ .../libraries/widget/examples/search.lua | 129 ++ .../lite-xl/libraries/widget/filepicker.lua | 395 +++++ .../lite-xl/libraries/widget/foldingbook.lua | 216 +++ .../lite-xl/libraries/widget/fontdialog.lua | 338 ++++ .../lite-xl/libraries/widget/fonts/cache.lua | 287 +++ .../lite-xl/libraries/widget/fonts/info.lua | 550 ++++++ .../lite-xl/libraries/widget/fonts/init.lua | 230 +++ .../lite-xl/libraries/widget/fontslist.lua | 223 +++ .../config/lite-xl/libraries/widget/init.lua | 1544 +++++++++++++++++ .../lite-xl/libraries/widget/inputdialog.lua | 88 + .../lite-xl/libraries/widget/itemslist.lua | 162 ++ .../libraries/widget/keybinddialog.lua | 257 +++ .../config/lite-xl/libraries/widget/label.lua | 95 + .../config/lite-xl/libraries/widget/line.lua | 61 + .../lite-xl/libraries/widget/listbox.lua | 926 ++++++++++ .../lite-xl/libraries/widget/manifest.json | 17 + .../lite-xl/libraries/widget/messagebox.lua | 343 ++++ .../lite-xl/libraries/widget/notebook.lua | 188 ++ .../lite-xl/libraries/widget/numberbox.lua | 238 +++ .../lite-xl/libraries/widget/progressbar.lua | 95 + .../lite-xl/libraries/widget/scrollbar.lua | 33 + .../libraries/widget/searchreplacelist.lua | 607 +++++++ .../lite-xl/libraries/widget/selectbox.lua | 288 +++ .../lite-xl/libraries/widget/textbox.lua | 308 ++++ .../lite-xl/libraries/widget/toggle.lua | 140 ++ .../lite-xl/libraries/widget/treelist.lua | 609 +++++++ 37 files changed, 10111 insertions(+) create mode 100644 resources/amiga/config/lite-xl/libraries/widget/LICENSE create mode 100644 resources/amiga/config/lite-xl/libraries/widget/README.md create mode 100644 resources/amiga/config/lite-xl/libraries/widget/button.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/checkbox.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/colorpicker.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/colorpickerdialog.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/dialog.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/examples/floating.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/examples/listbox.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/examples/messagebox.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/examples/notebook.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/examples/search.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/filepicker.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/foldingbook.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/fontdialog.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/fonts/cache.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/fonts/info.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/fonts/init.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/fontslist.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/init.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/inputdialog.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/itemslist.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/keybinddialog.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/label.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/line.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/listbox.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/manifest.json create mode 100644 resources/amiga/config/lite-xl/libraries/widget/messagebox.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/notebook.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/numberbox.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/progressbar.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/scrollbar.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/searchreplacelist.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/selectbox.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/textbox.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/toggle.lua create mode 100644 resources/amiga/config/lite-xl/libraries/widget/treelist.lua diff --git a/resources/amiga/config/lite-xl/libraries/widget/LICENSE b/resources/amiga/config/lite-xl/libraries/widget/LICENSE new file mode 100644 index 00000000..2fa909a9 --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Jefferson González + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/resources/amiga/config/lite-xl/libraries/widget/README.md b/resources/amiga/config/lite-xl/libraries/widget/README.md new file mode 100644 index 00000000..762fc1cc --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/README.md @@ -0,0 +1,92 @@ +**Update Notice:** With the now available +[lpm](https://github.com/lite-xl/lite-xl-plugin-manager) package manager, the +installation path for the Widgets has changed to: `{DATADIR}/libraries/widget`. +Users and package maintainers are encouraged to point the widgets library +to this new location as all plugins making use of it will be updated to use +the new location. + +# Lite XL Widgets + +A widgets plugin that can be used by plugin writers to more easily implement +interactive UI elements. The plugin leverages lite-xl __View__ system and +provides ready to use components to reduce code duplication for stuff that +most of the time is the same and simplify the process of writing your own +GUI controls. + +## Some Features + +* dragging +* floating view +* on hover event +* basic onclick event +* tooltip by using status view +* detection of widgets that don't need update or drawing which lowers cpu usage +* child widget coordinates calculations relative to the parent widget + +Components currently provided by this plugin are: + +* [Base Widget](init.lua) +* [Button](button.lua) +* [CheckBox](checkbox.lua) +* [ColorPicker](colorpicker.lua) +* [ColorPickerDialog](colorpickerdialog.lua) +* [Dialog](dialog.lua) +* [FilePicker](filepicker.lua) +* [FoldingBook](foldingbook.lua) +* [FontDialog](fontdialog.lua) +* [FontsList](fontslist.lua) +* [InputDialog](inputdialog.lua) +* [ItemsList](itemslist.lua) +* [KeybindDialog](keybinddialog.lua) +* [Label](label.lua) +* [Line](line.lua) +* [ListBox](listbox.lua) +* [MessageBox](messagebox.lua) +* [NoteBook](notebook.lua) +* [NumberBox](numberbox.lua) +* [ProgressBar](progressbar.lua) +* [SearchReplaceList](searchreplacelist.lua) +* [SelectBox](selectbox.lua) +* [TextBox](textbox.lua) +* [TreeList](treelist.lua) +* [Toggle](toggle.lua) + +You can also write your own re-usable components and share them back for +everyone to benefit by opening a Pull Request! + +## Installation + +Clone into the lite-xl configuration directory, for example on linux: + +```sh +mkdir ~/.config/lite-xl/libraries +git clone https://github.com/lite-xl/lite-xl-widgets ~/.config/lite-xl/libraries/widget +``` + +## Usage + +Until some form of documentation is written check the [examples](examples/) +directory which contains code samples to help you understand how to use the +plugin. A good starting point can be the [search mockup](examples/search.lua). + +## Showcase Videos + +Floating non blocking message boxes: + +https://user-images.githubusercontent.com/1702572/160674291-cd13192d-d256-4a19-a641-166d4585be68.mp4 + +Floating parent widget with a ListBox inside: + +https://user-images.githubusercontent.com/1702572/160674347-60d6d497-5612-4f5b-9d0d-65d417586c64.mp4 + +Non floating mockup of a search side bar: + +https://user-images.githubusercontent.com/1702572/160674403-d0fcea4f-6b94-496c-b150-1c7372c93f29.mp4 + +A bottom NoteBook with tabs and various widgets inside. + +https://user-images.githubusercontent.com/1702572/160674477-e89d4aa1-ce21-4e50-b1c7-b30ab58cbcde.mp4 + +ListBox with formatted text used in LSP plugin: + +https://user-images.githubusercontent.com/1702572/160675168-c86dbcad-5b20-4f7c-9b07-7d092683ebb0.mp4 diff --git a/resources/amiga/config/lite-xl/libraries/widget/button.lua b/resources/amiga/config/lite-xl/libraries/widget/button.lua new file mode 100644 index 00000000..f346d312 --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/button.lua @@ -0,0 +1,163 @@ +-- +-- Button Widget. +-- @copyright Jefferson Gonzalez +-- @license MIT +-- + +local style = require "core.style" +local Widget = require "libraries.widget" + +---@class widget.button.icon +---@field public code string | nil +---@field public color renderer.color | nil +---@field public hover_color renderer.color | nil +local ButtonIcon = {} + +---@class widget.button : widget +---@overload fun(parent:widget?, label:string?):widget.button +---@field public padding widget.position +---@field public icon widget.button.icon +---@field public expanded boolean +local Button = Widget:extend() + +---Constructor +---@param parent widget +---@param label? string +function Button:new(parent, label) + Button.super.new(self, parent) + + self.type_name = "widget.button" + + self.icon = { + code = nil, color = nil, hover_color = nil + } + + self.padding = { + x = style.padding.x, + y = style.padding.y + } + + self.expanded = false + + self:set_label(label or "") +end + +---When set to true the button width will be the same as parent +---@param expand? boolean | nil +function Button:toggle_expand(expand) + if type(expand) == "boolean" then + self.expanded = expand + else + self.expanded = not self.expanded + end +end + +---Set the icon drawn alongside the button text. +---@param code? string +---@param color? renderer.color +---@param hover_color? renderer.color +function Button:set_icon(code, color, hover_color) + self.icon.code = code + self.icon.color = color + self.icon.hover_color = hover_color + + self:set_label(self.label) +end + +---Set the button text and recalculates the widget size. +---@param text string +function Button:set_label(text) + Button.super.set_label(self, text) + + local font = self:get_font() + local border = self.border.width * 2 + + if self.expanded and self.parent then + self.size.x = self.parent.size.x - self.position.rx - border + else + self.size.x = font:get_width(self.label) + (self.padding.x * 2) - border + end + + self.size.y = font:get_height() + (self.padding.y * 2) - border + + if self.icon.code then + local icon_w = style.icon_font:get_width(self.icon.code) + + if self.label ~= "" then + icon_w = icon_w + (self.padding.x / 2) + end + + local icon_h = style.icon_font:get_height() + (self.padding.y * 2) - border + + self.size.x = self.size.x + icon_w + self.size.y = math.max(self.size.y, icon_h) + end +end + +function Button:on_mouse_enter(...) + Button.super.on_mouse_enter(self, ...) + self.hover_text = style.accent + self.hover_back = style.line_highlight +end + +function Button:on_mouse_leave(...) + Button.super.on_mouse_leave(self, ...) + self.hover_text = nil + self.hover_back = nil +end + +function Button:on_scale_change(new_scale, prev_scale) + Button.super.on_scale_change(self, new_scale, prev_scale) + self.padding.x = self.padding.x * (new_scale / prev_scale) + self.padding.y = self.padding.y * (new_scale / prev_scale) +end + +function Button:update() + if not Button.super.update(self) then return false end + + -- update size + self:set_label(self.label) + + return true +end + +function Button:draw() + self.background_color = self.hover_back or style.background + + if not Button.super.draw(self) then return false end + + local font = self:get_font() + + local offsetx = self.position.x + self.padding.x + local offsety = self.position.y + local h = self:get_height() + local ih, th = style.icon_font:get_height(), font:get_height() + + if self.icon.code then + local normal = self.icon.color or style.text + local hover = self.icon.hover_color or style.accent + renderer.draw_text( + style.icon_font, + self.icon.code, + offsetx, + th > ih and (offsety + (h / 2)) - (ih/2) or (offsety + self.padding.y), + self.hover_text and hover or normal + ) + offsetx = offsetx + style.icon_font:get_width(self.icon.code) + (style.padding.x / 2) + end + + if self.label ~= "" then + renderer.draw_text( + font, + self.label, + offsetx, + ih > th and (offsety + (h / 2)) - (th/2) or (offsety + self.padding.y), + self.hover_text or self.foreground_color or style.text + ) + end + + return true +end + + +return Button diff --git a/resources/amiga/config/lite-xl/libraries/widget/checkbox.lua b/resources/amiga/config/lite-xl/libraries/widget/checkbox.lua new file mode 100644 index 00000000..357defa3 --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/checkbox.lua @@ -0,0 +1,142 @@ +-- +-- CheckBox Widget. +-- @copyright Jefferson Gonzalez +-- @license MIT +-- + +local style = require "core.style" +local Widget = require "libraries.widget" + +---@class widget.checkbox : widget +---@overload fun(parent?:widget, label?:string):widget.checkbox +---@field private checked boolean +local CheckBox = Widget:extend() + +---Constructor +---@param parent widget +---@param label string +function CheckBox:new(parent, label) + CheckBox.super.new(self, parent) + self.type_name = "widget.checkbox" + self.checked = false + self:set_label(label or "") + self.animating = false + self.animating_color = style.caret +end + +---Set the checkbox label and recalculates the widget size. +---@param text string +function CheckBox:set_label(text) + CheckBox.super.set_label(self, text) + + local _, _, bw, _ = self:get_box_rect() + + local font = self:get_font() + + self.size.x = font:get_width(self.label) + bw + (style.padding.x / 2) + self.size.y = font:get_height() +end + +---Change the status of the checkbox. +---@param checked boolean +function CheckBox:set_checked(checked) + self.checked = checked + self:on_change(self.checked) +end + +---Get the status of the checkbox. +---@return boolean +function CheckBox:is_checked() + return self.checked +end + +---Called when the checkbox is (un)checked. +---@param checked boolean +function CheckBox:on_checked(checked) end + +function CheckBox:on_mouse_enter(...) + CheckBox.super.on_mouse_enter(self, ...) + self.hover_text = style.accent + self.hover_back = style.dim +end + +function CheckBox:on_mouse_leave(...) + CheckBox.super.on_mouse_leave(self, ...) + self.hover_text = nil + self.hover_back = nil +end + +function CheckBox:on_click() + self.checked = not self.checked + self:on_checked(self.checked) + self:on_change(self.checked) + + self.animating = true + self.animating_color = {table.unpack(style.caret)} + local target_color = {table.unpack(style.caret)} + + if self.checked then + self.animating_color[4] = 0 + target_color[4] = 255 + else + self.animating_color[4] = 255 + target_color[4] = 0 + end + self:animate(self.animating_color, {table.unpack(target_color)}, { + on_complete = function() + self.animating = false + end + }) +end + +function CheckBox:get_box_rect() + local size = 1.6 + local font = self:get_font() + local fh = font:get_height() / size + return + self.position.x, + self.position.y + (fh / (size * 2)), + font:get_width("x") + 4, + fh +end + +function CheckBox:update() + if not CheckBox.super.update(self) then return false end + + -- update size + self:set_label(self.label) + + return true +end + +function CheckBox:draw() + if not self:is_visible() then return false end + + local bx, by, bw, bh = self:get_box_rect() + + self:draw_border(bx, by, bw, bh) + + renderer.draw_rect( + bx, by, bw, bh, + self.hover_back or self.background_color or style.background + ) + + if self.animating then + renderer.draw_rect(bx + 2, by + 2, bw-4, bh-4, self.animating_color) + elseif self.checked then + renderer.draw_rect(bx + 2, by + 2, bw-4, bh-4, style.caret) + end + + renderer.draw_text( + self:get_font(), + self.label, + self.position.x + bw + (style.padding.x / 2), + self.position.y, + self.hover_text or self.foreground_color or style.text + ) + + return true +end + + +return CheckBox diff --git a/resources/amiga/config/lite-xl/libraries/widget/colorpicker.lua b/resources/amiga/config/lite-xl/libraries/widget/colorpicker.lua new file mode 100644 index 00000000..4e2563b5 --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/colorpicker.lua @@ -0,0 +1,745 @@ +-- +-- Color Picker Widget +-- Note: HSV and HSL conversion functions adapted from: +-- https://github.com/EmmanuelOga/columns/blob/master/utils/color.lua +-- which in turn was ported from: +-- http://axonflux.com/handy-rgb-to-hsl-and-rgb-to-hsv-color-model-c +-- +local core = require "core" +local style = require "core.style" +local common = require "core.common" +local Widget = require "libraries.widget" +local TextBox = require "libraries.widget.textbox" + +---@alias widget.colorpicker.colorrange renderer.color[] + +---The numerical portion of a color range on the hue selection bar. +---@type number +local HUE_COLOR_SEGMENT = 100 / 6 + +---Hue color ranges in the order rendered on the hue bar. +---@type widget.colorpicker.colorrange[] +local HUE_COLOR_RANGES = { + -- red -> yellow + { {255, 0, 0, 255}, {255, 255, 0, 255} }, + -- yellow -> green + { {255, 255, 0, 255}, {0, 255, 0, 255} }, + -- green -> cyan + { {0, 255 ,0, 255}, {0, 255, 255, 255} }, + -- cyan -> blue + { {0, 255, 255, 255}, {0, 0, 255, 255} }, + -- blue -> purple + { {0, 0, 255, 255}, {255, 0, 255, 255} }, + -- purple -> red + { {255, 0, 255, 255}, {255, 0, 0, 255} } +} + +---@type renderer.color +local COLOR_BLACK = {0, 0, 0, 255} + +---@type renderer.color +local COLOR_WHITE = {255, 255, 255, 255} + +---@class widget.colorpicker : widget +---@overload fun(parent:widget?, color?:renderer.color|string):widget.colorpicker +---@field hue_color renderer.color +---@field saturation_color renderer.color +---@field brightness_color renderer.color +---@field hue_pos number +---@field saturation_pos number +---@field brightness_pos number +---@field alpha number +---@field hue_mouse_down boolean +---@field saturation_mouse_down boolean +---@field brightness_mouse_down boolean +---@field html_notation widget.textbox +---@field rgba_notation widget.textbox +local ColorPicker = Widget:extend() + +---Constructor +---@param parent widget +---@param color? renderer.color | string +function ColorPicker:new(parent, color) + ColorPicker.super.new(self, parent, false) + + self.type_name = "widget.colorpicker" + + self.hue_pos = 0 + self.saturation_pos = 100 + self.brightness_pos = 100 + self.alpha = 255 + + self.hue_color = COLOR_BLACK + self.saturation_color = COLOR_BLACK + self.brightness_color = COLOR_BLACK + + self.hue_mouse_down = false; + self.saturation_mouse_down = false + self.brightness_mouse_down = false + + self.selector = { x = 0, y = 0, w = 0, h = 0 } + + self:set_border_width(0) + + local this = self + self.html_notation = TextBox(self, "#FF0000") + self.rgba_notation = TextBox(self, "rgba(255,0,0,1)") + self.html_updating = false + self.rgba_updating = false + + function self.html_notation:on_change(value) + if + not this.hue_mouse_down + and + not this.saturation_mouse_down + and + not this.brightness_mouse_down + and + not this.html_updating + then + this:set_color(value, true) + end + end + + function self.rgba_notation:on_change(value) + if + not this.hue_mouse_down + and + not this.saturation_mouse_down + and + not this.brightness_mouse_down + and + not this.rgba_updating + then + this:set_color(value, false, true) + end + end + + self:set_color(color or {255, 0, 0, 255}) + + -- set initial child positions and size + self:update_size() +end + +---Converts an RGB color value to HSL. Conversion formula +---adapted from http://en.wikipedia.org/wiki/HSL_color_space. +---Assumes r, g, and b are contained in the set [0, 255] and +---returns h, s, and l in the set [0, 1]. +---@param rgba renderer.color +---@return table hsla +function ColorPicker.rgb_to_hsl(rgba) + local r, g, b, a = rgba[1], rgba[2], rgba[3], rgba[4] + r, g, b = r / 255, g / 255, b / 255 + + local max, min = math.max(r, g, b), math.min(r, g, b) + local h, s, l + + l = (max + min) / 2 + + if max == min then + h, s = 0, 0 -- achromatic + else + local d = max - min + if l > 0.5 then s = d / (2 - max - min) else s = d / (max + min) end + if max == r then + h = (g - b) / d + if g < b then h = h + 6 end + elseif max == g then h = (b - r) / d + 2 + elseif max == b then h = (r - g) / d + 4 + end + h = h / 6 + end + + return {h, s, l, a and a/255 or 1} +end + +---Converts an HSL color value to RGB. Conversion formula +---adapted from http://en.wikipedia.org/wiki/HSL_color_space. +---Assumes h, s, and l are contained in the set [0, 1] and +---returns r, g, and b in the set [0, 255]. +---@param h number The hue +---@param s number The saturation +---@param l number The lightness +---@param a number The alpha +---@return renderer.color rgba +function ColorPicker.hsl_to_rgb(h, s, l, a) + local r, g, b + + if s == 0 then + r, g, b = l, l, l -- achromatic + else + local function hue2rgb(p, q, t) + if t < 0 then t = t + 1 end + if t > 1 then t = t - 1 end + if t < 1/6 then return p + (q - p) * 6 * t end + if t < 1/2 then return q end + if t < 2/3 then return p + (q - p) * (2/3 - t) * 6 end + return p + end + + local q + if l < 0.5 then q = l * (1 + s) else q = l + s - l * s end + local p = 2 * l - q + + r = hue2rgb(p, q, h + 1/3) + g = hue2rgb(p, q, h) + b = hue2rgb(p, q, h - 1/3) + end + + return {r * 255, g * 255, b * 255, a * 255} +end + +---Converts an RGB color value to HSV. Conversion formula +---adapted from http://en.wikipedia.org/wiki/HSV_color_space. +---Assumes r, g, and b are contained in the set [0, 255] and +---returns h, s, and v in the set [0, 1]. +---@param rgba renderer.color +---@return table hsva The HSV representation +function ColorPicker.rgb_to_hsv(rgba) + local r, g, b, a = rgba[1], rgba[2], rgba[3], rgba[4] + r, g, b, a = r / 255, g / 255, b / 255, a / 255 + local max, min = math.max(r, g, b), math.min(r, g, b) + local h, s, v + v = max + + local d = max - min + if max == 0 then s = 0 else s = d / max end + + if max == min then + h = 0 -- achromatic + else + if max == r then + h = (g - b) / d + if g < b then h = h + 6 end + elseif max == g then h = (b - r) / d + 2 + elseif max == b then h = (r - g) / d + 4 + end + h = h / 6 + end + + return {h, s, v, a/255} +end + +---Converts an HSV color value to RGB. Conversion formula +---adapted from http://en.wikipedia.org/wiki/HSV_color_space. +---Assumes h, s, and v are contained in the set [0, 1] and +---returns r, g, and b in the set [0, 255]. +---@param h number The hue +---@param s number The saturation +---@param v number The brightness +---@param a number The alpha +---@return renderer.color rgba The RGB representation +function ColorPicker.hsv_to_rgb(h, s, v, a) + local r, g, b + + local i = math.floor(h * 6); + local f = h * 6 - i; + local p = v * (1 - s); + local q = v * (1 - f * s); + local t = v * (1 - (1 - f) * s); + + i = i % 6 + + if i == 0 then r, g, b = v, t, p + elseif i == 1 then r, g, b = q, v, p + elseif i == 2 then r, g, b = p, v, t + elseif i == 3 then r, g, b = p, q, v + elseif i == 4 then r, g, b = t, p, v + elseif i == 5 then r, g, b = v, p, q + end + + return {math.ceil(r * 255), math.ceil(g * 255), math.ceil(b * 255), math.ceil(a * 255)} +end + +---Converts a css format color string into a renderer.color if possible, +---if conversion fails returns nil. Adapted from colorpreview plugin. +---@param color string +---@return renderer.color? color +function ColorPicker.color_from_string(color) + local s, e, r, g, b, a, base, nibbles; + + s, e, r, g, b, a = color:find("#(%x%x)(%x%x)(%x%x)(%x?%x?)") + + if s then + base = 16 + else + s, e, r, g, b, a = color:find("#(%x)(%x)(%x)") + + if s then + base = 16 + nibbles = true + else + s, e, r, g, b, a = color:find( + "rgba?%((%d+)%D+(%d+)%D+(%d+)[%s,]-([%.%d]-)%s-%)" + ) + end + end + + if not s then return nil end + + r = tonumber(r or "", base) + g = tonumber(g or "", base) + b = tonumber(b or "", base) + + a = tonumber(a or "", base) + if a ~= nil then + if base ~= 16 then + a = a * 0xff + end + else + a = 0xff + end + + if nibbles then + r = r * 16 + g = g * 16 + b = b * 16 + end + + return {r, g, b, a} +end + +---Gets a color between two given colors on the position +---defined by the given percent. +---@param from_color renderer.color +---@param to_color renderer.color +---@param percent number +---@return renderer.color color +function ColorPicker.color_in_between(from_color, to_color, percent) + local color = {} + for i=1, 4 do + if from_color[i] == to_color[i] then + color[i] = from_color[i] + else + color[i] = common.clamp( + from_color[i] + math.floor((to_color[i] - from_color[i]) * percent), + 0, + 255 + ) + end + end + return color +end + +function ColorPicker:get_name() + return "Color Picker" +end + +---Gets the currently selected color on the hue bar. +---@return renderer.color +function ColorPicker:get_hue_color() + local w = self.selector.w + local pos = self.hue_pos + local pos_percent = pos / 100 + local range_size = w / 6 + local range + if pos <= (HUE_COLOR_SEGMENT) then + range = 1 + elseif pos <= (HUE_COLOR_SEGMENT) * 2 then + range = 2 + elseif pos <= (HUE_COLOR_SEGMENT) * 3 then + range = 3 + elseif pos <= (HUE_COLOR_SEGMENT) * 4 then + range = 4 + elseif pos <= (HUE_COLOR_SEGMENT) * 5 then + range = 5 + else + range = 6 + end + local range_position = (w * pos_percent) - ((range_size * range)-range_size) + local range_percent = range_position / range_size + return ColorPicker.color_in_between( + HUE_COLOR_RANGES[range][1], + HUE_COLOR_RANGES[range][2], + range_percent + ) +end + +---Gets the currently selected color on the saturation bar. +---@return renderer.color +function ColorPicker:get_saturation_color() + local w = self.selector.w + local pos = self.saturation_pos + local pos_percent = pos / 100 + local range_size = w + local range, color1, color2 = 1, COLOR_WHITE, self.hue_color + local range_position = (w * pos_percent) - ((range_size * range)-range_size) + local range_percent = range_position / range_size + return ColorPicker.color_in_between(color1, color2, range_percent) +end + +---Gets the currently selected color on the brightness bar. +---@return renderer.color +function ColorPicker:get_brightness_color() + local w = self.selector.w + local pos = self.brightness_pos + local pos_percent = pos / 100 + local range_size = w + local range, color1, color2 = 1, COLOR_BLACK, self.saturation_color + local range_position = (w * pos_percent) - ((range_size * range)-range_size) + local range_percent = range_position / range_size + return ColorPicker.color_in_between(color1, color2, range_percent) +end + +---Gets the currently selected rgba color. +---@return renderer.color +function ColorPicker:get_color() + return ColorPicker.hsv_to_rgb( + self.hue_pos / 100, + self.saturation_pos / 100, + self.brightness_pos / 100, + self.alpha / 255 + ) +end + +---Set current color from rgba source which can also +---be a css string representation. +---@param color renderer.color | string +function ColorPicker:set_color(color, skip_html, skip_rgba) + -- we set the color on a coroutine in case it is been set before + -- the control is properly initialized like the constructor. + core.add_thread(function() + if type(color) == "string" then + color = ColorPicker.color_from_string(color) + end + + if not color then color = {255, 0, 0, 255} end + + local hsva = ColorPicker.rgb_to_hsv(color) + + self.hue_pos = hsva[1] * 100 + self.saturation_pos = hsva[2] * 100 + self.brightness_pos = hsva[3] * 100 + + self.hue_color = self:get_hue_color() + self.saturation_color = self:get_saturation_color() + self.brightness_color = self:get_brightness_color() + self.alpha = color[4] + + if not skip_html then + self.html_updating = true + self.html_notation:set_text(string.format( + "#%02X%02X%02X%02X", + color[1], color[2], color[3], color[4] + )) + self.html_updating = false + end + + if not skip_rgba then + self.rgba_updating = true + self.rgba_notation:set_text(string.format( + "rgba(%d,%d,%d,%.2f)", + color[1], color[2], color[3], color[4] / 255 + )) + self.rgba_updating = false + end + + self:on_change(color) + end) +end + +---Set the transparency level, the lower the given alpha the more transparent. +---@param alpha number A value from 0 to 255 +function ColorPicker:set_alpha(alpha) + self.alpha = common.clamp(alpha, 0, 255) +end + +---Draw a hue color bar at given location and size. +---@param x number +---@param y number +---@param w number +---@param h number +function ColorPicker:draw_hue(x, y, w, h) + local sx = x + local step = 1 + local cwidth = 1 + local cheight = h or 10 + + if w < 255*6 then + step = 255 / (w / 6) + else + cwidth = (w / 6) / 255 + end + + -- red -> yellow + for g=0, 255, step do + renderer.draw_rect(x, y, cwidth, cheight, {255, g, 0, 255}) + x = x + cwidth + end + + -- yellow -> green + for r=255, 0, -step do + renderer.draw_rect(x, y, cwidth, cheight, {r, 255, 0, 255}) + x = x + cwidth + end + + -- green -> cyan + for b=0, 255, step do + renderer.draw_rect(x, y, cwidth, cheight, {0, 255, b, 255}) + x = x + cwidth + end + + -- cyan -> blue + for g=255, 0, -step do + renderer.draw_rect(x, y, cwidth, cheight, {0, g, 255, 255}) + x = x + cwidth + end + + -- blue -> purple + for r=0, 255, step do + renderer.draw_rect(x, y, cwidth, cheight, {r, 0, 255, 255}) + x = x + cwidth + end + + -- purple -> red + for b=255, 0, -step do + renderer.draw_rect(x, y, cwidth, cheight, {255, 0, b, 255}) + x = x + cwidth + end + + sx = sx + (w * (self.hue_pos / 100)) + self:draw_selector(sx, y, cheight, self.hue_color) +end + +---Draw a saturation color bar at given location and size. +---@param x number +---@param y number +---@param w number +---@param h number +function ColorPicker:draw_saturation(x, y, w, h) + local sx = x + local step = 1 + local cwidth = 1 + local cheight = h or 10 + + if w < 255 then + step = 255 / w + else + cwidth = w / 255 + end + + -- white to base + for i=0, 255, step do + local color = ColorPicker.color_in_between(COLOR_WHITE, self.hue_color, i / 255) + renderer.draw_rect(x, y, cwidth, cheight, color) + x = x + cwidth + end + + sx = sx + (w * (self.saturation_pos / 100)) + self:draw_selector(sx, y, cheight, self.saturation_color) +end + +---Draw a brightness color bar at given location and size. +---@param x number +---@param y number +---@param w number +---@param h number +function ColorPicker:draw_brightness(x, y, w, h) + local sx = x + local step = 1 + local cwidth = 1 + local cheight = h or 10 + + if w < 255 then + step = 255 / w + else + cwidth = w / 255 + end + + -- black to base + for i=0, 255, step do + local color = ColorPicker.color_in_between(COLOR_BLACK, self.saturation_color, i / 255) + renderer.draw_rect(x, y, cwidth, cheight, color) + x = x + cwidth + end + + sx = sx + (w * (self.brightness_pos / 100)) + self:draw_selector(sx, y, cheight, self.brightness_color) +end + +---@param self widget.colorpicker +local function update_control_values(self) + local color = self:get_color() + self.alpha = color[4] + self.html_updating = true + self.rgba_updating = true + self.html_notation:set_text(string.format( + "#%02X%02X%02X%02X", + color[1], color[2], color[3], color[4] + )) + self.rgba_notation:set_text(string.format( + "rgba(%d,%d,%d,%.2f)", + color[1], color[2], color[3], color[4] / 255 + )) + self.html_updating = false + self.rgba_updating = false + self:on_change(color) +end + +function ColorPicker:on_mouse_pressed(button, x, y, clicks) + if not ColorPicker.super.on_mouse_pressed(self, button, x, y, clicks) then + return false + end + + if + x >= self.selector.x and x <= self.selector.x + self.selector.w + and + y >= self.selector.y and y <= self.selector.y + self.selector.h + then + local sx, sw = self.selector.x, self.selector.w + self.hue_pos = common.clamp(x, sx, sx + sw) + self.hue_pos = ((self.hue_pos - self.selector.x) / self.selector.w) * 100 + self.hue_color = self:get_hue_color() + self.saturation_color = self:get_saturation_color() + self.hue_mouse_down = true + elseif + x >= self.selector.x and x <= self.selector.x + self.selector.w + and + y >= self.selector.y + style.padding.y + self.selector.h + and + y <= self.selector.y + style.padding.y + (self.selector.h * 2) + then + local sx, sw = self.selector.x, self.selector.w + self.saturation_pos = common.clamp(x, sx, sx + sw) + self.saturation_pos = ((self.saturation_pos - self.selector.x) / self.selector.w) * 100 + self.saturation_color = self:get_saturation_color() + self.brightness_color = self:get_brightness_color() + self.saturation_mouse_down = true + elseif + x >= self.selector.x and x <= self.selector.x + self.selector.w + and + y >= self.selector.y + style.padding.y * 2 + self.selector.h * 2 + and + y <= self.selector.y + style.padding.y * 2 + (self.selector.h * 4) + then + local sx, sw = self.selector.x, self.selector.w + self.brightness_pos = common.clamp(x, sx, sx + sw) + self.brightness_pos = ((self.brightness_pos - self.selector.x) / self.selector.w) * 100 + self.brightness_color = self:get_brightness_color() + self.brightness_mouse_down = true + end + if self.hue_mouse_down or self.saturation_mouse_down or self.brightness_mouse_down then + self:capture_mouse() + update_control_values(self) + end + return true +end + +function ColorPicker:on_mouse_released(button, x, y) + if self.hue_mouse_down or self.saturation_mouse_down or self.brightness_mouse_down then + self:release_mouse() + end + + self.hue_mouse_down = false + self.saturation_mouse_down = false + self.brightness_mouse_down = false + + if not ColorPicker.super.on_mouse_released(self, button, x, y) then + return false + end + + return true +end + +function ColorPicker:on_mouse_moved(x, y, dx, dy) + if self.hue_mouse_down then + local sx, sw = self.selector.x, self.selector.w + self.hue_pos = common.clamp(x, sx, sx + sw) + self.hue_pos = ((self.hue_pos - self.selector.x) / self.selector.w) * 100 + self.hue_color = self:get_hue_color() + self.saturation_color = self:get_saturation_color() + self.brightness_color = self:get_brightness_color() + elseif self.saturation_mouse_down then + local sx, sw = self.selector.x, self.selector.w + self.saturation_pos = common.clamp(x, sx, sx + sw) + self.saturation_pos = ((self.saturation_pos - self.selector.x) / self.selector.w) * 100 + self.saturation_color = self:get_saturation_color() + self.brightness_color = self:get_brightness_color() + elseif self.brightness_mouse_down then + local sx, sw = self.selector.x, self.selector.w + self.brightness_pos = common.clamp(x, sx, sx + sw) + self.brightness_pos = ((self.brightness_pos - self.selector.x) / self.selector.w) * 100 + self.brightness_color = self:get_brightness_color() + end + if self.hue_mouse_down or self.saturation_mouse_down or self.brightness_mouse_down then + update_control_values(self) + else + return ColorPicker.super.on_mouse_moved(self, x, y, dx, dy) + end + return true +end + +function ColorPicker:update_size() + self.selector.h = 10 * SCALE + local x, y = 0, style.padding.y * 3 + self.selector.h * 4 + self.html_notation:set_position(x, y) + self.rgba_notation:set_position(self.html_notation:get_right() + style.padding.x, y) + if self:get_width() < self.rgba_notation:get_right() then + self:set_size(self.rgba_notation:get_right() + style.padding.x) + end + if self:get_height() < self.rgba_notation:get_bottom() then + self:set_size(nil, self.rgba_notation:get_bottom() + style.padding.y) + end +end + +function ColorPicker:update() + if not ColorPicker.super.update(self) then return false end + self:update_size() + return true +end + +function ColorPicker:draw_selector(x, y, h, color) + local border = 2 * SCALE + x = x - border - ((10 * SCALE) / border) + y = y - border + renderer.draw_rect(x, y, 14 * SCALE, h + (border * 2), COLOR_WHITE) + renderer.draw_rect(x+border, y + border, 10 * SCALE, h, color) +end + +function ColorPicker:draw() + if not ColorPicker.super.draw(self) then return false end + + local x, y, w = self.position.x, self.position.y, self.size.x + + self.selector.x = x + self.selector.y = style.padding.y + y + self.selector.w = w - style.padding.x - 100 + self.selector.h = 10 * SCALE + + self:draw_hue( + self.selector.x, + self.selector.y, + self.selector.w, + self.selector.h + ) + + self:draw_saturation( + self.selector.x, + self.selector.y + style.padding.y + self.selector.h, + self.selector.w, + self.selector.h + ) + + self:draw_brightness( + self.selector.x, + self.selector.y + style.padding.y * 2 + self.selector.h * 2, + self.selector.w, + self.selector.h + ) + + local c = self:get_color() + + renderer.draw_rect( + self.selector.x + style.padding.x + self.selector.w, + self.selector.y, + 100, + (self.selector.y + style.padding.y * 2 + self.selector.h * 3) + - self.selector.y, + c + ) + + return true +end + + +return ColorPicker diff --git a/resources/amiga/config/lite-xl/libraries/widget/colorpickerdialog.lua b/resources/amiga/config/lite-xl/libraries/widget/colorpickerdialog.lua new file mode 100644 index 00000000..a88dc803 --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/colorpickerdialog.lua @@ -0,0 +1,77 @@ +-- +-- Color Picker Dialog Widget. +-- @copyright Jefferson Gonzalez +-- @license MIT +-- + +local style = require "core.style" +local Button = require "libraries.widget.button" +local ColorPicker = require "libraries.widget.colorpicker" +local Dialog = require "libraries.widget.dialog" + +---@class widget.colorpickerdialog : widget.dialog +---@overload fun(title?:string, color?:renderer.color|string):widget.colorpickerdialog +---@field super widget.dialog +---@field picker widget.colorpicker +---@field apply widget.button +---@field cancel widget.button +local ColorPickerDialog = Dialog:extend() + +---Constructor +---@param title? string +---@param color? renderer.color | string +function ColorPickerDialog:new(title, color) + ColorPickerDialog.super.new(self, title or "Color Picker") + + self.type_name = "widget.colorpickerdialog" + self.picker = ColorPicker(self.panel, color) + + local this = self + + self.apply = Button(self.panel, "Apply") + self.apply:set_icon("S") + function self.apply:on_click() + this:on_apply(this.picker:get_color()) + this:on_close() + end + + self.cancel = Button(self.panel, "Cancel") + self.cancel:set_icon("C") + function self.cancel:on_click() + this:on_close() + end +end + +---Called when the user clicks on apply +---@param value renderer.color +function ColorPickerDialog:on_apply(value) end + +function ColorPickerDialog:update() + if not ColorPickerDialog.super.update(self) then return false end + + self.picker:set_position(style.padding.x/2, 0) + + self.apply:set_position( + style.padding.x/2, + self.picker:get_bottom() + style.padding.y + ) + self.cancel:set_position( + self.apply:get_right() + style.padding.x, + self.picker:get_bottom() + style.padding.y + ) + + self.panel.size.x = self.panel:get_real_width() + style.padding.x + self.panel.size.y = self.panel:get_real_height() + self.size.x = self:get_real_width() - (style.padding.x / 2) + self.size.y = self:get_real_height() + (style.padding.y / 2) + + self.close:set_position( + self.size.x - self.close.size.x - (style.padding.x / 2), + style.padding.y / 2 + ) + + return true +end + + +return ColorPickerDialog diff --git a/resources/amiga/config/lite-xl/libraries/widget/dialog.lua b/resources/amiga/config/lite-xl/libraries/widget/dialog.lua new file mode 100644 index 00000000..4f27b4b7 --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/dialog.lua @@ -0,0 +1,164 @@ +-- +-- Dialog object that serves as base to implement other dialogs. +-- @copyright Jefferson Gonzalez +-- @license MIT +-- + +local core = require "core" +local style = require "core.style" +local Widget = require "libraries.widget" +local Button = require "libraries.widget.button" +local Label = require "libraries.widget.label" + +---@class widget.dialog : widget +---@overload fun(title?:string):widget.dialog +---@field protected title widget.label +---@field protected close widget.button +---@field protected panel widget +local Dialog = Widget:extend() + +---Constructor +---@param title string +function Dialog:new(title) + Dialog.super.new(self) + + self.type_name = "widget.dialog" + + self.draggable = true + self.scrollable = false + + -- minimum width and height + self.size.mx = 400 + self.size.my = 150 + + self.title = Label(self, "") + self.close = Button(self, "") + self.close:set_icon("X") + self.close.border.width = 0 + self.close:toggle_background(false) + self.close.padding.x = 4 + self.close.padding.y = 0 + self.panel = Widget(self) + self.panel.border.width = 0 + self.panel.scrollable = true + + local this = self + + function self.close:on_click() + this:on_close() + this:hide() + end + + self:set_title(title or "") +end + +---Returns the widget where you can add child widgets to this dialog. +---@return widget +function Dialog:get_panel() + return self.panel +end + +---Change the dialog title. +---@param text string|widget.styledtext +function Dialog:set_title(text) + self.title:set_label(text) +end + +---Calculate the dialog size, centers it relative to screen and shows it. +function Dialog:show() + Dialog.super.show(self) + self:update() + self:centered() +end + +---Called when the user clicks the close button of the dialog. +function Dialog:on_close() + self:hide() +end + +function Dialog:update() + if not Dialog.super.update(self) then return false end + + local width = math.max( + self.title:get_width() + (style.padding.x * 3) + self.close:get_width(), + self.size.mx, + self.size.x + ) + + local height = math.max( + self.title:get_height() + (style.padding.y * 3), + self.size.my, + self.size.y + ) + + self:set_size(width, height) + + self.title:set_position( + style.padding.x / 2, + style.padding.y / 2 + ) + + self.close:set_position( + self.size.x - self.close.size.x - (style.padding.x / 2), + style.padding.y / 2 + ) + + self.panel:set_position( + 0, + self.title:get_bottom() + (style.padding.y / 2) + ) + + self.panel:set_size( + self.size.x, + self.size.y - self.title.size.y - style.padding.y + ) + + return true +end + +---We overwrite default draw function to draw the title background. +function Dialog:draw() + if not self:is_visible() then return false end + + Dialog.super.draw(self) + + self:draw_border() + + if self.background_color then + self:draw_background(self.background_color) + else + self:draw_background( + self.parent and style.background or style.background2 + ) + end + + if #self.childs > 0 then + core.push_clip_rect( + self.position.x, + self.position.y, + self.size.x, + self.size.y + ) + end + + -- draw the title background + renderer.draw_rect( + self.position.x, + self.position.y, + self.size.x, self.title:get_height() + style.padding.y, + style.selection + ) + + for i=#self.childs, 1, -1 do + self.childs[i]:draw() + end + + if #self.childs > 0 then + core.pop_clip_rect() + end + + return true +end + + +return Dialog diff --git a/resources/amiga/config/lite-xl/libraries/widget/examples/floating.lua b/resources/amiga/config/lite-xl/libraries/widget/examples/floating.lua new file mode 100644 index 00000000..ae25cfce --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/examples/floating.lua @@ -0,0 +1,102 @@ +-- +-- Basic floating example. +-- + +local core = require "core" +local Widget = require "libraries.widget" +local Button = require "libraries.widget.button" +local CheckBox = require "libraries.widget.checkbox" +local Line = require "libraries.widget.line" +local Label = require "libraries.widget.label" +local TextBox = require "libraries.widget.textbox" + +local function on_button_click(self) + system.show_fatal_error("Clicked:", self.label) +end + +---@type widget +local widget = Widget() +widget.size.x = 300 +widget.size.y = 300 +widget.position.x = 100 +widget.draggable = true +widget.scrollable = true + +---@type widget.button +local button = Button(widget, "Button1") +button:set_position(10, 10) +button:set_tooltip("Description 1") +button.on_click = on_button_click + +---@type widget.button +local button2 = Button(widget, "Button2") +button2:set_position(10, button:get_bottom() + 10) +button2:set_tooltip("Description 2") + +---@type widget.button +local button3 = Button(widget, "Button2") +button3:set_position(button:get_right() + 10, 10) +button3:set_tooltip("Description 2") +button3.on_click = on_button_click + +---@type widget.button +local button23 = Button(widget, "Button23") +button23:set_position(button:get_right() / 2, 10) +button23:set_tooltip("Description 22") +button23.on_click = on_button_click + +---@type widget.checkbox +local checkbox = CheckBox(widget, "Some Checkbox") +checkbox:set_position(10, button2:get_bottom() + 10) +checkbox:set_tooltip("Description checkbox") +checkbox.on_checked = function(_, checked) + core.log_quiet(tostring(checked)) +end + +---@type widget.label +local label = Label(widget, "Label:") +label:set_position(10, checkbox:get_bottom() + 10) + +---@type widget.textbox +local textbox = TextBox(widget, "", "enter text...") +textbox:set_position(10, label:get_bottom() + 10) +textbox:set_tooltip("Texbox") + +---@type widget.button +local button4 = Button(widget, "Button4") +button4:set_position(10, textbox:get_bottom() + 10) +button4:set_tooltip("Description 4") +button4.on_click = on_button_click + +local button5 = Button(widget, "Button5") +button5:set_position(10, button4:get_bottom() + 10) +button5:set_tooltip("Description 5") +button5.on_click = on_button_click + +local button6 = Button(widget, "Button6") +button6:set_position(10, button5:get_bottom() + 10) +button6:set_tooltip("Description 6") +button6.on_click = on_button_click + +---@type widget.line +local line = Line(widget) +line:set_position(0, button6:get_bottom() + 10) + +-- reposition items on scale changes +widget.update = function(self) + if Widget.update(self) then + button:set_position(10, 10) + button2:set_position(10, button:get_bottom() + 10) + button23:set_position(button:get_right() / 2, 10) + button3:set_position(button:get_right() + 10, 10) + checkbox:set_position(10, button2:get_bottom() + 10) + label:set_position(10, checkbox:get_bottom() + 10) + textbox:set_position(10, label:get_bottom() + 10) + button4:set_position(10, textbox:get_bottom() + 10) + button5:set_position(10, button4:get_bottom() + 10) + button6:set_position(10, button5:get_bottom() + 10) + line:set_position(0, button6:get_bottom() + 10) + end +end + +widget:show() diff --git a/resources/amiga/config/lite-xl/libraries/widget/examples/listbox.lua b/resources/amiga/config/lite-xl/libraries/widget/examples/listbox.lua new file mode 100644 index 00000000..0b6c8800 --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/examples/listbox.lua @@ -0,0 +1,53 @@ +-- +-- Basic listbox example. +-- + +local style = require "core.style" +local command = require "core.command" +local Widget = require "libraries.widget" +local ListBox = require "libraries.widget.listbox" + +---@type widget +local widget = Widget() +widget.size.x = 400 +widget.size.y = 150 +widget.position.x = 100 +widget.draggable = true +widget.scrollable = false + +widget:centered() + +---@type widget.listbox +local listbox = ListBox(widget) +listbox.size.y = widget.size.y - widget.border.width*2 +listbox:centered() + +listbox:add_row({ + style.icon_font, style.syntax.string, "!", style.font, style.text, " Error, ", + ListBox.COLEND, + "A message." +}) +for i=1, 10000 do + listbox:add_row({ + tostring(i) .. ". Good ", + ListBox.COLEND, + "Hi!." + }) +end +listbox:add_row({ + "More ", + ListBox.COLEND, + "Final message." +}) + +listbox.on_row_click = function(self, idx, data) + system.show_fatal_error("Clicked a row", idx) +end + +widget:show() + +command.add(nil,{ + ["listbox-widget:toggle"] = function() + widget:toggle_visible() + end +}) diff --git a/resources/amiga/config/lite-xl/libraries/widget/examples/messagebox.lua b/resources/amiga/config/lite-xl/libraries/widget/examples/messagebox.lua new file mode 100644 index 00000000..be37dd35 --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/examples/messagebox.lua @@ -0,0 +1,51 @@ +-- +-- MessageBox example. +-- + +local MessageBox = require "libraries.widget.messagebox" + +---@type widget.messagebox +local messagebox = MessageBox( + nil, + "Multiline", + { + "Some multiline message\nTo see how it works." + } +) +messagebox.size.x = 250 +messagebox.size.y = 300 + +messagebox:add_button("Ok") +messagebox:add_button("Cancel") + +messagebox:show() + +MessageBox.info( + "Feeling", + "Are you feeling well?", + function(self, button_id, button) + if button_id == 3 then + MessageBox.error( + "Error", {"No response was received!\nNo points for you!"} + ) + elseif button_id == 2 then + MessageBox.warning( + "Warning", + "We have to do something about that!", + nil, + MessageBox.BUTTONS_YES_NO + ) + elseif button_id == 1 then + MessageBox.info( + "Info", + "We are two now :)" + ) + end + end, + MessageBox.BUTTONS_YES_NO_CANCEL +) + +function messagebox:on_close(button_id, button) + MessageBox.on_close(self, button_id, button) + self:destroy() +end diff --git a/resources/amiga/config/lite-xl/libraries/widget/examples/notebook.lua b/resources/amiga/config/lite-xl/libraries/widget/examples/notebook.lua new file mode 100644 index 00000000..59e0b0bb --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/examples/notebook.lua @@ -0,0 +1,134 @@ +-- +-- NoteBook example. +-- + +local core = require "core" +local keymap = require "core.keymap" +local command = require "core.command" +local style = require "core.style" +local NoteBook = require "libraries.widget.notebook" +local Button = require "libraries.widget.button" +local TextBox = require "libraries.widget.textbox" +local NumberBox = require "libraries.widget.numberbox" +local Toggle = require "libraries.widget.toggle" +local ProgressBar = require "libraries.widget.progressbar" +local CheckBox = require "libraries.widget.checkbox" +local ListBox = require "libraries.widget.listbox" + +---@type widget.notebook +local notebook = NoteBook() +notebook.size.x = 250 +notebook.size.y = 300 +notebook.border.width = 0 + +local log = notebook:add_pane("log", "Messages") +local build = notebook:add_pane("build", "Build") +local errors = notebook:add_pane("errors", "Errors") +local diagnostics = notebook:add_pane("diagnostics", "Diagnostics") + +notebook:set_pane_icon("log", "i") +notebook:set_pane_icon("build", "P") +notebook:set_pane_icon("errors", "!") + +---@type widget.textbox +local textbox = TextBox(log, "", "placeholder...") +textbox:set_position(10, 20) + +---@type widget.numberbox +local numberbox = NumberBox(log, 10) +numberbox:set_position(10, textbox:get_bottom() + 20) + +---@type widget.toggle +local toggle = Toggle(log, "The Toggle:", true) +toggle:set_position(10, numberbox:get_bottom() + 20) + +---@type widget.progressbar +local progress = ProgressBar(log, 33) +progress:set_position(textbox:get_right() + 50, 20) + +---@type widget.checkbox +local checkbox = CheckBox(build, "Child checkbox") +checkbox:set_position(10, 20) + +---@type widget.button +local button = Button(errors, "A test button") +button:set_position(10, 20) +button.on_click = function() + system.show_fatal_error("Message", "Hello World") +end + +---@type widget.checkbox +local checkbox2 = CheckBox(errors, "Child checkbox2") +checkbox2:set_position(10, button:get_bottom() + 30) + +---@type widget.listbox +diagnostics.scrollable = false + +local listbox = ListBox(diagnostics) +listbox.border.width = 0 +listbox:enable_expand(true) + +listbox:add_column("Severity") +listbox:add_column("Message") + +listbox:add_row({ + style.icon_font, style.syntax.string, "!", style.font, style.text, " Error", + ListBox.COLEND, + "A message to display to the user." +}) +listbox:add_row({ + style.icon_font, style.syntax.string, "!", style.font, style.text, " Error", + ListBox.COLEND, + "Another message to display to the user\nwith new line characters\nfor the win." +}) + +core.add_thread(function() + for num=1, 1000 do + listbox:add_row({ + style.icon_font, style.syntax.string, "!", style.font, style.text, " Error", + ListBox.COLEND, + tostring(num), + " Another message to display to the user\nwith new line characters\nfor the win." + }, num) + if num % 100 == 0 then + coroutine.yield() + end + end + listbox:add_row({ + style.icon_font, style.syntax.string, "!", style.font, style.text, " Error", + ListBox.COLEND, + "Final message to display." + }) +end) + +listbox.on_row_click = function(self, idx, data) + if data then + system.show_fatal_error("Row Data", data) + end + self:remove_row(idx) +end + +-- defer draw needs to be set to false when adding widget as a lite-xl node +notebook.border.width = 0 +notebook.draggable = false +notebook.defer_draw = false + +local inside_node = false + +-- You can add the widget as a lite-xl node +command.add(nil,{ + ["notebook-widget:toggle"] = function() + if inside_node then + notebook:toggle_visible() + else + local node = core.root_view:get_primary_node() + node:split("down", notebook, {y=true}, true) + notebook:show() + inside_node = true + end + end +}) + +keymap.add { + ["alt+shift+m"] = "notebook-widget:toggle", +} diff --git a/resources/amiga/config/lite-xl/libraries/widget/examples/search.lua b/resources/amiga/config/lite-xl/libraries/widget/examples/search.lua new file mode 100644 index 00000000..fec0c960 --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/examples/search.lua @@ -0,0 +1,129 @@ +-- +-- A basic search layout example. +-- + +local core = require "core" +local command = require "core.command" +local Widget = require "libraries.widget" +local Button = require "libraries.widget.button" +local CheckBox = require "libraries.widget.checkbox" +local Line = require "libraries.widget.line" +local Label = require "libraries.widget.label" +local TextBox = require "libraries.widget.textbox" +local MessageBox = require "libraries.widget.messagebox" +local SelectBox = require "libraries.widget.selectbox" + +local function on_button_click(self) + MessageBox.info("Clicked:", self.label) +end + +---@type widget +local widget = Widget() +widget.name = "Search and Replace" +widget.size.x = 300 +widget.size.y = 300 +widget.position.x = 100 +widget.draggable = true +widget.scrollable = true + +---@type widget.label +local label = Label(widget, "Find and Replace") +label:set_position(10, 10) + +---@type widget.line +local line = Line(widget) +line:set_position(0, label:get_bottom() + 10) + +---@type widget.textbox +local findtext = TextBox(widget, "", "search...") +findtext:set_position(10, line:get_bottom() + 10) +findtext:set_tooltip("Text to search") + +---@type widget.textbox +local replacetext = TextBox(widget, "", "replace...") +replacetext:set_position(10, findtext:get_bottom() + 10) +replacetext:set_tooltip("Text to replace") + +---@type widget.button +local findprev = Button(widget, "Find Prev") +findprev:set_position(10, replacetext:get_bottom() + 10) +findprev:set_tooltip("Find backwards") +findprev.on_click = on_button_click + +---@type widget.button +local findnext = Button(widget, "Find Next") +findnext:set_position(findprev:get_right() + 5, replacetext:get_bottom() + 10) +findnext:set_tooltip("Find forward") +findnext.on_click = on_button_click + +---@type widget.button +local replace = Button(widget, "Replace All") +replace:set_position(10, findnext:get_bottom() + 10) +replace:set_tooltip("Replace all matching results") +replace.on_click = on_button_click + +---@type widget.line +local line_options = Line(widget) +line_options:set_position(0, replace:get_bottom() + 10) + +---@type widget.checkbox +local insensitive = CheckBox(widget, "Insensitive") +insensitive:set_position(10, line_options:get_bottom() + 10) +insensitive:set_tooltip("Case insensitive search") +insensitive.on_checked = function(_, checked) + core.log_quiet(tostring(checked)) +end + +---@type widget.checkbox +local regex = CheckBox(widget, "Regex") +regex:set_position(10, insensitive:get_bottom() + 10) +regex:set_tooltip("Treat search text as a regular expression") +regex.on_checked = function(_, checked) + core.log_quiet(tostring(checked)) +end + +---@type widget.selectbox +local scope = SelectBox(widget, "scope") +scope:set_position(10, regex:get_bottom() + 10) +scope:add_option("current file") +scope:add_option("project files") +scope:add_option("some really long option to see") +scope:add_option("other item") +scope:add_option("other option") + +-- reposition items on scale changes +widget.update = function(self) + if Widget.update(self) then + label:set_position(10, 10) + line:set_position(0, label:get_bottom() + 10) + findtext:set_position(10, line:get_bottom() + 10) + findtext.size.x = self.size.x - 20 + replacetext:set_position(10, findtext:get_bottom() + 10) + replacetext.size.x = self.size.x - 20 + findprev:set_position(10, replacetext:get_bottom() + 10) + findnext:set_position(findprev:get_right() + 5, replacetext:get_bottom() + 10) + replace:set_position(10, findnext:get_bottom() + 10) + line_options:set_position(0, replace:get_bottom() + 10) + insensitive:set_position(10, line_options:get_bottom() + 10) + regex:set_position(10, insensitive:get_bottom() + 10) + scope:set_position(10, regex:get_bottom() + 10) + scope.size.x = self.size.x - 20 + end +end + +widget:show() + +-- You can add the widget as a lite-xl node +widget.border.width = 0 +widget.draggable = false +widget.defer_draw = false +widget.target_size = 250 + +local node = core.root_view:get_primary_node() +node:split("right", widget, {x=true}, true) + +command.add(nil,{ + ["find-widget:toggle"] = function() + widget:toggle_visible() + end +}) diff --git a/resources/amiga/config/lite-xl/libraries/widget/filepicker.lua b/resources/amiga/config/lite-xl/libraries/widget/filepicker.lua new file mode 100644 index 00000000..c37af811 --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/filepicker.lua @@ -0,0 +1,395 @@ +local core = require "core" +local common = require "core.common" +local style = require "core.style" +local Widget = require "libraries.widget" +local Button = require "libraries.widget.button" +local Label = require "libraries.widget.label" + +---@class widget.filepicker : widget +---@overload fun(parent?:widget, path?:string):widget.filepicker +---@field public pick_mode integer +---@field public filters table +---@field private path string +---@field private file widget.label +---@field private textbox widget.textbox +---@field private button widget.button +local FilePicker = Widget:extend() + +---Operation modes for the file picker. +---@type table +FilePicker.mode = { + ---Opens file browser the selected file does not has to exist. + FILE = 1, + ---Opens file browser the selected file has to exist. + FILE_EXISTS = 2, + ---Opens directory browser the selected directory does not has to exist. + DIRECTORY = 4, + ---Opens directory browser the selected directory has to exist. + DIRECTORY_EXISTS = 8 +} + +---@param text string +local function suggest_directory(text) + text = common.home_expand(text) + return common.home_encode_list(common.dir_path_suggest(text)) +end + +---@param path string +local function check_directory_path(path) + local abs_path = system.absolute_path(path) + local info = abs_path and system.get_file_info(abs_path) + if not info or info.type ~= 'dir' then return nil end + return abs_path +end + +---@param str string +---@param find string +---@param replace string +local function str_replace(str, find, replace) + local start, ending = str:find(find, 1, true) + if start == 1 then + return replace .. str:sub(ending + 1) + else + return str:sub(1, start - 1) .. replace .. str:sub(ending + 1) + end +end + +---@alias widget.filepicker.modes +---| `FilePicker.mode.FILE` +---| `FilePicker.mode.FILE_EXISTS` +---| `FilePicker.mode.DIRECTORY` +---| `FilePicker.mode.DIRECTORY_EXISTS` + +---Constructor +---@param parent widget +---@param path? string +function FilePicker:new(parent, path) + FilePicker.super.new(self, parent) + + local this = self + + self.type_name = "widget.filepicker" + + self.filters = {} + self.border.width = 0 + self.pick_mode = FilePicker.mode.FILE + + self.file = Label(self, "") + self.file.clickable = true + self.file:set_border_width(1) + function self.file:on_click(button) + if button == "left" then + this:show_picker() + end + end + function self.file:on_mouse_enter(...) + Label.super.on_mouse_enter(self, ...) + self.border.color = style.caret + end + function self.file:on_mouse_leave(...) + Label.super.on_mouse_leave(self, ...) + self.border.color = style.text + end + + self.button = Button(self, "") + self.button:set_icon("D") + self.button:set_tooltip("open file browser") + function self.button:on_click(button) + if button == "left" then + this:show_picker() + end + end + + local label_width = self.file:get_width() + if label_width <= 10 then + label_width = 200 + (self.file.border.width * 2) + self.file:set_size(200, self.button:get_height() - self.button.border.width * 2) + end + + self:set_size( + label_width + self.button:get_width(), + math.max(self.file:get_height(), self.button:get_height()) + ) + + self:set_path(path) +end + +---Set the filepicker size +---@param width? number +---@param height? number +function FilePicker:set_size(width, height) + FilePicker.super.set_size(self, width, height) + + self.file:set_position(0, 0) + self.file:set_size( + self:get_width() - self.button:get_width(), + self.button:get_height() + ) + + self.button:set_position(self.file:get_right(), 0) + + self.size.y = math.max( + self.file:get_height(), + self.button:get_height() + -- something is off on calculation since adding border width should not + -- be needed to display whole rendered control at all... + ) + self.button.border.width +end + +---Add a lua pattern to the filters list +---@param pattern string +function FilePicker:add_filter(pattern) + table.insert(self.filters, pattern) +end + +---Clear the filters list +function FilePicker:clear_filters() + self.filters = {} +end + +---Set the operation mode for the file picker. +---@param mode widget.filepicker.modes | string | integer +function FilePicker:set_mode(mode) + if type(mode) == "string" then + ---@type integer + local intmode = FilePicker.mode[mode:upper()] + self.pick_mode = intmode + else + self.pick_mode = mode + end +end + +---Set the full path including directory and filename. +---@param path? string +function FilePicker:set_path(path) + if path then + self.path = path or "" + if common.path_belongs_to(path, core.project_dir) then + self.file.label = path ~= "" and + common.relative_path(core.project_dir, path) + or + "" + else + self.file.label = path + end + else + self.path = "" + self.file.label = "" + end +end + +---Get the full path including directory and filename. +---@return string | nil +function FilePicker:get_path() + if self.path ~= "" then + return self.path + end + return nil +end + +---Get the full path relative to current project dir or absolute if it doesn't +---belongs to the current project directory. +---@return string +function FilePicker:get_relative_path() + if + self.path ~= "" + and + common.path_belongs_to(self.path, core.project_dir) + then + return common.relative_path(core.project_dir, self.path) + end + return self.path or "" +end + +---Set the filename part only. +---@param name string +function FilePicker:set_filename(name) + local dir_part = common.dirname(self.path) + if dir_part then + self:set_path(dir_part .. PATHSEP .. name) + else + self:set_path(name) + end +end + +---Get the filename part only. +---@return string | nil +function FilePicker:get_filename() + local dir_part = common.dirname(self.path) + if dir_part then + local filename = str_replace(self.path, dir_part .. PATHSEP, "") + return filename + elseif self.path ~= "" then + return self.path + end + return nil +end + +---Set the directory part only. +---@param dir string +function FilePicker:set_directory(dir) + local filename = self:get_filename() + if filename then + self:set_path(dir:gsub("[\\/]$", "") .. PATHSEP .. filename) + else + self:set_path(dir:gsub("[\\/]$", "")) + end +end + +---Get the directory part only. +---@return string | nil +function FilePicker:get_directory() + if self.path ~= "" then + local dir_part = common.dirname(self.path) + if dir_part then return dir_part end + end + return nil +end + +---Filter a list of directories by applying currently set filters. +---@param self widget.filepicker +---@param list table +---@return table +local function filter(self, list) + if #self.filters > 0 then + local new_list = {} + for _, value in ipairs(list) do + if common.match_pattern(value, self.filters) then + table.insert(new_list, value) + elseif + self.pick_mode == FilePicker.mode.FILE + or + self.pick_mode == FilePicker.mode.FILE_EXISTS + then + local path = common.home_expand(value) + local abs_path = check_directory_path(path) + if abs_path then + table.insert(new_list, value) + end + end + end + return new_list + end + return list +end + +---@param self widget.filepicker +local function show_file_picker(self) + core.command_view:enter("Choose File", { + text = self:get_relative_path(), + submit = function(text) + ---@type string + local filename = text + local dirname = common.dirname(common.home_expand(text)) + if dirname then + filename = common.home_expand(text) + filename = system.absolute_path(dirname) + .. PATHSEP + .. str_replace(filename, dirname .. PATHSEP, "") + elseif filename ~= "" then + filename = core.project_dir .. PATHSEP .. filename + end + self:set_path(filename) + self:on_change(filename ~= "" and filename or nil) + end, + suggest = function (text) + return filter( + self, + common.home_encode_list(common.path_suggest(common.home_expand(text))) + ) + end, + validate = function(text) + if #self.filters > 0 and text ~= "" and not common.match_pattern(text, self.filters) then + core.error( + "File does not match the filters: %s", + table.concat(self.filters, ", ") + ) + return false + end + local filename = common.home_expand(text) + local path_stat, err = system.get_file_info(filename) + if path_stat and path_stat.type == 'dir' then + core.error("Cannot open %s, is a folder", text) + return false + end + if self.pick_mode == FilePicker.mode.FILE_EXISTS then + if not path_stat then + core.error("Cannot open file %s: %s", text, err) + return false + end + else + local dirname = common.dirname(filename) + local dir_stat = dirname and system.get_file_info(dirname) + if dirname and not dir_stat then + core.error("Directory does not exists: %s", dirname) + return false + end + end + return true + end, + }) +end + +---@param self widget.filepicker +local function show_dir_picker(self) + core.command_view:enter("Choose Directory", { + text = self:get_relative_path(), + submit = function(text) + local path = common.home_expand(text) + local abs_path = check_directory_path(path) + self:set_path(abs_path or text) + self:on_change(abs_path or (text ~= "" and text or nil)) + end, + suggest = function(text) + return filter(self, suggest_directory(text)) + end, + validate = function(text) + if #self.filters > 0 and text ~= "" and not common.match_pattern(text, self.filters) then + core.error( + "Directory does not match the filters: %s", + table.concat(self.filters, ", ") + ) + return false + end + if self.pick_mode == FilePicker.mode.DIRECTORY_EXISTS then + local path = common.home_expand(text) + local abs_path = check_directory_path(path) + if not abs_path then + core.error("Cannot open directory %q", path) + return false + end + end + return true + end + }) +end + +---Show the command view file or directory browser depending on the +---current file picker mode. +function FilePicker:show_picker() + if + self.pick_mode == FilePicker.mode.FILE + or + self.pick_mode == FilePicker.mode.FILE_EXISTS + then + show_file_picker(self) + else + show_dir_picker(self) + end +end + +function FilePicker:update() + if not FilePicker.super.update(self) then return false end + + if self:get_width() ~= (self.file:get_width() + self.button:get_width()) then + self:set_size( + self.file:get_width() + self.button:get_width(), + self.button:get_height() + ) + end + + return true +end + + +return FilePicker diff --git a/resources/amiga/config/lite-xl/libraries/widget/foldingbook.lua b/resources/amiga/config/lite-xl/libraries/widget/foldingbook.lua new file mode 100644 index 00000000..1b456a3c --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/foldingbook.lua @@ -0,0 +1,216 @@ +-- +-- FoldingBook Widget. +-- @copyright Jefferson Gonzalez +-- @license MIT +-- + +local style = require "core.style" +local Widget = require "libraries.widget" +local Button = require "libraries.widget.button" + +---Represents a foldingbook pane +---@class widget.foldingbook.pane +---@field public name string +---@field public tab widget.button +---@field public container widget +---@field public expanded boolean +local FoldingBookPane = {} + +---@class widget.foldingbook : widget +---@overload fun(parent:widget?):widget.foldingbook +---@field public panes widget.foldingbook.pane[] +local FoldingBook = Widget:extend() + +---FoldingBook constructor +---@param parent widget +function FoldingBook:new(parent) + FoldingBook.super.new(self, parent) + self.type_name = "widget.foldingbook" + self.panes = {} + self.scrollable = true +end + +---@param pane widget.foldingbook.pane +function FoldingBook:on_tab_click(pane) + pane.expanded = not pane.expanded +end + +---Adds a new pane to the foldingbook and returns a container widget where +---you can add more child elements. +---@param name string +---@param label string +---@return widget container +function FoldingBook:add_pane(name, label) + ---@type widget.button + local tab = Button(self, label) + tab.border.width = 0 + tab:toggle_expand(true) + tab:set_icon("+") + + if #self.panes > 0 then + if self.panes[#self.panes].expanded then + tab:set_position(0, self.panes[#self.panes].container:get_bottom() + 2) + else + tab:set_position(0, self.panes[#self.panes].tab:get_bottom() + 2) + end + else + tab:set_position(0, 10) + end + + local container = Widget(self) + container:set_position(0, tab:get_bottom() + 4) + container:set_size(self:get_width(), 0) + + local pane = { + name = name, + tab = tab, + container = container, + expanded = false + } + + tab.on_click = function() + self:on_tab_click(pane) + end + + table.insert(self.panes, pane) + + return container +end + +---@param name string +---@return widget.foldingbook.pane | nil +function FoldingBook:get_pane(name) + for _, pane in pairs(self.panes) do + if pane.name == name then + return pane + end + end + return nil +end + +---Delete a pane and all its childs from the folding book. +---@param name string +---@return boolean deleted +function FoldingBook:delete_pane(name) + for idx, pane in ipairs(self.panes) do + if pane.name == name then + self:remove_child(pane.tab) + self:remove_child(pane.container) + table.remove(self.panes, idx) + return true + end + end + return false +end + +---Activates the given pane +---@param name string +---@param visible boolean | nil +function FoldingBook:toggle_pane(name, visible) + local pane = self:get_pane(name) + if pane then + if type(visible) == "boolean" then + pane.expanded = visible + else + pane.expanded = not pane.expanded + end + end +end + +---Change the tab label of the given pane. +---@param name string +---@param label string +function FoldingBook:set_pane_label(name, label) + local pane = self:get_pane(name) + if pane then + pane.tab:set_label(label) + return true + end + return false +end + +---Set or remove the icon for the given pane. +---@param name string +---@param icon string +---@param color? renderer.color|nil +---@param hover_color? renderer.color|nil +function FoldingBook:set_pane_icon(name, icon, color, hover_color) + local pane = self:get_pane(name) + if pane then + pane.tab:set_icon(icon, color, hover_color) + return true + end + return false +end + +---Recalculate the position of the elements on resizing or position changes. +function FoldingBook:update() + if not FoldingBook.super.update(self) then return false end + + ---@type widget.foldingbook.pane + local prev_pane = nil + + for _, pane in ipairs(self.panes) do + local tx, ty = 0, 10 + local cx, cy = 0, 0 + local cw, ch = 0, 0 + + if prev_pane then + if prev_pane and prev_pane.container:is_visible() then + ty = prev_pane.container:get_bottom() + 2 + else + ty = prev_pane.tab:get_bottom() + 2 + end + end + + pane.tab:set_position(tx, ty) + + cy = pane.tab:get_bottom() + 4 + cw = self:get_width() + if #pane.container.childs > 0 then + ch = pane.container:get_real_height() + 10 + end + + pane.container.border.color = style.divider + + if pane.expanded and not pane.container.hiding then + pane.container:set_position(cx, cy) + pane.container:set_size(cw) + if not pane.container.visible then + pane.container:set_size(cw, ch) + pane.container:show_animated(true) + pane.tab:set_icon("-") + pane.container.hiding = false + end + elseif pane.container.visible and not pane.container.hiding then + pane.tab:set_icon("+") + pane.container.hiding = true + pane.container:hide_animated(true, false, { + on_complete = function() + pane.container.hiding = false + end + }) + end + + prev_pane = pane + end + + return true +end + +---Here we draw the bottom line on each tab. +function FoldingBook:draw() + if not FoldingBook.super.draw(self) then return false end + + for _, pane in ipairs(self.panes) do + local x = pane.tab.position.x + local y = pane.tab.position.y + pane.tab:get_height() + local w = self:get_width() + renderer.draw_rect(x, y, w, 2, style.selection) + end + + return true +end + + +return FoldingBook diff --git a/resources/amiga/config/lite-xl/libraries/widget/fontdialog.lua b/resources/amiga/config/lite-xl/libraries/widget/fontdialog.lua new file mode 100644 index 00000000..b4bae986 --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/fontdialog.lua @@ -0,0 +1,338 @@ +-- +-- Font Dialog Widget. +-- @copyright Jefferson Gonzalez +-- @license MIT +-- + +local core = require "core" +local style = require "core.style" +local Button = require "libraries.widget.button" +local CheckBox = require "libraries.widget.checkbox" +local NumberBox = require "libraries.widget.numberbox" +local Dialog = require "libraries.widget.dialog" +local Label = require "libraries.widget.label" +local Line = require "libraries.widget.line" +local SelectBox = require "libraries.widget.selectbox" +local MessageBox = require "libraries.widget.messagebox" +local Fonts = require "libraries.widget.fonts" + +---@class widget.fontdialog.fontoptions : renderer.fontoptions +---@field size number + +---@class widget.fontdialog : widget.dialog +---@overload fun(font?:widget.fontslist.font, options?:widget.fontdialog.fontoptions):widget.fontdialog +---@field super widget.dialog +---@field fontdata widget.fontslist.font +---@field preview widget.label +---@field font_size widget.numberbox +---@field choose widget.button +---@field choose_mono widget.button +---@field line widget.line +---@field antialiasing widget.selectbox +---@field hinting widget.selectbox +---@field bold widget.checkbox +---@field italic widget.checkbox +---@field underline widget.checkbox +---@field smoothing widget.checkbox +---@field strikethrough widget.checkbox +---@field save widget.button +---@field cancel widget.button +local FontDialog = Dialog:extend() + +---Constructor +---@param font? widget.fontslist.font +---@param options? widget.fontdialog.fontoptions +function FontDialog:new(font, options) + FontDialog.super.new(self, "Font Selector") + + self.selected = nil + + local this = self + + self.type_name = "widget.fontdialog" + + self.preview = Label(self.panel, "No Font Selected") + self.preview.border.width = 1 + self.preview.clickable = true + self.preview:set_size(100, 100) + function self.preview:on_mouse_enter(...) + Label.super.on_mouse_enter(self, ...) + self.border.color = style.caret + end + function self.preview:on_mouse_leave(...) + Label.super.on_mouse_leave(self, ...) + self.border.color = style.text + end + + self.font_size = NumberBox(self.panel, 15, 5) + function self.font_size:on_change() + this:update_preview() + end + + self.choose = Button(self.panel, "All") + self.choose:set_icon("D") + self.choose:set_tooltip("Choose a Font") + function self.choose:on_click() + Fonts.show_picker(function(name, path) + local fontdata = {name = name, path = path} + this:set_font(fontdata) + this:update_preview() + end, false) + core.status_view:remove_tooltip() + end + + self.choose_mono = Button(self.panel, "Mono") + self.choose_mono:set_icon("D") + self.choose_mono:set_tooltip("Choose a Monospace Font") + function self.choose_mono:on_click() + Fonts.show_picker(function(name, path) + local fontdata = {name = name, path = path} + this:set_font(fontdata) + this:update_preview() + end, true) + core.status_view:remove_tooltip() + end + + self.line = Line(self.panel) + + self.antialiasing = SelectBox(self.panel, "antialiasing") + self.antialiasing:add_option("None", "none") + self.antialiasing:add_option("Grayscale", "grayscale") + self.antialiasing:add_option("Subpixel", "subpixel") + function self.antialiasing:on_selected() + this:update_preview() + end + + self.hinting = SelectBox(self.panel, "hinting") + self.hinting:add_option("None", "none") + self.hinting:add_option("Slight", "slight") + self.hinting:add_option("full", "full") + function self.hinting:on_selected() + this:update_preview() + end + + self.bold = CheckBox(self.panel, "Bold") + function self.bold:on_checked() + this:update_preview() + end + self.italic = CheckBox(self.panel, "Italic") + function self.italic:on_checked() + this:update_preview() + end + self.underline = CheckBox(self.panel, "Underline") + function self.underline:on_checked() + this:update_preview() + end + self.smoothing = CheckBox(self.panel, "Smooth") + function self.smoothing:on_checked() + this:update_preview() + end + self.strikethrough = CheckBox(self.panel, "Strike") + function self.strikethrough:on_checked() + this:update_preview() + end + + self.save = Button(self.panel, "Save") + self.save:set_icon("S") + function self.save:on_click() + if this.fontdata and this.fontdata.name then + this:on_save(this:get_font(), this:get_options()) + this:on_close() + else + MessageBox.error("No font selected", "Please select a font") + end + end + + self.cancel = Button(self.panel, "Cancel") + self.cancel:set_icon("C") + function self.cancel:on_click() + this:on_close() + end + + if font then + self:set_font(font) + if not options then + self:update_preview() + end + end + if options then + self:set_options(options) + end +end + +function FontDialog:update_preview() + local options = self:get_options() + + if self.fontdata and self.fontdata.path then + self.preview.font = renderer.font.load( + self.fontdata.path, options.size * SCALE, options + ) + local fontmeta = renderer.font.get_metadata and renderer.font.get_metadata(self.fontdata.path) or {} + local preview = fontmeta.sampletext or self.fontdata.name + if preview then + self.preview:set_label(preview) + end + else + self.preview.font = renderer.font.load( + DATADIR .. "/fonts/FiraSans-Regular.ttf", + options.size * SCALE, + options + ) + end + + collectgarbage "step" +end + +---@param font widget.fontslist.font +function FontDialog:set_font(font) + self.fontdata = font + if self.fontdata.name then + self.preview:set_label(self.fontdata.name) + end +end + +---@return widget.fontslist.font +function FontDialog:get_font() + return self.fontdata +end + +---@param options widget.fontdialog.fontoptions +function FontDialog:set_options(options) + if options.size then + self.font_size:set_value(tonumber(options.size) or 15) + end + + if options.antialiasing then + if options.antialiasing == "none" then + self.antialiasing:set_selected(1) + elseif options.antialiasing == "grayscale" then + self.antialiasing:set_selected(2) + elseif options.antialiasing == "subpixel" then + self.antialiasing:set_selected(3) + end + end + + if options.hinting then + if options.hinting == "none" then + self.hinting:set_selected(1) + elseif options.hinting == "slight"then + self.hinting:set_selected(2) + elseif options.hinting == "full" then + self.hinting:set_selected(3) + end + end + + if options.bold ~= nil then + self.bold:set_checked(options.bold) + end + if options.italic ~= nil then + self.italic:set_checked(options.italic) + end + if options.underline ~= nil then + self.underline:set_checked(options.underline) + end + if options.smoothing ~= nil then + self.smoothing:set_checked(options.smoothing) + end + if options.strikethrough ~= nil then + self.strikethrough:set_checked(options.strikethrough) + end +end + +---@return widget.fontdialog.fontoptions +function FontDialog:get_options() + return { + size = self.font_size:get_value(), + antialiasing = self.antialiasing:get_selected_data() or "none", + hinting = self.hinting:get_selected_data() or "none", + bold = self.bold:is_checked(), + italic = self.italic:is_checked(), + underline = self.underline:is_checked(), + smoothing = self.smoothing:is_checked(), + strikethrough = self.strikethrough:is_checked() + } +end + +---Called when the user clicks on save +---@param font widget.fontslist.font +---@param options widget.fontdialog.fontoptions +function FontDialog:on_save(font, options) end + +function FontDialog:update() + if not FontDialog.super.update(self) then return false end + + self.preview:set_position(style.padding.x/2, style.padding.y/2) + + self.font_size:set_position( + style.padding.x/2, + self.preview:get_bottom() + style.padding.y + ) + + self.choose:set_position( + self.font_size:get_right() + (style.padding.x/2), + self.preview:get_bottom() + style.padding.y + ) + + self.choose_mono:set_position( + self.choose:get_right() + (style.padding.x/2), + self.preview:get_bottom() + style.padding.y + ) + + self.line:set_position( + 0, + self.font_size:get_bottom() + style.padding.y + ) + + self.antialiasing:set_position( + style.padding.x/2, + self.line:get_bottom() + style.padding.y + ) + self.hinting:set_position( + self.antialiasing:get_right() + (style.padding.x/2), + self.line:get_bottom() + style.padding.y + ) + + self.bold:set_position( + style.padding.x/2, + self.hinting:get_bottom() + style.padding.y + ) + self.italic:set_position( + self.bold:get_right() + style.padding.x, + self.hinting:get_bottom() + style.padding.y + ) + self.underline:set_position( + self.italic:get_right() + style.padding.x, + self.hinting:get_bottom() + style.padding.y + ) + self.smoothing:set_position( + self.underline:get_right() + style.padding.x, + self.hinting:get_bottom() + style.padding.y + ) + self.strikethrough:set_position( + self.smoothing:get_right() + style.padding.x, + self.hinting:get_bottom() + style.padding.y + ) + + self.save:set_position( + style.padding.x/2, + self.underline:get_bottom() + style.padding.y + ) + self.cancel:set_position( + self.save:get_right() + style.padding.x, + self.underline:get_bottom() + style.padding.y + ) + + self.panel.size.x = self.panel:get_real_width() + style.padding.x + self.panel.size.y = self.panel:get_real_height() + self.size.x = self:get_real_width() - (style.padding.x / 2) + self.size.y = self:get_real_height() + (style.padding.y / 2) + + self.line:set_width(self.size.x - style.padding.x) + + self.preview:set_size(self.size.x - style.padding.x) + + return true +end + + +return FontDialog diff --git a/resources/amiga/config/lite-xl/libraries/widget/fonts/cache.lua b/resources/amiga/config/lite-xl/libraries/widget/fonts/cache.lua new file mode 100644 index 00000000..2dc6471b --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/fonts/cache.lua @@ -0,0 +1,287 @@ +local core = require "core" +local common = require "core.common" +local Object = require "core.object" +local FontInfo = require "libraries.widget.fonts.info" + +---@class widget.fonts.cache : core.object +---@overload fun():widget.fonts.cache +---@field fontinfo widget.fonts.info +---@field found integer +---@field found_monospaced integer +---@field building boolean +---@field monosppaced boolean +---@field searching_monospaced boolean +---@field fontdirs table +---@field fonts widget.fonts.data[] +local FontCache = Object:extend() + +---Constructor +function FontCache:new() + self.fontinfo = FontInfo() + self.fontdirs = {} + self.fonts = {} + self.loaded_fonts = {} + self.found = 0 + self.found_monospaced = 0 + self.building = false + self.searching_monospaced = false + self.monospaced = false + + table.insert(self.fontdirs, USERDIR .. "/fonts") + table.insert(self.fontdirs, DATADIR .. "/fonts") + + if PLATFORM == "Windows" then + table.insert(self.fontdirs, HOME .. PATHSEP .. "AppData\\Local\\Microsoft\\Windows\\Fonts" ) + table.insert(self.fontdirs, os.getenv("SYSTEMROOT") .. PATHSEP .. "Fonts" ) + elseif PLATFORM == "Mac OS X" then + table.insert(self.fontdirs, HOME .. "/Library/Fonts") + table.insert(self.fontdirs, "/Library/Fonts") + table.insert(self.fontdirs, "/System/Library/Fonts") + else + table.insert(self.fontdirs, HOME .. "/.local/share/fonts") + table.insert(self.fontdirs, HOME .. "/.fonts") + table.insert(self.fontdirs, "/usr/local/share/fonts") + table.insert(self.fontdirs, "/usr/share/fonts") + end + + if not self:load_cache() then + self:build() + elseif not self.monospaced then + self:verify_monospaced() + end +end + +---Check if the cache is already building. +---@return boolean building +function FontCache:is_building() + if self.building or self.searching_monospaced then + return true + end + return false +end + +---Build the font cache and save it. +---@return boolean started False if cache is already been built +function FontCache:build() + if self:is_building() then + core.log_quiet("The font cache is already been generated, please wait.") + return false + end + + self.found = 0 + self.building = true + self.monospaced = false + self.loaded_fonts = {} + + core.log_quiet("Generating font cache...") + local start_time = system.get_time() + + local this = self + core.add_thread(function() + for _, dir in ipairs(this.fontdirs) do + this:scan_dir(dir) + end + this:save_cache() + this.building = false + this.loaded_fonts = {} + core.log_quiet( + "Font cache generated in %.1fs for %s fonts!", + system.get_time() - start_time, tostring(this.found) + ) + self:verify_monospaced() + end) + + return true +end + +---Clear current font cache and rebuild it. +---@return boolean started False if cache is already been built +function FontCache:rebuild() + if self:is_building() then + core.log_quiet("The font cache is already been generated, please wait.") + return false + end + + local fontcache_file = USERDIR .. "/font_cache.lua" + local file = io.open(fontcache_file, "r") + + if file ~= nil then + file:close() + os.remove(fontcache_file) + end + + self.fonts = {} + self.loaded_fonts = {} + self.found = 0 + self.found_monospaced = 0 + + return self:build() +end + +---Scan a directory for valid font files and load them into the cache. +---@param path string +---@param run_count? integer +function FontCache:scan_dir(path, run_count) + run_count = run_count or 1 + local can_yield = coroutine.running() + local list = system.list_dir(path) + if list then + for _, name in pairs(list) do + if name:match("%.[tToO][tT][fFcC]$") and not self.loaded_fonts[name] then + -- prevent loading of duplicate files + self.loaded_fonts[name] = true + local font_path = path .. PATHSEP .. name + local read, errmsg = self.fontinfo:read(font_path) + + if read then + local font_data + font_data, errmsg = self.fontinfo:get_data() + if font_data then + table.insert(self.fonts, font_data) + self.found = self.found + 1 + else + io.stderr:write( + "Error: " .. path .. PATHSEP .. name .. "\n" + .. " " .. errmsg .. "\n" + ) + end + else + io.stderr:write( + "Error: " .. path .. PATHSEP .. name .. "\n" + .. " " .. errmsg .. "\n" + ) + end + if can_yield and run_count % 100 == 0 then + coroutine.yield() + end + else + self:scan_dir(path .. PATHSEP .. name, run_count) + end + run_count = run_count + 1 + end + end +end + +---Search and mark monospaced fonts on currently loaded cache and save it. +function FontCache:verify_monospaced() + if self:is_building() then + core.log_quiet("The monospaced verification is already running, please wait.") + return + end + + self.found_monospaced = 0 + self.searching_monospaced = true + self.monospaced = false + + core.log_quiet("Finding monospaced fonts...") + local start_time = system.get_time() + + local this = self + core.add_thread(function() + for _, font in ipairs(this.fonts) do + if not font.monospace then + FontInfo.check_is_monospace(font) + end + if font.monospace then + this.found_monospaced = this.found_monospaced + 1 + end + coroutine.yield() + end + this.monospaced = true + this:save_cache() + this.searching_monospaced = false + core.log_quiet( + "Found %s monospaced fonts in %.1fs!", + tostring(this.found_monospaced), system.get_time() - start_time + ) + end) +end + +---Load font cache from persistent file for faster startup time. +function FontCache:load_cache() + local ok, t = pcall(dofile, USERDIR .. "/font_cache.lua") + if ok then + self.fonts = t.fonts + self.monospaced = t.monospaced + self.found = t.found + self.found_monospaced = t.found_monospaced + return true + end + return false +end + +---Store current font cache to persistent file. +function FontCache:save_cache() + local fp = io.open(USERDIR .. "/font_cache.lua", "w") + if fp then + local output = "{\n" + .. "found = "..tostring(self.found)..",\n" + .. "found_monospaced = "..tostring(self.found_monospaced)..",\n" + .. "monospaced = "..tostring(self.monospaced)..",\n" + .. "[\"fonts\"] = " + .. common.serialize( + self.fonts, + { pretty = true, escape = true, sort = true, initial_indent = 1 } + ):gsub("^%s+", "") + .. "\n}\n" + fp:write("return ", output) + fp:close() + end +end + +---Search for a font and return the best match. +---@param name string +---@param style? widget.fonts.style +---@param monospaced? boolean +---@return widget.fonts.data? font_data +---@return string? errmsg +function FontCache:search(name, style, monospaced) + if #self.fonts == 0 then + return nil, "the font cache needs to be rebuilt" + end + + style = style or "regular" + name = name:ulower() + style = style:ulower() + + if name == "monospace" then + name = "mono" + monospaced = true + end + + if not self.monospaced then monospaced = false end + + ---@type widget.fonts.data + local fontdata = nil + local prev_score = 0 + + for _, font in ipairs(self.fonts) do + if not monospaced or (monospaced and font.monospace) then + local score = system.fuzzy_match( + font.fullname:ulower(), + name .. " " .. style, + false + ) + if score ~= nil and (score > prev_score or prev_score == 0) then + fontdata = font + prev_score = score + end + end + end + + if fontdata then + local fontfile = io.open(fontdata.path, "r") + if not fontfile then + return nil, "found font file does not exists, cache is outdated" + else + fontfile:close() + end + else + return nil, "no matching font found" + end + + return fontdata +end + + +return FontCache diff --git a/resources/amiga/config/lite-xl/libraries/widget/fonts/info.lua b/resources/amiga/config/lite-xl/libraries/widget/fonts/info.lua new file mode 100644 index 00000000..3ffa6968 --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/fonts/info.lua @@ -0,0 +1,550 @@ +-- Based on the code from: +-- https://gist.github.com/zr-tex8r/1969061a025fa4fc5486c9c28460f48e + +local Object = require "core.object" + +-------------------------------------------------------------------------------- +-- Class Declarations +-------------------------------------------------------------------------------- + +---@class widget.fonts.cdata : core.object +---@overload fun(data:string):widget.fonts.cdata +---@field private data string +---@field private position integer +local FontCDATA = Object:extend() + +---@class widget.fonts.reader : core.object +---@overload fun(font_path?:string):widget.fonts.reader +---@field private file file* +---@field private path string +local FontReader = Object:extend() + +---@class widget.fonts.data +---@field public path string +---@field public id number @Numerical id of the font +---@field public type '"ttc"' | '"ttf"' | '"otf"' +---@field public copyright string +---@field public family string +---@field public subfamily '"Regular"' | '"Bold"' | '"Italic"' | '"Bold Italic"' +---@field public fullname string +---@field public version string +---@field public psname string +---@field public url string +---@field public license string +---@field public tfamily string +---@field public tsubfamily '"Regular"' | '"Bold"' | '"Italic"' | '"Bold Italic"' +---@field public wwsfamily string +---@field public wwssubfamily string +---@field public monospace boolean + +---@class widget.fonts.info : core.object +---@overload fun(font_path?:string):widget.fonts.info +---@field private reader widget.fonts.reader +---@field public path string @Path of the font file +---@field public data widget.fonts.data[] @Holds the metadata for each of the embedded fonts +local FontInfo = Object:extend() + +---@alias widget.fonts.style +---|>'"regular"' +---| '"bold"' +---| '"italic"' +---| '"bold italic"' +---| '"thin"' +---| '"medium"' +---| '"light"' +---| '"black"' +---| '"condensed"' +---| '"oblique"' +---| '"bold oblique"' +---| '"extra nold"' +---| '"Extra bold italic"' +---| '"bold condensed"' + +-------------------------------------------------------------------------------- +-- FontCDATA Implementation +-------------------------------------------------------------------------------- +function FontCDATA:new(data) + self.data = data + self.position = 0 +end + +function FontCDATA:__tostring() + return "cdata(pos="..self.position..")" +end + +function FontCDATA:pos(p) + if not p then return self.position end + self.position = p + return self +end + +function FontCDATA:unum(b) + local v, data = 0, self.data + assert(#data >= self.position + b, 11) + for _ = 1, b do + self.position = self.position + 1 + v = v * 256 + data:byte(self.position) + end + return v +end + +function FontCDATA:setunum(b, v) + local t, data = {}, self.data + t[1] = data:sub(1, self.position) + self.position = self.position + b + assert(#data >= self.position, 12) + t[b + 2] = data:sub(self.position + 1) + for i = 1, b do + t[b + 2 - i] = string.char(v % 256) + v = math.floor(v / 256) + end + self.data = table.concat(t, '') + return self +end + +function FontCDATA:str(b) + local data = self.data + self.position = self.position + b + assert(#data >= self.position, 13) + return data:sub(self.position - b + 1, self.position) +end + +function FontCDATA:setstr(s) + local t, data = {}, self.data + t[1], t[2] = data:sub(1, self.position), s + self.position = self.position + #s + assert(#data >= self.position, 14) + t[3] = data:sub(self.position + 1) + self.data = table.concat(t, '') + return self +end + +function FontCDATA:ushort() + return self:unum(2) +end + +function FontCDATA:ulong() + return self:unum(4) +end + +function FontCDATA:setulong(v) + return self:setunum(4, v) +end + +function FontCDATA:ulongs(num) + local t = {} + for i = 1, num do + t[i] = self:unum(4) + end + return t +end + +-------------------------------------------------------------------------------- +-- FontReader Implementation +-------------------------------------------------------------------------------- +function FontReader:new(font_path) + local file, errmsg = io.open(font_path, "rb") + assert(file, errmsg) + self.file = file + self.path = font_path +end + +function FontReader:__gc() + if self.file then + self.file:close() + end +end + +function FontReader:__tostring() + return "reader("..self.path..")" +end + +---@param offset integer +---@param len integer +---@return widget.fonts.cdata? +---@return string|nil errmsg +function FontReader:cdata(offset, len) + local data, errmsg = self:read(offset, len) + if data then + return FontCDATA(data) + end + return nil, errmsg +end + +function FontReader:read(offset, len) + self.file:seek("set", offset) + local data = self.file:read(len) + if data:len() ~= len then + return nil, "failed reading font data" + end + return data +end + +function FontReader:close() + self.file:close() + self.file = nil +end + +-------------------------------------------------------------------------------- +-- FontInfo Helper Functions +-------------------------------------------------------------------------------- +-- speeds up function lookups +local floor, ceil = math.floor, math.ceil + +local function div(x, y) + return floor(x / y), x % y +end + +local function utf16betoutf8(src) + local s, d = { tostring(src):byte(1, -1) }, {} + for i = 1, #s - 1, 2 do + local c = s[i] * 256 + s[i+1] + if c < 0x80 then d[#d+1] = c + elseif c < 0x800 then + local x, y = div(c, 0x40) + d[#d+1] = x + 0xC0; d[#d+1] = y + 0x80 + elseif c < 0x10000 then + local x, y, z = div(c, 0x1000); y, z = div(y, 0x40) + d[#d+1] = x + 0xE0; d[#d+1] = y + 0x80; d[#d+1] = z + 0x80 + else + assert(nil) + end + end + return string.char(table.unpack(d)) +end + +local file_type = { + [0x74746366] = 'ttc', + [0x10000] = 'ttf', + [0x4F54544F] = 'otf', + [1008813135] = 'ttc' +} + +---@param reader widget.fonts.reader +local function otf_offset(reader) + local cd, errmsg = reader:cdata(0, 12) + if not cd then + return nil, errmsg + end + local tag = cd:ulong() + local ftype = file_type[tag]; + if ftype == 'ttc' then + local ver = cd:ulong(); + local num = cd:ulong(); + cd, errmsg = reader:cdata(12, 4 * num) + if not cd then + return nil, errmsg + end + local res = cd:ulongs(num); + return res + elseif ftype == 'otf' or ftype == 'ttf' then + return { 0 } + else + return nil, string.format("unknown file tag: %s", tag) + end +end + +---@param reader widget.fonts.reader +---@param fofs integer +---@param ntbl integer +local function otf_name_table(reader, fofs, ntbl) + local cd_d = reader:cdata(fofs + 12, 16 * ntbl) + if not cd_d then + return nil, "error reading names table" + end + for _ = 1, ntbl do + local t = {-- tag, csum, ofs, len + cd_d:str(4), cd_d:ulong(), cd_d:ulong(), cd_d:ulong() + } + if t[1] == 'name' then + return reader:cdata(t[3], ceil(t[4] / 4) * 4) + end + end + return nil, "name table is missing" +end + +---@param cdata widget.fonts.cdata +local function otf_name_records(cdata) + local nfmt, nnum, nofs = cdata:ushort(), cdata:ushort(), cdata:ushort() + assert(nfmt == 0, string.format("unsupported name table format: %s", nfmt)) + local nr = {} + for i = 1, nnum do + nr[i] = { -- pid, eid, langid, nameid, len, ofs + cdata:ushort(), cdata:ushort(), cdata:ushort(), + cdata:ushort(), cdata:ushort(), cdata:ushort() + nofs + } + end + return nr +end + +---@param cdata widget.fonts.cdata +local function otf_name(cdata, nr, nameid) + local function seek(pid, eid, lid) + for i = 1, #nr do + local t = nr[i] + local ok = (t[4] == nameid and t[1] == pid and t[2] == eid and + t[3] == lid) + if ok then return t end + end + end + + local rec = seek(3, 1, 0x409) + or seek(3, 10, 0x409) + or seek(1, 0, 0) or seek(0, 3, 0) + or seek(0, 4, 0) or seek(0, 6, 0) + + if not rec then return '' end + local s = cdata:pos(rec[6]):str(rec[5]) + return (rec[1] == 3) and utf16betoutf8(s) or s +end + +---@param reader widget.fonts.reader +local function otf_list(reader, fid, fofs) + local cd_fh, errmsg = reader:cdata(fofs, 12) + if not cd_fh then + return nil, errmsg + end + + local tag = cd_fh:ulong() + local ntbl = cd_fh:ushort() + + local cd_n = nil + cd_n, errmsg = otf_name_table(reader, fofs, ntbl) + if not cd_n then + return nil, errmsg + end + + local ext = { id = fid; type = file_type[tag] or '' } + + local nr = nil + nr, errmsg = otf_name_records(cd_n) + if not nr then + return nil, errmsg + end + + local output = { + id = ext.id, + type = ext.type, + copyright = otf_name(cd_n, nr, 0), + family = otf_name(cd_n, nr, 1), + subfamily = otf_name(cd_n, nr, 2), + fullname = otf_name(cd_n, nr, 4), + version = otf_name(cd_n, nr, 5), + psname = otf_name(cd_n, nr, 6), + url = otf_name(cd_n, nr, 11), + license = otf_name(cd_n, nr, 13), + tfamily = otf_name(cd_n, nr, 16), + tsubfamily = otf_name(cd_n, nr, 17), + } + + return output +end + +-------------------------------------------------------------------------------- +-- FontInfo Implementation +-------------------------------------------------------------------------------- + +---Helper function to check and update a font monospace attribute. +---@param font_data widget.fonts.data +---@return boolean checked +---@return string? errmsg +function FontInfo.check_is_monospace(font_data) + if font_data then + local loaded, fontren = pcall(renderer.font.load, font_data.path, 8, {}) + if not loaded then + return false, "could not load font" + else + if fontren:get_width("|") == fontren:get_width("W") then + font_data.monospace = true + else + font_data.monospace = false + end + end + end + return true +end + +---Constructor +---@param font_path? string +function FontInfo:new(font_path) + if type(font_path) == "string" then + self:read(font_path) + else + self.data = {} + self.path = "" + self.last_error = "no font given" + end +end + +local function fontinfo_read_native(self, font_path) + ---@type widget.fonts.data + local font + ---@type string? + local errmsg + + ---@diagnostic disable-next-line + font, errmsg = renderer.font.get_metadata(font_path) + + if not font then + self.last_error = errmsg + return font, errmsg + end + + local add = true + local family = nil + if font.tfamily then + family = font.tfamily + elseif font.family then + family = font.family + end + + local subfamily = nil + if font.tsubfamily then + subfamily = font.tsubfamily -- sometimes tsubfamily includes more styles + elseif font.subfamily then + subfamily = font.subfamily + end + + -- fix font meta data or discard if empty + if family and subfamily then + font.fullname = family .. " " .. subfamily + elseif font.fullname and family and not font.fullname:ufind(family, 1, true) then + font.fullname = font.fullname .. " " .. family + elseif not font.fullname and family then + font.fullname = family + else + self.last_error = "font metadata is empty" + add = false + end + + if add then + table.insert(self.data, font) + else + return nil, self.last_error + end + + return true +end + +local function fontinfo_read_nonnative(self, font_path) + self.reader = FontReader(font_path) + + local tofs, errmsg = otf_offset(self.reader) + + if not tofs then + self.last_error = errmsg + return nil, errmsg + end + + local data = nil + for i = 1, #tofs do + data, errmsg = otf_list(self.reader, i - 1, tofs[i]) + if data then + table.insert(self.data, data) + else + self.last_error = errmsg + return nil, errmsg + end + end + + if self.data[1] then + local font = self.data[1] + + local family = nil + if font.tfamily ~= "" then + family = font.tfamily + elseif font.family ~= "" then + family = font.family + end + + local subfamily = nil + if font.tsubfamily ~= "" then + subfamily = font.tsubfamily -- sometimes tsubfamily includes more styles + elseif font.subfamily ~= "" then + subfamily = font.subfamily + end + + -- fix font meta data or discard if empty + if family and subfamily then + font.fullname = family .. " " .. subfamily + elseif font.fullname ~= "" and family and not font.fullname:ufind(family, 1, true) then + font.fullname = font.fullname .. " " .. family + elseif font.fullname == "" and family then + font.fullname = family + else + self.data = {} + self.last_error = "font metadata is empty" + return nil, self.last_error + end + end + + self.reader:close() + + return true +end + +---Open a font file and read its metadata. +---@param font_path string +---@return widget.fonts.info? +---@return string|nil errmsg +function FontInfo:read(font_path) + self.data = {} + self.path = font_path + + local read, errmsg + + ---@diagnostic disable-next-line + if type(renderer.font.get_metadata) == "function" then + read, errmsg = fontinfo_read_native(self, font_path) + else + read, errmsg = fontinfo_read_nonnative(self, font_path) + end + + if not read then + return read, errmsg + end + + return self +end + +---Get the amount of collections on the font file. +---@return integer +function FontInfo:embedded_fonts_count() + return #self.data +end + +---Get the metadata of a previously read font file without +---copyright and license information which can be long. +---@param idx? integer Optional position of the embedded font +---@return widget.fonts.data? +---@return string|nil errmsg +function FontInfo:get_data(idx) + idx = idx or 1 + local data = {} + + if #self.data > 0 and self.data[idx] then + data = self.data[idx] + else + return nil, self.last_error + end + + return { + path = self.path, + id = data.id, + type = data.type, + family = data.family, + subfamily = data.subfamily, + fullname = data.fullname, + version = data.version, + psname = data.psname, + url = data.url, + tfamily = data.tfamily, + tsubfamily = data.tsubfamily, + wwsfamily = data.wwsfamily, + wwssubfamily = data.wwssubfamily, + monospace = data.monospace or false + } +end + + +return FontInfo diff --git a/resources/amiga/config/lite-xl/libraries/widget/fonts/init.lua b/resources/amiga/config/lite-xl/libraries/widget/fonts/init.lua new file mode 100644 index 00000000..5b3c51f1 --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/fonts/init.lua @@ -0,0 +1,230 @@ +local core = require "core" +local common = require "core.common" +local style = require "core.style" +local FontCache = require "libraries.widget.fonts.cache" +local StatusView = require "core.statusview" + +---@class widget.fonts +local Fonts = {} + +---@type widget.fonts.cache | nil +local fontcache = nil + +---@type table | nil +local fonts = nil + +---Last time the status view item was rendered +local last_statusview_render = 0 + +---The amount of fonts matching the user query +local matching_fonts = 0 + +---Flag that indicates if command view font picker is for monospaced +local pick_monospaced = false + +---Generate the list of fonts displayed on the CommandView. +---@param monospaced? boolean Only display fonts detected as monospaced. +local function generate_fonts(monospaced) + if fontcache then + if fontcache.building then monospaced = false end + fonts = {} + for idx, f in ipairs(fontcache.fonts) do + if not monospaced or (monospaced and f.monospace) then + table.insert(fonts, f.fullname .. "||" .. idx) + end + end + end +end + +---Helper function to split a string by a given delimeter. +local function split(s, delimeter, delimeter_pattern) + if not delimeter_pattern then + delimeter_pattern = delimeter + end + + local result = {}; + for match in (s..delimeter):gmatch("(.-)"..delimeter_pattern) do + table.insert(result, match); + end + return result; +end + +local already_cleaning = false + +---Clean the generated font cache used on command view to free some ram +local function clean_fonts_cache() + if not fontcache or already_cleaning then return end + if not fontcache.building and not fontcache.searching_monospaced then + fontcache = nil + fonts = nil + collectgarbage "collect" + else + already_cleaning = true + core.add_thread(function() + while fontcache.building or fontcache.searching_monospaced do + coroutine.yield(1) + end + if + core.active_view ~= core.command_view + or + ( + core.command_view.label ~= "Select Font: " + and + core.command_view.label ~= "List only monospaced fonts?: " + ) + then + fontcache = nil + fonts = nil + collectgarbage "collect" + already_cleaning = false + end + end) + end +end + +---Launch the commandview and let the user select a font. +---@param callback fun(name:string, path:string) +---@param monospaced boolean +function Fonts.show_picker(callback, monospaced) + if not fontcache then fontcache = FontCache() end + + pick_monospaced = monospaced + + if not fontcache.building and (not monospaced or fontcache.monospaced) then + generate_fonts(monospaced) + else + core.add_thread(function() + while + (fontcache.building or (monospaced and not fontcache.monospaced)) + and + core.active_view == core.command_view + and + core.command_view.label == "Select Font: " + do + core.command_view:update_suggestions() + coroutine.yield(2) + end + generate_fonts(monospaced) + core.command_view:update_suggestions() + end) + end + + last_statusview_render = system.get_time() + + core.command_view:enter("Select Font", { + submit = function(text, item) + callback(item.text, item.info) + clean_fonts_cache() + end, + suggest = function(text) + if fontcache.building or (monospaced and fontcache.searching_monospaced) then + generate_fonts(monospaced) + end + local res = common.fuzzy_match(fonts, text) + matching_fonts = #res + for i, name in ipairs(res) do + local font_info = split(name, "||") + local id = tonumber(font_info[2]) + local font_data = fontcache.fonts[id] + res[i] = { + text = font_data.fullname, + info = font_data.path, + id = id + } + end + return res + end, + cancel = function() + clean_fonts_cache() + end + }) +end + +---Same as `show_picker()` but asks the user if he wants a monospaced font. +---@param callback fun(name:string, path:string) +function Fonts.show_picker_ask_monospace(callback) + if not fontcache then fontcache = FontCache() end + + core.command_view:enter("List only monospaced fonts?", { + submit = function(text, item) + Fonts.show_picker(callback, item.mono) + end, + suggest = function(text) + local res = common.fuzzy_match({"Yes", "No"}, text) + for i, name in ipairs(res) do + res[i] = { + text = name, + mono = text == "Yes" and true or false + } + end + return res + end, + cancel = function() + clean_fonts_cache() + end + }) +end + +---Check if the font cache is been built. +---@return boolean building +function Fonts.cache_is_building() + if not fontcache then return false end + return fontcache:is_building() +end + +---Remove current fonts cache file and regenerates a fresh one. +---@return boolean started False if cache is already been built +function Fonts.clean_cache() + if not fontcache then fontcache = FontCache() end + return fontcache:rebuild() +end + +core.status_view:add_item({ + predicate = function() + return core.active_view == core.command_view + and core.command_view.label == "Select Font: " + end, + name = "widget:font-select", + alignment = StatusView.Item.LEFT, + get_item = function() + local found = 0 + local dots, status = "", "" + if fontcache then + if fontcache.building or fontcache.searching_monospaced then + dots = "." + if system.get_time() - last_statusview_render >= 3 then + last_statusview_render = system.get_time() + elseif system.get_time() - last_statusview_render >= 2 then + dots = "..." + elseif system.get_time() - last_statusview_render >= 1 then + dots = ".." + end + end + if fontcache.building then + status = " | searching system fonts" .. dots + elseif fontcache.searching_monospaced then + status = " | detecting monospaced fonts" .. dots + end + + if fontcache.building or not pick_monospaced then + found = fontcache.found + else + found = fontcache.found_monospaced + end + end + + return { + style.text, + style.font, + "Matches: " + .. tostring(matching_fonts) + .. " / " + .. tostring(found) + .. status + } + end, + position = 1 +}) + + +return Fonts diff --git a/resources/amiga/config/lite-xl/libraries/widget/fontslist.lua b/resources/amiga/config/lite-xl/libraries/widget/fontslist.lua new file mode 100644 index 00000000..b2109a09 --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/fontslist.lua @@ -0,0 +1,223 @@ +-- +-- FontsList Widget. +-- @copyright Jefferson Gonzalez +-- @license MIT +-- +local style = require "core.style" +local Widget = require "libraries.widget" +local Button = require "libraries.widget.button" +local ListBox = require "libraries.widget.listbox" +local FontDialog = require "libraries.widget.fontdialog" +local MessageBox = require "libraries.widget.messagebox" + +---@class widget.fontslist.font : widget +---@field name string +---@field path string + +---@class widget.fontslist : widget +---@overload fun(parent?:widget):widget.fontslist +---@field list widget.listbox +---@field add widget.button +---@field remove widget.button +---@field up widget.button +---@field down widget.button +---@field options renderer.fontoptions +---@field private dialog boolean +local FontsList = Widget:extend() + +---Constructor +---@param parent widget +function FontsList:new(parent) + FontsList.super.new(self, parent) + + self.type_name = "widget.fontslist" + + self.border.width = 0 + + self.dialog = false + + self.options = {} + + self.list = ListBox(self) + + local this = self + + function self.list:on_mouse_pressed(button, x, y, clicks) + if not ListBox.on_mouse_pressed(self, button, x, y, clicks) then + return false + end + + if clicks == 2 and not this.dialog then + this.dialog = true + local selected = this.list:get_selected() + local fontdata = this.list:get_row_data(selected) + ---@type widget.inputdialog + local font = FontDialog(fontdata, this:get_options()) + function font:on_save(fontdata, options) + this:set_options(options) + this:edit_font(selected, fontdata) + end + function font:on_close() + FontDialog.on_close(self) + self:destroy() + this.dialog = false + end + font:show() + end + + return true + end + + self.add = Button(self, "Add") + self.add:set_icon("B") + function self.add:on_click() + if #this.list.rows > 9 then + MessageBox.error("Max Fonts Reached", "Only a maximum of ten fonts can be added.") + return + end + if not this.dialog then + this.dialog = true + ---@type widget.inputdialog + local font = FontDialog(nil, this:get_options()) + function font:on_save(fontdata, options) + this:set_options(options) + this:add_font(fontdata) + end + function font:on_close() + FontDialog.on_close(self) + self:destroy() + this.dialog = false + end + font:show() + end + end + + self.remove = Button(self, "Remove") + self.remove:set_icon("C") + function self.remove:on_click() + local selected = this.list:get_selected() + if selected then + if #this.list.rows > 1 then + this:remove_font(selected) + else + MessageBox.error("Font required", "A minimum of one font is needed") + end + else + MessageBox.error("No font selected", "Please select a font to remove") + end + end + + self.up = Button(self, "") + self.up:set_icon("<") + self.up:set_tooltip("increase font priority") + function self.up:on_click() + local selected = this.list:get_selected() + if selected then + this.list:move_row_up(selected) + this:on_change() + else + MessageBox.error("No font selected", "Please select a font to move") + end + end + + self.down = Button(self, "") + self.down:set_icon(">") + self.down:set_tooltip("decrease font priority") + function self.down:on_click() + local selected = this.list:get_selected() + if selected then + this.list:move_row_down(selected) + this:on_change() + else + MessageBox.error("No font selected", "Please select a font to move") + end + end +end + +---Add a new font into the list. +---@param font widget.fontslist.font +function FontsList:add_font(font) + self.list:add_row({font.name}, font) + self.list:set_visible_rows() + self:on_change() +end + +---Edit an existing font on the list. +---@param idx integer +---@param font widget.fontslist.font +function FontsList:edit_font(idx, font) + self.list:set_row(idx, {font.name}) + self.list:set_row_data(idx, font) + self:on_change() +end + +---Remove the given font from the list. +---@param idx integer +function FontsList:remove_font(idx) + self.list:remove_row(idx) + self:on_change() +end + +---Return the fonts from the list. +---@return table +function FontsList:get_fonts() + local output = {} + local count = #self.list.rows + for i=1, count, 1 do + table.insert(output, self.list:get_row_data(i)) + end + return output +end + +---Set the global options for the fonts group +---@param options renderer.fontoptions +function FontsList:set_options(options) + self.options = options +end + +---Get the global options for the font group +---@return renderer.fontoptions +function FontsList:get_options() + return self.options +end + +function FontsList:update() + if not FontsList.super.update(self) then return false end + + if self.size.x == 0 then + self.size.x = self.add:get_width() + + (style.padding.x / 2) + self.remove:get_width() + + (style.padding.x / 2) + self.up:get_width() + + (style.padding.x / 2) + self.down:get_width() + (50 * SCALE) + self.size.y = self.add:get_height() + (style.padding.y * 2) + 100 + end + + self.list:set_position(0, 0) + + self.list:set_size( + self.size.x, + self.size.y - self.add:get_height() - (style.padding.y * 2) + ) + + self.add:set_position(0, self.list:get_bottom() + style.padding.y) + + self.remove:set_position( + self.add:get_right() + (style.padding.x / 2), + self.list:get_bottom() + style.padding.y + ) + + self.up:set_position( + self.remove:get_right() + (style.padding.x / 2), + self.list:get_bottom() + style.padding.y + ) + + self.down:set_position( + self.up:get_right() + (style.padding.x / 2), + self.list:get_bottom() + style.padding.y + ) + + return true +end + + +return FontsList diff --git a/resources/amiga/config/lite-xl/libraries/widget/init.lua b/resources/amiga/config/lite-xl/libraries/widget/init.lua new file mode 100644 index 00000000..c25352f2 --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/init.lua @@ -0,0 +1,1544 @@ +-- +-- Base widget implementation for lite. +-- @copyright Jefferson Gonzalez +-- @license MIT +-- + +local core = require "core" +local config = require "core.config" +local style = require "core.style" +local keymap = require "core.keymap" +local View = require "core.view" +local ScrollBar = require "libraries.widget.scrollbar" +local RootView + +---Represents the border of a widget. +---@class widget.border +---@field public width number +---@field public color renderer.color | nil + +---Represents the position of a widget. +---@class widget.position +---@field public x number Real X +---@field public y number Real y +---@field public rx number Relative X +---@field public ry number Relative Y +---@field public dx number Dragging initial x position +---@field public dy number Dragging initial y position + +---@class widget.animation.options +---Prevents duplicated animations from getting added. +---@field name? string +---Speed of the animation, defaults to 0.5 +---@field rate? number +---Called each time the value of a property changes. +---@field on_step? fun(target:table, property:string, value:number) +---Called when the animation finishes. +---@field on_complete? fun(widget:widget) + +---@class widget.animation +---@field target table +---@field properties table +---@field options? widget.animation.options + +---Represents a reference to a font stored elsewhere. +---@class widget.fontreference +---@field public container table +---@field public name string + +---@alias widget.font widget.fontreference | renderer.font | string + +---@alias widget.clicktype +---| "left" +---| "right" + +---@alias widget.styledtext table + +---A base widget +---@class widget : core.view +---@overload fun(parent?:widget, floating?:boolean):widget +---@field public super widget +---@field public parent widget | nil +---@field public name string +---@field public position widget.position +---Modifying this property directly is not advised, use set_size() instead. +---@field public size widget.position +---@field public childs table +---@field public child_active widget | nil +---@field public zindex integer +---@field public border widget.border +---@field public clickable boolean +---@field public draggable boolean +---@field public scrollable boolean +---@field public font widget.font +---@field public foreground_color renderer.color +---@field public background_color renderer.color +---@field public render_background boolean +---@field public type_name string +---@field protected visible boolean +---@field protected has_focus boolean +---@field protected dragged boolean +---@field protected tooltip string +---@field protected label string | widget.styledtext +---@field protected input_text boolean +---@field protected textview widget +---@field protected next_zindex integer +---@field protected mouse widget.position +---@field protected prev_size widget.position +---@field protected mouse_is_pressed boolean +---@field protected mouse_is_hovering boolean +---@field protected mouse_pressed_outside boolean +---@field protected is_scrolling boolean +---By default is set to true to allow ctrl+wheel or cmd+wheel on mac to scale +---the interface, you can set it to false on your parent widget to allow +---manually intercepting ctrl+wheel. +---@field protected skip_scroll_ctrl boolean +---@field protected captured_widget widget Widget that captured mouse events +---@field protected animations widget.animation[] +local Widget = View:extend() + +---Indicates on a widget.styledtext that a new line follows. +---@type integer +Widget.NEWLINE = 1 + +---Keep track of last hovered widget to properly trigger on_mouse_leave +---@type widget | nil +local last_hovered_child = nil + +---A list of floating widgets that need to receive events. +---@type table +local floating_widgets = {} + +---Flag that indicates if the tooltip is been shown +---@type boolean +local widget_showing_tooltip = false + +---When no parent is given to the widget constructor it will automatically +---overwrite RootView methods to intercept system events. +---@param parent? widget +---@param floating? boolean +function Widget:new(parent, floating) + Widget.super.new(self) + + self.v_scrollbar = ScrollBar(self, {direction = "v", alignment = "e"}) + self.h_scrollbar = ScrollBar(self, {direction = "h", alignment = "e"}) + + self.type_name = "widget" + self.parent = parent + self.name = "---" -- defaults to the application name + if type(floating) == "boolean" then + self.defer_draw = floating + else + self.defer_draw = true + end + self.childs = {} + self.child_active = nil + self.zindex = nil + self.next_zindex = 1 + self.border = { + width = 1, + color = nil + } + self.foreground_color = nil + self.background_color = nil + self.render_background = true + self.visible = parent and true or false + self.has_focus = false + self.clickable = true + self.draggable = false + self.dragged = false + self.font = "font" + self.force_events = {} + self.tooltip = "" + self.label = "" + self.input_text = false + self.textview = nil + self.mouse = {x = 0, y = 0} + self.prev_size = {x = 0, y = 0} + self.is_scrolling = false + self.skip_scroll_ctrl = true + + self.mouse_is_pressed = false + self.mouse_is_hovering = false + + -- used to allow proper node resizing + self.mouse_pressed_outside = false + + self.animations = {} + + if parent then + parent:add_child(self) + elseif self.defer_draw then + table.insert(floating_widgets, self) + Widget.override_rootview() + end + + self:set_position(0, 0) +end + +---Useful for debugging. +function Widget:__tostring() + return self.type_name +end + +---Add a child widget, automatically assign a zindex if non set and sorts +---them in reverse order for better events matching. +---@param child widget +function Widget:add_child(child) + if not child.zindex then + child.zindex = self.next_zindex + self.next_zindex = self.next_zindex + 1 + end + + table.insert(self.childs, child) + table.sort(self.childs, function(t1, t2) return t1.zindex > t2.zindex end) +end + +---Remove a child widget. +---@param child widget +function Widget:remove_child(child) + for position, element in ipairs(self.childs) do + if child == element then + child:destroy_childs() + table.remove(self.childs, position) + break + end + end +end + +---Show the widget. +function Widget:show() + if not self.parent then + if self.size.x <= 0 or self.size.y <= 0 then + self.size.x = self.prev_size.x + self.size.y = self.prev_size.y + end + self.prev_size.x = 0 + self.prev_size.y = 0 + end + self.visible = true + -- re-triggers update to make sure everything was properly calculated + -- and redraw the interface once, maybe something else can be changed + -- to not require this action, but for now lets do this. + core.add_thread(function() + self:update() + core.redraw = true + end) +end + +---Perform an animated show. +---@param lock_x? boolean Do not resize width while animating +---@param lock_y? boolean Do not resize height while animating +---@param options? widget.animation.options +function Widget:show_animated(lock_x, lock_y, options) + if not self.parent then + if self.size.x <= 0 or self.size.y <= 0 then + self.size.x = self.prev_size.x + self.size.y = self.prev_size.y + end + self.prev_size.x = 0 + self.prev_size.y = 0 + end + + local target_x, target_y = math.floor(self.size.x), math.floor(self.size.y) + self.size.x = lock_x and target_x or 0 + self.size.y = lock_y and target_y or 0 + local properties = {} + if not lock_x then properties["x"] = target_x end + if not lock_y then properties["y"] = target_y end + options = options or {} + self:animate(self.size, properties, { + name = options.name or "show_animated", + rate = options.rate, + on_step = options.on_step, + on_complete = options.on_complete + }) + + self.visible = true +end + +---Hide the widget. +function Widget:hide() + self.visible = false + -- we need to force size to zero on parent widget to properly hide it + -- when used as a lite node, otherwise the reserved space of the node + -- will stay visible and dragging will reveal empty space. + if not self.parent then + if self.size.x > 0 or self.size.y > 0 then + -- we only do this once to prevent issues of consecutive hide calls + if self.prev_size.x == 0 and self.prev_size.y == 0 then + self.prev_size.x = self.size.x + self.prev_size.y = self.size.y + end + self.size.x = 0 + self.size.y = 0 + end + end +end + +---Perform an animated hide. +---@param lock_x? boolean Do not resize width while animating +---@param lock_y? boolean Do not resize height while animating +---@param options? widget.animation.options +function Widget:hide_animated(lock_x, lock_y, options) + local x, y = self.size.x, self.size.y + local target_x = lock_x and self.size.x or 0 + local target_y = lock_y and self.size.y or 0 + local properties = {} + if not lock_x then properties["x"] = target_x end + if not lock_y then properties["y"] = target_y end + options = options or {} + self:animate(self.size, properties, { + name = options.name or "hide_animated", + rate = options.rate, + on_step = options.on_step, + on_complete = function() + self.size.x, self.size.y = x, y + self:hide() + if options.on_complete then + options.on_complete(self) + end + end + }) +end + +---When set to false the background rendering is disabled. +---@param enable? boolean | nil +function Widget:toggle_background(enable) + if type(enable) == "boolean" then + self.render_background = enable + else + self.render_background = not self.render_background + end +end + +---Toggle visibility of widget. +---@param animated? boolean +---@param lock_x? boolean +---@param lock_y? boolean +---@param options? widget.animation.options +function Widget:toggle_visible(animated, lock_x, lock_y, options) + if not self.visible then + if not animated then + self:show() + else + self:show_animated(lock_x, lock_y, options) + end + else + if not animated then + self:hide() + else + self:hide_animated(lock_x, lock_y, options) + end + end +end + +---Check if the widget is visible also recursing to the parent visibility. +---@return boolean +function Widget:is_visible() + if + not self.visible or (self.parent and not self.parent:is_visible()) + then + return false + end + return true +end + +---Taken from the logview and modified it a tiny bit. +---TODO: something similar should be on lite-xl core. +---@param font widget.font +---@param text string +---@param x integer +---@param y integer +---@param color renderer.color +---@param only_calc boolean +---@return integer resx +---@return integer resy +---@return integer width +---@return integer height +function Widget:draw_text_multiline(font, text, x, y, color, only_calc) + font = self:get_font(font) + local th = font:get_height() + local resx, resy = x, y + local width, height = 0, 0 + for line in (text .. "\n"):gmatch("(.-)\n") do + resy = y + if only_calc then + resx = x + font:get_width(line) + else + resx = renderer.draw_text(font, line, x, y, color) + end + y = y + th + width = math.max(width, resx - x) + height = height + th + end + return resx, resy, width, height +end + +---Render or calculate the size of the specified range of elements +---in a styled text elemet. +---@param text widget.styledtext +---@param start_idx integer +---@param end_idx integer +---@param x integer +---@param y integer +---@param only_calc boolean +---@return integer width +---@return integer height +function Widget:draw_styled_text(text, x, y, only_calc, start_idx, end_idx) + local font = self:get_font() + local color = self.foreground_color or style.text + local width = 0 + local height = font:get_height() + local new_line = false + local nx = x + + start_idx = start_idx or 1 + end_idx = end_idx or #text + + for pos=start_idx, end_idx, 1 do + local element = text[pos] + local ele_type = type(element) + if + ele_type == "userdata" + or + (element.container or type(element[1]) == "userdata") + then + if ele_type == "table" and element.container then + font = element.container[element.name] + else + font = element + end + elseif ele_type == "table" then + color = element + elseif element == Widget.NEWLINE then + y = y + font:get_height() + nx = x + new_line = true + elseif ele_type == "string" then + local rx, ry, w, h = self:draw_text_multiline( + font, element, nx, y, color, only_calc + ) + y = ry + nx = rx + if new_line then + height = height + h + width = math.max(width, w) + new_line = false + else + height = math.max(height, h) + width = width + w + end + end + end + + return width, height +end + +---Draw the widget configured border or custom one. +---@param x? number +---@param y? number +---@param w? number +---@param h? number +function Widget:draw_border(x, y, w, h) + if self.border.width <= 0 then return end + + x = x or self.position.x + y = y or self.position.y + w = w or self.size.x + h = h or self.size.y + + x = x - self.border.width + y = y - self.border.width + w = w + (self.border.width * 2) + h = h + (self.border.width * 2) + + -- Draw lines instead of full rectangle to be able to draw on top + + --top + renderer.draw_rect( + x, y, w + x % 1 - self.border.width, self.border.width, + self.border.color or style.text + ) + --bottom + renderer.draw_rect( + x, y+h - self.border.width, w + x % 1 - self.border.width, self.border.width, + self.border.color or style.text + ) + --right + renderer.draw_rect( + x+w - self.border.width, y, self.border.width, h, + self.border.color or style.text + ) + --left + renderer.draw_rect( + x, y, self.border.width, h, + self.border.color or style.text + ) +end + +---Called by lite node system to properly resize the widget. +---@param axis string | "'x'" | "'y'" +---@param value number +function Widget:set_target_size(axis, value) + if not self.visible then + return false + end + if axis == "x" then + self:set_size(value) + else + self:set_size(nil, value) + end + return true +end + +---@param width? integer +---@param height? integer +function Widget:set_size(width, height) + -- take into consideration the border as part of size + if width then + if width > (self.border.width * 2) then + width = width - (self.border.width * 2) + else + width = 0 + end + end + if height then + if height > (self.border.width * 2) then + height = height - (self.border.width * 2) + else + height = 0 + end + end + + if not self.parent and not self.visible then + if width then self.prev_size.x = width end + if height then self.prev_size.y = height end + else + if width then self.size.x = width end + if height then self.size.y = height end + end +end + +---Set the widget border size and appropriately re-set the widget size. +---@param width integer +function Widget:set_border_width(width) + local wwidth, wheight = 0, 0; + if self.border.width > 0 then + local prev_width = self.border.width * 2 + if not self.parent and not self.visible then + wwidth = self.prev_size.x + prev_width + wheight = self.prev_size.y + prev_width + else + wwidth = self.size.x + prev_width + wheight = self.size.y + prev_width + end + end + self.border.width = width + self:set_size(wwidth, wheight) +end + +---Called on the update function to be able to scroll the child widgets. +function Widget:update_position() + if self.parent then + self.position.x = self.position.rx + self.border.width + self.position.y = self.position.ry + self.border.width + + -- add offset to properly scroll + local ox, oy = self.parent:get_content_offset() + self.position.x = ox + self.position.x + self.position.y = oy + self.position.y + end + + for _, child in pairs(self.childs) do + child:update_position() + end +end + +---Set the position of the widget and updates the child absolute coordinates +---@param x? integer +---@param y? integer +function Widget:set_position(x, y) + if x then self.position.x = x + self.border.width end + if y then self.position.y = y + self.border.width end + + if self.parent then + -- add offset to properly scroll + local ox, oy = self.parent:get_content_offset() + + if x then + self.position.rx = x + self.position.x = ox + self.position.x + end + + if y then + self.position.ry = y + self.position.y = oy + self.position.y + end + end + + if x or y then + for _, child in pairs(self.childs) do + child:set_position(child.position.rx, child.position.ry) + end + end +end + +---Get the real renderer.font associated with a widget.font. +---@param font? widget.font +---@return renderer.font +function Widget:get_font(font) + if not font then font = self.font end + local font_type = type(font) + if font_type == "userdata" then + return font + elseif font_type == "string" then + return style[font] + elseif font and font.container then + return font.container[font.name] + end + if not font then + return style.font + end + return font +end + +---Get the relative position in relation to parent +---@return widget.position +function Widget:get_position() + local position = { x = self.position.x, y = self.position.y } + if self.parent then + position.x = self.position.rx + position.y = self.position.ry + end + return position +end + +---Get width including borders. +---@return number +function Widget:get_width() + return self.size.x + (self.border.width * 2) +end + +---Get height including borders. +---@return number +function Widget:get_height() + return self.size.y + (self.border.width * 2) +end + +---Get the right x coordinate relative to parent +---@return number +function Widget:get_right() + return self:get_position().x + self:get_width() +end + +---Get the bottom y coordinate relative to parent +---@return number +function Widget:get_bottom() + return self:get_position().y + self:get_height() +end + +---Overall height of the widget accounting all visible child widgets. +---@return number +function Widget:get_real_height() + local size = 0 + for _, child in pairs(self.childs) do + if child.visible then + size = math.max(size, child:get_bottom()) + end + end + return size +end + +---Overall width of the widget accounting all visible child widgets. +---@return number +function Widget:get_real_width() + local size = 0 + for _, child in pairs(self.childs) do + if child.visible then + size = math.max(size, child:get_right()) + end + end + return size +end + +---Check if the given mouse coordinate is hovering the widget +---@param x number +---@param y number +---@return boolean +function Widget:mouse_on_top(x, y) + return + self.visible + and + x >= self.position.x - self.border.width + and + x <= self.position.x - self.border.width + self:get_width() + and + y >= self.position.y - self.border.width + and + y <= self.position.y - self.border.width + self:get_height() +end + +---Mark a widget as having focus. +---TODO: Implement set focus system by pressing a key like tab? +function Widget:set_focus(has_focus) + self.set_focus = has_focus +end + +---Text displayed when the widget is hovered. +---@param tooltip string +function Widget:set_tooltip(tooltip) + self.tooltip = tooltip +end + +---A text label for the widget, not all widgets support this. +---@param text string | widget.styledtext +function Widget:set_label(text) + self.label = text +end + +---Used internally when dragging is activated. +---@param x number +---@param y number +function Widget:drag(x, y) + if self.position.dx and self.position.dy then + self:set_position(x - self.position.dx, y - self.position.dy) + end +end + +---Center the widget horizontally and vertically to the screen or parent widget. +function Widget:centered() + local w, h = system.get_window_size(core.window); + if self.parent then + w = self.parent:get_width() + h = self.parent:get_height() + end + self:set_position( + (w / 2) - (self:get_width() / 2), + (h / 2) - (self:get_height() / 2) + ) +end + +---Replaces current active child with a new one and calls the +---activate/deactivate events of the child. This is especially +---used to send text input events to widgets with input_text support. +---@param child? widget If nil deactivates current child +function Widget:swap_active_child(child) + if self.parent then + self.parent:swap_active_child(child) + return + end + + if child and child == self.child_active then return end + + local active_child = self.child_active + + if self.child_active then + self.child_active:deactivate() + end + + self.child_active = child + + if child then + if not self.prev_view then + self.prev_view = core.active_view + end + core.set_active_view(child.input_text and child.textview or child) + self.child_active:activate() + elseif self.prev_view then + -- return focus to previous view + if self.prev_view ~= active_child then + core.set_active_view(self.prev_view) + else + core.set_active_view(self) + end + self.prev_view = nil + end +end + +---Calculates the y scrollable size taking into account the bottom most +---widget or the size of the widget it self if greater. +---@return number +function Widget:get_scrollable_size() + return math.max(self.size.y, self:get_real_height()) +end + +---Calculates the x scrollable size taking into account the right most +---widget or the size of the widget it self if greater. +---@return number +function Widget:get_h_scrollable_size() + return math.max(self.size.x, self:get_real_width()) +end + +---The name that is displayed on lite-xl tabs. +function Widget:get_name() + return self.parent and self.parent:get_name() or self.name +end + +-- +-- Events +-- + +---Send file drop event to hovered child. +---@param filename string +---@param x number +---@param y number +---@return boolean processed +function Widget:on_file_dropped(filename, x, y) + if not self.visible then return false end + + for _, child in pairs(self.childs) do + if child:mouse_on_top(x, y) then + return child:on_file_dropped(filename, x, y) + end + end + + return false +end + +---Redirects any text input to active child with the input_text flag. +---@param text string +---@return boolean processed +function Widget:on_text_input(text) + if not self.visible then return false end + + Widget.super.on_text_input(self, text) + + if self.child_active then + self.child_active:on_text_input(text) + return true + end + + return false +end + +---All mouse events will be directly sent to the widget even if mouse moves +---outside the widget region. +---@param scrolling? boolean Capture for scrolling +function Widget:capture_mouse(scrolling) + local parent = self.parent + while parent do + -- propagate to parents so if mouse is not on top still + -- reach the childrens when the mouse is released + if scrolling then parent.is_scrolling = true end + parent.captured_widget = self + parent = parent.parent + end + if scrolling then self.is_scrolling = true end +end + +---Undo capture_mouse() +function Widget:release_mouse() + local parent = self.parent + while parent do + -- propagate to parents so if mouse is not on top still + -- reach the childrens when the mouse is released + parent.is_scrolling = false + parent.captured_widget = nil + parent = parent.parent + end + self.is_scrolling = false +end + +---Send mouse pressed events to hovered child or starts dragging if enabled. +---@param button widget.clicktype +---@param x number +---@param y number +---@param clicks integer +---@return boolean processed +function Widget:on_mouse_pressed(button, x, y, clicks) + if not self.visible then return false end + + -- Capture when scrollbar is pressed + if Widget.super.on_mouse_pressed(self, button, x, y, clicks) then + self:capture_mouse(true) + return true + end + + for _, child in pairs(self.childs) do + if child:mouse_on_top(x, y) and child.clickable then + child:on_mouse_pressed(button, x, y, clicks) + return true + end + end + + if self:mouse_on_top(x, y) then + self.mouse_is_pressed = true + + if self.parent then + -- propagate to parents so if mouse is not on top still + -- reach the childrens when the mouse is released + self.parent.mouse_is_pressed = true + end + + if self.draggable and not self.child_active then + self.position.dx = x - self.position.x + self.position.dy = y - self.position.y + system.set_cursor("hand") + end + else + self:swap_active_child() + return false + end + + return true +end + +---Send mouse released events to hovered child, ends dragging if enabled and +---emits on click events if applicable. +---@param button widget.clicktype +---@param x number +---@param y number +---@return boolean processed +function Widget:on_mouse_released(button, x, y) + if not self.visible then return false end + + if widget_showing_tooltip then + widget_showing_tooltip = false + core.status_view:remove_tooltip() + end + + if self.captured_widget then + self.captured_widget:on_mouse_released(button, x, y) + if self.is_scrolling then + self.captured_widget:release_mouse() + end + return true + end + + Widget.super.on_mouse_released(self, button, x, y) + + self:swap_active_child() + + if self.child_active then + self.child_active:on_mouse_released(button, x, y) + end + + if not self.dragged then + for _, child in pairs(self.childs) do + local mouse_on_top = child:mouse_on_top(x, y) + if + mouse_on_top + or + child.mouse_is_pressed + then + child:on_mouse_released(button, x, y) + if child.input_text then + self:swap_active_child(child) + end + if mouse_on_top and child.mouse_is_pressed then + child:on_click(button, x, y) + end + return true + end + end + end + + if + not self.dragged + and + not self.mouse_is_pressed + then + return false + end + + if self.mouse_is_pressed then + if self:mouse_on_top(x, y) then + self:on_click(button, x, y) + end + self.mouse_is_pressed = false + if self.parent then + self.parent.mouse_is_pressed = false + end + if self.draggable then + system.set_cursor("arrow") + end + end + + self.dragged = false + + return true +end + +---Event emitted when the value of the widget changes. +---If applicable, child widgets should call this method +---when its value changes. +---@param value any +function Widget:on_change(value) end + +---Click event emitted on a succesful on_mouse_pressed +---and on_mouse_released events combo. +---@param button widget.clicktype +---@param x number +---@param y number +function Widget:on_click(button, x, y) end + +---Emitted to input_text widgets when clicked. +function Widget:activate() end + +---Emitted to input_text widgets on lost focus. +function Widget:deactivate() end + +---Besides the on_mouse_moved this event emits on_mouse_enter +---and on_mouse_leave for easy hover effects. Also, if the +---widget is scrollable and pressed this will drag it unless +---there is an active input_text child active. +---@param x number +---@param y number +---@param dx number +---@param dy number +function Widget:on_mouse_moved(x, y, dx, dy) + if not self.visible then return false end + + if self.captured_widget then + self.captured_widget:on_mouse_moved(x, y, dx, dy) + return true + end + + Widget.super.on_mouse_moved(self, x, y, dx, dy) + + -- store latest mouse coordinates for usage on the on_mouse_wheel event. + self.mouse.x = x + self.mouse.y = y + + if self.child_active then + self.child_active:on_mouse_moved(x, y, dx, dy) + end + + if not self.dragged then + local hovered = nil + for _, child in pairs(self.childs) do + if + not hovered + and + (child:mouse_on_top(x, y) or child.mouse_is_pressed) + then + hovered = child + elseif child.mouse_is_hovering then + if widget_showing_tooltip then + widget_showing_tooltip = false + core.status_view:remove_tooltip() + end + child.mouse_is_hovering = false + child:on_mouse_leave(x, y, dx, dy) + system.set_cursor("arrow") + end + end + + if hovered then + hovered:on_mouse_moved(x, y, dx, dy) + if last_hovered_child and not last_hovered_child:mouse_on_top(x, y) then + last_hovered_child:on_mouse_leave(x, y, dx, dy) + last_hovered_child.mouse_is_hovering = false + last_hovered_child = nil + end + return true; + end + end + + if + not self:mouse_on_top(x, y) + and + not self.mouse_is_pressed + and + not self.mouse_is_hovering + then + return false + end + + local is_over = true + + if self:mouse_on_top(x, y) then + if not self.mouse_is_hovering then + system.set_cursor("arrow") + self.mouse_is_hovering = true + if #self.tooltip > 0 then + widget_showing_tooltip = true + core.status_view:show_tooltip(self.tooltip) + end + self:on_mouse_enter(x, y, dx, dy) + last_hovered_child = self + end + else + self:on_mouse_leave(x, y, dx, dy) + self.mouse_is_hovering = false + is_over = false + end + + if not self.child_active and self.mouse_is_pressed and self.draggable then + system.set_cursor("hand") + self:drag(x, y) + self.dragged = true + return true + end + + return is_over +end + +---Emitted once when the mouse hovers the widget. +function Widget:on_mouse_enter(x, y, dx, dy) + for _, child in pairs(self.childs) do + if child:mouse_on_top(x, y) then + child:on_mouse_enter(x, y, dx, dy) + break + end + end +end + +---Emitted once when the mouse leaves the widget. +function Widget:on_mouse_leave(x, y, dx, dy) + for _, child in pairs(self.childs) do + if child.mouse_is_hovering then + child:on_mouse_leave(x, y, dx, dy) + end + end +end + +function Widget:on_mouse_left() + if not self.captured_widget then + Widget.super.on_mouse_left(self) + self:on_mouse_moved(-1, -1, -1, -1) + end +end + +function Widget:on_mouse_wheel(y, x) + if + not self.visible + or + not self:mouse_on_top(self.mouse.x, self.mouse.y) + then + return false + else + local ctrl_pressed = false + if self.skip_scroll_ctrl and not self.parent then + local ctrl_key = PLATFORM == "Mac OS X" and "cmd" or "ctrl" + ctrl_pressed = keymap.modkeys[ctrl_key] + -- ensure only ctrl/cmd is pressed + if ctrl_pressed then + for key, status in pairs(keymap.modkeys) do + if key ~= ctrl_key and status then + ctrl_pressed = false + break + end + end + end + end + if ctrl_pressed then return false end + end + + for _, child in pairs(self.childs) do + if child:mouse_on_top(self.mouse.x, self.mouse.y) then + if child:on_mouse_wheel(y, x) then + return true + end + end + end + + if self.scrollable then + if keymap.modkeys["shift"] then + x = y + y = 0 + end + if y and y ~= 0 then + self.scroll.to.y = self.scroll.to.y + y * -config.mouse_wheel_scroll + end + if x and x ~= 0 then + self.scroll.to.x = self.scroll.to.x + x * -config.mouse_wheel_scroll + end + return true + end + + return false +end + +---Can be overriden by widgets to listen for scale change events to apply +---any neccesary changes in sizes, padding, etc... +---@param new_scale number +---@param prev_scale number +function Widget:on_scale_change(new_scale, prev_scale) + local font_type = type(self.font) + if + font_type == "userdata" + or + (font_type == "table" and not self.font.container) + then + self.font:set_size( + self.font:get_size() * (new_scale / prev_scale) + ) + end +end + +---Registers a new animation to be ran on the update cycle. +---@param target? table If nil assumes properties belong to widget it self. +---@param properties table +---@param options? widget.animation.options +function Widget:animate(target, properties, options) + if not target then target = self end + + -- if name is set then prevent adding if another one with the same + -- animation name is already running + if options and options.name then + for _, animation in ipairs(self.animations) do + if animation.options and animation.options.name == options.name then + return + end + end + end + + table.insert(self.animations, { + target = target, + properties = properties, + options = options + }) +end + +---Runs all registered animations removing duplicated and finished ones. +function Widget:run_animations() + if #self.animations > 0 then + ---@type table + local duplicates = {} + + local targets = {} + local deleted = 0 + for i=1, #self.animations do + local animation = self.animations[i - deleted] + + -- do not run animations that change same target to prevent conflicts. + if not targets[animation.target] then + local finished = true + local options = animation.options or {} + for name, value in pairs(animation.properties) do + if animation.target[name] ~= value then + self:move_towards(animation.target, name, value, options.rate) + if options.on_step then + options.on_step(animation.target, name, animation.target[name]) + end + if animation.target[name] ~= value then + finished = false + end + end + end + if finished then + if options.on_complete then + options.on_complete(self) + end + table.remove(self.animations, i - deleted) + deleted = deleted + 1 + end + targets[animation.target] = animation + -- only registers it as duplicated if the animation does needs to + -- perform any tasks on completion. + elseif not targets[animation.target].on_complete then + duplicates[targets[animation.target]] = animation + end + end + + -- remove older duplcated animations that modify same target and properties + for duplicate, newer_animation in pairs(duplicates) do + local exact_properties = true + for name, _ in pairs(duplicate.properties) do + if not newer_animation.properties[name] then + exact_properties = false + break + end + end + if exact_properties then + for name, _ in pairs(newer_animation.properties) do + if not duplicate.properties[name] then + exact_properties = false + break + end + end + end + if exact_properties then + for i, animation in ipairs(self.animations) do + if animation == duplicate then + table.remove(self.animations, i) + break + end + end + end + end + end +end + +---If visible execute the widget calculations and returns true. +---@return boolean +function Widget:update() + if not self:is_visible() then return false end + + Widget.super.update(self) + + -- call this to be able to properly scroll + self:update_position() + + -- run any pending animations + self:run_animations() + + for _, child in pairs(self.childs) do + child:update() + end + + return true +end + +function Widget:draw_scrollbar() + if self.scrollable then + Widget.super.draw_scrollbar(self) + end +end + +---If visible draw the widget and returns true. +---@return boolean +function Widget:draw() + if not self:is_visible() then return false end + + Widget.super.draw(self) + + self:draw_border() + + if self.render_background then + if self.background_color then + self:draw_background(self.background_color) + else + self:draw_background( + self.parent and style.background or style.background2 + ) + end + end + + if #self.childs > 0 then + core.push_clip_rect( + self.position.x, + self.position.y, + self.size.x, + self.size.y + ) + end + + for i=#self.childs, 1, -1 do + self.childs[i]:draw() + end + + if #self.childs > 0 then + core.pop_clip_rect() + end + + self:draw_scrollbar() + + return true +end + +---Recursively destroy all childs from the widget. +function Widget:destroy_childs() + for _=1, #self.childs do + self.childs[1]:destroy_childs() + table.remove(self.childs, 1) + end +end + +---If floating, remove the widget from the floating widgets list +---to allow proper garbage collection. +function Widget:destroy() + if not self.parent or self.defer_draw then + for idx, widget in ipairs(floating_widgets) do + if widget == self then + widget:destroy_childs() + floating_widgets[idx] = nil + table.remove(floating_widgets, idx) + break + end + end + end +end + +---Toggle the forced interception of given event even if all the conditions +---for emitting it are not met. +--- +---Note: only "mouse_released" is implemented for the moment on floating views +---for use in the SelectBox, maybe a better system can be implemented on +---the future. +---@param name "mouse_released" +---@param force boolean If omitted is set to true by default +function Widget:force_event(name, force) + if type(force) ~= "boolean" or force then + self.force_events[name] = true + else + self.force_events[name] = nil + end +end + +---Flag that indicates if the rootview events are already overrided. +---@type boolean +local root_overrided = false + +---Called when initializing a floating widget to generate RootView overrides, +---this function will only override the events once. +function Widget.override_rootview() + if root_overrided then return end + root_overrided = true + + if not RootView then RootView = require "core.rootview" end + + local root_view_on_mouse_pressed = RootView.on_mouse_pressed + local root_view_on_mouse_released = RootView.on_mouse_released + local root_view_on_mouse_moved = RootView.on_mouse_moved + local root_view_on_mouse_wheel = RootView.on_mouse_wheel + local root_view_update = RootView.update + local root_view_draw = RootView.draw + local root_view_on_file_dropped = RootView.on_file_dropped + local root_view_on_text_input = RootView.on_text_input + + function RootView:on_mouse_pressed(button, x, y, clicks) + local pressed = false + for i=#floating_widgets, 1, -1 do + local widget = floating_widgets[i] + if widget.visible then + widget.mouse_pressed_outside = not widget:mouse_on_top(x, y) + if + (not widget.defer_draw and not widget.child_active) + or + widget.mouse_pressed_outside + or + (pressed or not widget:on_mouse_pressed(button, x, y, clicks)) + then + widget:swap_active_child() + else + pressed = true + end + end + end + if not pressed then + return root_view_on_mouse_pressed(self, button, x, y, clicks) + else + return true + end + end + + function RootView:on_mouse_released(button, x, y) + local released = false + for i=#floating_widgets, 1, -1 do + local widget = floating_widgets[i] + if widget.visible then + if + (not widget.defer_draw and not widget.child_active) + or + ( + not widget.force_events["mouse_released"] + and + widget.mouse_pressed_outside + ) + or + not widget:on_mouse_released(button, x, y) + then + widget.mouse_pressed_outside = false + else + released = true + end + end + end + if not released then + root_view_on_mouse_released(self, button, x, y) + end + end + + function RootView:on_mouse_moved(x, y, dx, dy) + if core.active_view ~= core.command_view then + for i=#floating_widgets, 1, -1 do + local widget = floating_widgets[i] + if widget.visible then + if + (not widget.defer_draw and not widget.child_active) + or + widget.mouse_pressed_outside + or + not widget:on_mouse_moved(x, y, dx, dy) + then + if + not widget.is_scrolling and not widget.captured_widget + and + not widget.child_active and widget.outside_view + then + core.set_active_view(widget.outside_view) + widget.outside_view = nil + elseif widget.outside_view then + core.request_cursor("arrow") + end + else + if not widget.child_active and widget.defer_draw then + if not widget.outside_view then + widget.outside_view = core.active_view + end + core.set_active_view(widget) + end + return true + end + end + end + end + return root_view_on_mouse_moved(self, x, y, dx, dy) + end + + function RootView:on_mouse_wheel(y, x) + for i=#floating_widgets, 1, -1 do + local widget = floating_widgets[i] + if + widget.visible and widget.defer_draw and widget:on_mouse_wheel(y, x) + then + return true + end + end + return root_view_on_mouse_wheel(self, y, x) + end + + function RootView:on_file_dropped(filename, x, y) + for i=#floating_widgets, 1, -1 do + local widget = floating_widgets[i] + if + widget.visible and widget.defer_draw + and + widget:on_file_dropped(filename, x, y) + then + return true + end + end + return root_view_on_file_dropped(self, filename, x, y) + end + + function RootView:on_text_input(text) + for i=#floating_widgets, 1, -1 do + local widget = floating_widgets[i] + if + widget.visible and widget.defer_draw and widget:on_text_input(text) + then + return true + end + end + return root_view_on_text_input(self, text) + end + + function RootView:update() + root_view_update(self) + local count = #floating_widgets + for i=1, count, 1 do + local widget = floating_widgets[i] + if widget.visible and widget.defer_draw then + widget:update() + end + end + end + + function RootView:draw() + local count = #floating_widgets + for i=1, count, 1 do + local widget = floating_widgets[i] + if widget.visible and widget.defer_draw then + core.root_view:defer_draw(widget.draw, widget) + end + end + root_view_draw(self) + end +end + + +return Widget diff --git a/resources/amiga/config/lite-xl/libraries/widget/inputdialog.lua b/resources/amiga/config/lite-xl/libraries/widget/inputdialog.lua new file mode 100644 index 00000000..793dd8f4 --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/inputdialog.lua @@ -0,0 +1,88 @@ +-- +-- Input Dialog Widget. +-- @copyright Jefferson Gonzalez +-- @license MIT +-- + +local style = require "core.style" +local Button = require "libraries.widget.button" +local Dialog = require "libraries.widget.dialog" +local Label = require "libraries.widget.label" +local TextBox = require "libraries.widget.textbox" + +---@class widget.inputdialog : widget.dialog +---@overload fun(title?:string, message?:string, text?:string):widget.inputdialog +---@field super widget.dialog +---@field message widget.label +---@field text widget.textbox +---@field save widget.button +---@field cancel widget.button +local InputDialog = Dialog:extend() + +---Constructor +---@param title string +---@param message string +---@param text string +function InputDialog:new(title, message, text) + InputDialog.super.new(self, title or "Enter Value") + + self.type_name = "widget.inputdialog" + + self.message = Label(self.panel, message) + self.text = TextBox(self.panel, text or "") + + local this = self + + self.save = Button(self.panel, "Save") + self.save:set_icon("S") + function self.save:on_click() + this:on_save(this.text:get_text()) + this:on_close() + end + + self.cancel = Button(self.panel, "Cancel") + self.cancel:set_icon("C") + function self.cancel:on_click() + this:on_close() + end +end + +---Called when the user clicks on save +---@param value string +function InputDialog:on_save(value) end + +function InputDialog:update() + if not InputDialog.super.update(self) then return false end + + self.message:set_position(style.padding.x/2, 0) + self.text:set_position(style.padding.x/2, self.message:get_bottom() + style.padding.y) + + self.save:set_position( + style.padding.x/2, + self.text:get_bottom() + style.padding.y + ) + self.cancel:set_position( + self.save:get_right() + style.padding.x, + self.text:get_bottom() + style.padding.y + ) + + self.panel.size.x = self.panel:get_real_width() + style.padding.x + self.panel.size.y = self.panel:get_real_height() + self.size.x = self:get_real_width() - (style.padding.x / 2) + self.size.y = self:get_real_height() + (style.padding.y / 2) + + self.text:set_size( + self.size.x - style.padding.x, + self.text:get_real_height() + ) + + self.close:set_position( + self.size.x - self.close.size.x - (style.padding.x / 2), + style.padding.y / 2 + ) + + return true +end + + +return InputDialog diff --git a/resources/amiga/config/lite-xl/libraries/widget/itemslist.lua b/resources/amiga/config/lite-xl/libraries/widget/itemslist.lua new file mode 100644 index 00000000..5e706c29 --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/itemslist.lua @@ -0,0 +1,162 @@ +-- +-- ItemsList Widget. +-- @copyright Jefferson Gonzalez +-- @license MIT +-- +local style = require "core.style" +local Widget = require "libraries.widget" +local Button = require "libraries.widget.button" +local ListBox = require "libraries.widget.listbox" +local InputDialog = require "libraries.widget.inputdialog" +local MessageBox = require "libraries.widget.messagebox" + +---@class widget.itemslist : widget +---@overload fun(parent:widget?):widget.itemslist +---@field list widget.listbox +---@field add widget.button +---@field remove widget.button +local ItemsList = Widget:extend() + +---Constructor +---@param parent widget +function ItemsList:new(parent) + ItemsList.super.new(self, parent) + + self.type_name = "widget.itemslist" + + self.border.width = 0 + + self.dialog = false + + self.list = ListBox(self) + + local this = self + + function self.list:on_mouse_pressed(button, x, y, clicks) + if not ListBox.on_mouse_pressed(self, button, x, y, clicks) then + return false + end + + if clicks == 2 and not this.dialog then + this.dialog = true + local selected = this.list:get_selected() + local selvalue = this.list:get_row_text(selected) + ---@type widget.inputdialog + local input = InputDialog("Edit Item", "Enter the new item value:", selvalue) + function input:on_save(value) + this:edit_item(selected, value) + end + function input:on_close() + InputDialog.on_close(self) + self:destroy() + this.dialog = false + end + input:show() + end + + return true + end + + self.add = Button(self, "Add") + self.add:set_icon("B") + function self.add:on_click() + if not this.dialog then + ---@type widget.inputdialog + local input = InputDialog("Add Item", "Enter the new item:") + function input:on_save(value) + this:add_item(value) + end + function input:on_close() + InputDialog.on_close(self) + self:destroy() + this.dialog = false + end + input:show() + end + end + + self.remove = Button(self, "Remove") + self.remove:set_icon("C") + function self.remove:on_click() + local selected = this.list:get_selected() + if selected then + this:remove_item(selected) + else + MessageBox.error("No item selected", "Please select an item to remove") + end + end +end + +---Add a new item into the list. +---@param text widget.styledtext | string +---@param data any +function ItemsList:add_item(text, data) + if type(text) == "string" then + text = {text} + end + self.list:add_row(text, data) + self.list:set_visible_rows() + self:on_change() +end + +---Edit an existing item on the list. +---@param idx integer +---@param text widget.styledtext | string +---@param data any +function ItemsList:edit_item(idx, text, data) + if type(text) == "string" then + text = {text} + end + self.list:set_row(idx, text) + if data then + self.list:set_row_data(idx, data) + end + self:on_change() +end + +---Remove the given item from the list. +---@param idx integer +function ItemsList:remove_item(idx) + self.list:remove_row(idx) + self:on_change() +end + +---Return the items from the list. +---@return table +function ItemsList:get_items() + local output = {} + local count = #self.list.rows + for i=1, count, 1 do + table.insert(output, self.list:get_row_text(i)) + end + return output +end + +function ItemsList:update() + if not ItemsList.super.update(self) then return false end + + if self.size.x == 0 then + self.size.x = self.add:get_width() + + (style.padding.x / 2) + self.remove:get_width() + (50 * SCALE) + self.size.y = self.add:get_height() + (style.padding.y * 2) + 100 + end + + self.list:set_position(0, 0) + + self.list:set_size( + self.size.x, + self.size.y - self.add:get_height() - (style.padding.y * 2) + ) + + self.add:set_position(0, self.list:get_bottom() + style.padding.y) + + self.remove:set_position( + self.add:get_right() + (style.padding.x / 2), + self.list:get_bottom() + style.padding.y + ) + + return true +end + + +return ItemsList diff --git a/resources/amiga/config/lite-xl/libraries/widget/keybinddialog.lua b/resources/amiga/config/lite-xl/libraries/widget/keybinddialog.lua new file mode 100644 index 00000000..c54f8abb --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/keybinddialog.lua @@ -0,0 +1,257 @@ +-- +-- KeyBinding Dialog Widget. +-- @copyright Jefferson Gonzalez +-- @license MIT +-- + +local keymap = require "core.keymap" +local style = require "core.style" +local Button = require "libraries.widget.button" +local Dialog = require "libraries.widget.dialog" +local Label = require "libraries.widget.label" +local Line = require "libraries.widget.line" +local ListBox = require "libraries.widget.listbox" +local MessageBox = require "libraries.widget.messagebox" + +---@type widget.keybinddialog +local current_dialog = nil + +---@class widget.keybinddialog : widget.dialog +---@overload fun():widget.keybinddialog +---@field super widget.dialog +---@field selected integer +---@field shortcuts widget.listbox +---@field add widget.button +---@field remove widget.button +---@field line widget.line +---@field message widget.label +---@field binding widget.label +---@field mouse_intercept widget.label +---@field save widget.button +---@field reset widget.button +---@field cancel widget.button +local KeybindDialog = Dialog:extend() + +---Constructor +function KeybindDialog:new() + KeybindDialog.super.new(self, "Keybinding Selector") + + self.type_name = "widget.keybinddialog" + + self.selected = nil + + local this = self + + self.shortcuts = ListBox(self.panel) + self.shortcuts:set_size(100, 100) + function self.shortcuts:on_row_click(idx, data) + this.selected = idx + end + + self.add = Button(self.panel, "Add") + self.add:set_icon("B") + function self.add:on_click() + this.shortcuts:add_row({"none"}) + this.shortcuts:set_selected(#this.shortcuts.rows) + this.shortcuts:set_visible_rows() + this.selected = #this.shortcuts.rows + end + + self.remove = Button(self.panel, "Remove") + self.remove:set_icon("C") + function self.remove:on_click() + local selected = this.shortcuts:get_selected() + if selected then + this.shortcuts:remove_row(selected) + this.shortcuts:set_selected(nil) + this.selected = nil + else + MessageBox.error("No shortcut selected", "Please select an shortcut to remove") + end + end + + self.line = Line(self.panel) + + self.message = Label(self.panel, "Press a key combination to change selected") + + self.mouse_intercept = Label(self.panel, "Grab mouse events here") + self.mouse_intercept.border.width = 1 + self.mouse_intercept.clickable = true + self.mouse_intercept:set_size(100, 100) + function self.mouse_intercept:on_mouse_pressed(button, x, y, clicks) + keymap.on_mouse_pressed(button, x, y, clicks) + return true + end + function self.mouse_intercept:on_mouse_wheel(y) + keymap.on_mouse_wheel(y) + return true + end + function self.mouse_intercept:on_mouse_enter(...) + Label.super.on_mouse_enter(self, ...) + self.border.color = style.caret + end + function self.mouse_intercept:on_mouse_leave(...) + Label.super.on_mouse_leave(self, ...) + self.border.color = style.text + end + + self.save = Button(self.panel, "Save") + self.save:set_icon("S") + function self.save:on_click() + this:on_save(this:get_bindings()) + this:on_close() + end + + self.reset = Button(self.panel, "Reset") + function self.reset:on_click() + this:on_reset() + this:on_close() + end + + self.cancel = Button(self.panel, "Cancel") + self.cancel:set_icon("C") + function self.cancel:on_click() + this:on_close() + end +end + +---@param bindings table +function KeybindDialog:set_bindings(bindings) + self.shortcuts:clear() + for _, binding in ipairs(bindings) do + self.shortcuts:add_row({binding}) + end + if #bindings > 0 then + self.shortcuts:set_selected(1) + self.selected = 1 + end + self.shortcuts:set_visible_rows() +end + +---@return table +function KeybindDialog:get_bindings() + local bindings = {} + for idx=1, #self.shortcuts.rows, 1 do + table.insert(bindings, self.shortcuts:get_row_text(idx)) + end + return bindings +end + +---Show the dialog and enable key interceptions +function KeybindDialog:show() + current_dialog = self + KeybindDialog.super.show(self) +end + +---Hide the dialog and disable key interceptions +function KeybindDialog:hide() + current_dialog = nil + KeybindDialog.super.hide(self) +end + +---Called when the user clicks on save +---@param bindings string +function KeybindDialog:on_save(bindings) end + +---Called when the user clicks on reset +function KeybindDialog:on_reset() end + +function KeybindDialog:update() + if not KeybindDialog.super.update(self) then return false end + + self.shortcuts:set_position(style.padding.x/2, 0) + + self.add:set_position( + style.padding.x/2, + self.shortcuts:get_bottom() + style.padding.y + ) + + self.remove:set_position( + self.add:get_right() + (style.padding.x/2), + self.shortcuts:get_bottom() + style.padding.y + ) + + self.line:set_position( + 0, + self.remove:get_bottom() + style.padding.y + ) + + self.message:set_position( + style.padding.x/2, + self.line:get_bottom() + style.padding.y + ) + self.mouse_intercept:set_position( + style.padding.x/2, + self.message:get_bottom() + style.padding.y + ) + + self.save:set_position( + style.padding.x/2, + self.mouse_intercept:get_bottom() + style.padding.y + ) + self.reset:set_position( + self.save:get_right() + style.padding.x, + self.mouse_intercept:get_bottom() + style.padding.y + ) + self.cancel:set_position( + self.reset:get_right() + style.padding.x, + self.mouse_intercept:get_bottom() + style.padding.y + ) + + self.panel.size.x = self.panel:get_real_width() + style.padding.x + self.panel.size.y = self.panel:get_real_height() + self.size.x = self:get_real_width() - (style.padding.x / 2) + self.size.y = self:get_real_height() + (style.padding.y / 2) + + self.shortcuts:set_size(self.size.x - style.padding.x) + + self.line:set_width(self.size.x - style.padding.x) + + self.mouse_intercept:set_size(self.size.x - style.padding.x) + + return true +end + +-------------------------------------------------------------------------------- +-- Intercept keymap events +-------------------------------------------------------------------------------- + +-- Same declarations as in core.keymap because modkey_map is not public +local macos = PLATFORM == "Mac OS X" +local modkeys_os = require("core.modkeys-" .. (macos and "macos" or "generic")) +local modkey_map = modkeys_os.map +local modkeys = modkeys_os.keys + +---Copied from core.keymap because it is not public +local function key_to_stroke(k) + local stroke = "" + for _, mk in ipairs(modkeys) do + if keymap.modkeys[mk] then + stroke = stroke .. mk .. "+" + end + end + return stroke .. k +end + +local keymap_on_key_pressed = keymap.on_key_pressed +function keymap.on_key_pressed(k, ...) + if current_dialog and current_dialog.selected then + local mk = modkey_map[k] + if mk then + keymap.modkeys[mk] = true + -- work-around for windows where `altgr` is treated as `ctrl+alt` + if mk == "altgr" then + keymap.modkeys["ctrl"] = false + end + current_dialog.shortcuts:set_row(current_dialog.selected, {key_to_stroke("")}) + else + current_dialog.shortcuts:set_row(current_dialog.selected, {key_to_stroke(k)}) + end + return true + else + return keymap_on_key_pressed(k, ...) + end +end + + +return KeybindDialog diff --git a/resources/amiga/config/lite-xl/libraries/widget/label.lua b/resources/amiga/config/lite-xl/libraries/widget/label.lua new file mode 100644 index 00000000..1c05264c --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/label.lua @@ -0,0 +1,95 @@ +-- +-- Label Widget. +-- @copyright Jefferson Gonzalez +-- @license MIT +-- + +local style = require "core.style" +local Widget = require "libraries.widget" + +---@class widget.label : widget +---@overload fun(parent:widget?, label?:string):widget.label +---@field clickable boolean +local Label = Widget:extend() + +---Constructor +---@param parent widget +---@param label string +function Label:new(parent, label) + Label.super.new(self, parent) + self.type_name = "widget.label" + self.clickable = false + self.border.width = 0 + self.custom_size = {x = 0, y = 0} + + self:set_label(label or "") +end + +---@param width? integer +---@param height? integer +function Label:set_size(width, height) + Label.super.set_size(self, width, height) + self.custom_size.x = self.size.x + self.custom_size.y = self.size.y +end + +---Set the label text and recalculates the widget size. +---@param text string|widget.styledtext +function Label:set_label(text) + Label.super.set_label(self, text) + + local font = self:get_font() + + if self.custom_size.x <= 0 then + if type(text) == "table" then + self.size.x, self.size.y = self:draw_styled_text(text, 0, 0, true) + else + self.size.x = font:get_width(self.label) + self.size.y = font:get_height() + end + + if self.border.width > 0 then + self.size.x = self.size.x + style.padding.x + self.size.y = self.size.y + style.padding.y + end + end +end + +function Label:update() + if not Label.super.update(self) then return false end + + if self.custom_size.x <= 0 then + -- update the size + self:set_label(self.label) + end + + return true +end + +function Label:draw() + if not self:is_visible() then return false end + + self:draw_border() + + local px = self.border.width > 0 and (style.padding.x / 2) or 0 + local py = self.border.width > 0 and (style.padding.y / 2) or 0 + + local posx, posy = self.position.x + px, self.position.y + py + + if type(self.label) == "table" then + self:draw_styled_text(self.label, posx, posy) + else + renderer.draw_text( + self:get_font(), + self.label, + posx, + posy, + self.foreground_color or style.text + ) + end + + return true +end + + +return Label diff --git a/resources/amiga/config/lite-xl/libraries/widget/line.lua b/resources/amiga/config/lite-xl/libraries/widget/line.lua new file mode 100644 index 00000000..a5152f3b --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/line.lua @@ -0,0 +1,61 @@ +-- +-- Line Widget. +-- @copyright Jefferson Gonzalez +-- @license MIT +-- + +local style = require "core.style" +local Widget = require "libraries.widget" + +---@class widget.line : widget +---@overload fun(parent?:widget, thickness?:integer, padding?:number):widget.line +---@field public padding integer +---@field private custom_width number +local Line = Widget:extend() + +---Constructor +---@param parent widget +---@param thickness integer +---@param padding number +function Line:new(parent, thickness, padding) + Line.super.new(self, parent) + self.type_name = "widget.line" + self.size.y = thickness or 2 + self.custom_width = nil + self.border.width = 0 + self.padding = padding or (style.padding.x / 2) +end + +---Set the thickness of the line +---@param thickness number +function Line:set_thickness(thickness) + self.size.y = thickness or 2 +end + +---Set a custom width for the line +---@param width number +function Line:set_width(width) + self.custom_width = width + self.size.x = width +end + +function Line:draw() + if not self:is_visible() then return false end + + if not self.custom_width then + self.size.x = self.parent.size.x - (self.padding * 2) + end + + renderer.draw_rect( + self.position.x + self.padding, + self.position.y, + self.size.x, + self.size.y, + self.foreground_color or style.caret + ) + + return true +end + + +return Line diff --git a/resources/amiga/config/lite-xl/libraries/widget/listbox.lua b/resources/amiga/config/lite-xl/libraries/widget/listbox.lua new file mode 100644 index 00000000..2b5333a2 --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/listbox.lua @@ -0,0 +1,926 @@ +-- +-- ListBox Widget. +-- @copyright Jefferson Gonzalez +-- @license MIT +-- + +local core = require "core" +local style = require "core.style" +local Widget = require "libraries.widget" +local MessageBox = require "libraries.widget.messagebox" + +---@class widget.listbox.column +---@field public name string +---@field public width string +---@field public expand boolean +---@field public longest integer + +---@alias widget.listbox.drawcol fun(self, row, x, y, font, color, only_calc) +---@alias widget.listbox.filtercb fun(self:widget.listbox, idx:integer, row:widget.listbox.row, data:any):number? + +---@alias widget.listbox.row table + +---@alias widget.listbox.colpos table + +---@class widget.listbox : widget +---@overload fun(parent:widget?):widget.listbox +---@field rows widget.listbox.row[] +---@field private row_data any +---@field private rows_original widget.listbox.row[] +---@field private row_data_original any +---@field private columns widget.listbox.column[] +---@field private positions widget.listbox.colpos[] +---@field private mouse widget.position +---@field private selected_row integer +---@field private hovered_row integer +---@field private largest_row integer +---@field private expand boolean +---@field private visible_rows table +---@field private visible_rendered boolean +---@field private last_scale integer +---@field private last_offset integer +local ListBox = Widget:extend() + +---Indicates on a widget.listbox.row that the end +---of a column was reached. +---@type integer +ListBox.COLEND = 1 + +---Indicates on a widget.listbox.row that a new line +---follows while still rendering the same column. +---@type integer +ListBox.NEWLINE = 2 + +---Constructor +---@param parent widget +function ListBox:new(parent) + ListBox.super.new(self, parent) + self.type_name = "widget.listbox" + self.scrollable = true + self.rows = {} + self.row_data = {} + self.rows_original = {} + self.row_data_original = {} + self.rows_idx_original = {} + self.columns = {} + self.positions = {} + self.selected_row = 0 + self.hovered_row = 0 + self.largest_row = 0 + self.expand = false + self.visible_rows = {} + self.visible_rendered = false + self.last_scale = 0 + self.last_offset = 0 + + self:set_size(200, (self:get_font():get_height() + (style.padding.y*2)) * 3) +end + +---Set which rows to show using the specified match string or callback, +---if nil all rows are restored. +---@param match? string | widget.listbox.filtercb +function ListBox:filter(match) + if (not match or match == "") and #self.rows_original > 0 then + self:clear() + for idx, row in ipairs(self.rows_original) do + self:add_row(row, self.row_data_original[idx]) + end + self.rows_original = {} + self.row_data_original = {} + self.rows_idx_original = {} + return + elseif match and match ~= "" then + self.rows_original = #self.rows_original > 0 + and self.rows_original or self.rows + self.row_data_original = #self.row_data_original > 0 + and self.row_data_original or self.row_data + self.rows_idx_original = {} + + self:clear() + + local match_type = type(match) + + local rows = {} + for idx, row in ipairs(self.rows_original) do + local score + if match_type == "function" then + score = match(self, idx, row, self.row_data_original[idx]) + else + score = system.fuzzy_match(self:get_row_text(row), match, false) + end + if score then + table.insert(rows, {row, self.row_data_original[idx], score, idx}) + end + end + + table.sort(rows, function(a, b) return a[3] > b[3] end) + + for _, row in ipairs(rows) do + self:add_row(row[1], row[2]) + table.insert(self.rows_idx_original, row[4]) + end + end +end + +---If no width is given column will be set to automatically +---expand depending on the longest element +---@param name string +---@param width? number +---@param expand? boolean +function ListBox:add_column(name, width, expand) + local column = { + name = name, + width = width or self:get_font():get_width(name), + expand = expand and expand or (width and false or true) + } + + table.insert(self.columns, column) +end + +---You can give it a table a la statusview style where you pass elements +---like fonts, colors, ListBox.COLEND, ListBox.NEWLINE and multiline strings. +---@param row widget.listbox.row +---@param data any Associated with the row and given to on_row_click() +function ListBox:add_row(row, data) + table.insert(self.rows, row) + table.insert(self.positions, self:get_col_positions(row)) + + if type(data) ~= "nil" then + self.row_data[#self.rows] = data + end + + -- increase columns width if needed + if #self.columns > 0 then + local ridx = #self.rows + for col, pos in ipairs(self.positions[ridx]) do + if self.columns[col].expand then + local w = self:draw_row_range(ridx, row, pos[1], pos[2], 1, 1, true) + + -- store the row with longest column for cheaper calculation + self.columns[col].width = math.max(self.columns[col].width, w) + if self.columns[col].width < w then + self.columns[col].longest = ridx + end + end + end + end + + -- precalculate the row size and position + self:calc_row_size_pos(#self.rows) +end + +---Calculate a row position and size and store it on the row it +---self on the fields x, y, w, h +---@param ridx integer +function ListBox:calc_row_size_pos(ridx) + local x = self.border.width + local y = self.border.width + + if ridx == 1 then + -- if columns are enabled leave some space to render them + if #self.columns > 0 then + y = y + self:get_font():get_height() + style.padding.y + end + else + y = y + self.rows[ridx-1].y + self.rows[ridx-1].h + end + + self:draw_row(ridx, x, y, true) +end + +---Recalculate all row sizes and positions which should be only required +---when lite-xl ui scale changes or a row is removed from the list +function ListBox:recalc_all_rows() + for ridx, _ in ipairs(self.rows) do + self:calc_row_size_pos(ridx) + end +end + +---Calculates the scrollable size based on the last row of the list. +---@return number +function ListBox:get_scrollable_size() + local size = self.size.y + local rows = #self.rows + if rows > 0 and self.rows[rows].y then + size = math.max(size, self.rows[rows].y + self.rows[rows].h) + end + return size +end + +function ListBox:get_h_scrollable_size() + local size = self.size.x + local rows = #self.rows + if rows > 0 and self.rows[rows].x then + size = math.max(size, self.rows[rows].x + self.rows[rows].w) + end + return size +end + +---Detects the rows that are visible each time the content is scrolled, +---this way the draw function will only process the visible rows. +function ListBox:set_visible_rows() + local _, oy = self:get_content_offset() + local h = self.size.y + + -- substract column heading from list height + local colh = 0 + if #self.columns > 0 then + colh = self:get_font():get_height() + style.padding.y + h = h - colh + end + + -- start from nearest row relative to scroll direction for + -- better performance on long lists + local idx, total, step = 1, #self.rows, 1 + if #self.visible_rows > 0 then + if oy < self.last_offset or not self.visible_rendered then + idx = self.visible_rows[1] + self.visible_rendered = true + else + idx = self.visible_rows[#self.visible_rows] + total = 1 + step = -1 + end + end + + oy = oy - self.position.y + + self.visible_rows = {} + local first_visible = false + local height = 0 + for i=idx, total, step do + local row = self.rows[i] + if row then + local top = row.y - colh + row.h + oy + local visible = false + local visible_area = h - top + if top < 0 and (top + row.h) > 0 then + visible = true + elseif top >= 0 and top < h then + visible = true + end + if visible and height <= h then + table.insert(self.visible_rows, i) + first_visible = true + -- store only the visible height + if top < 0 then + height = height + (top + row.h) + else + if visible_area > row.h then + height = height + row.h + else + height = height + visible_area + end + end + elseif first_visible then + table.insert(self.visible_rows, i) + break + end + end + end + + -- append one more row if possible to fill possible empty spaces of + -- incomplete row height calculation above (bad math skills workarounds) + local last_row = self.visible_rows[#self.visible_rows] + local first_row = self.visible_rows[1] + if #self.visible_rows > 0 then + if step == 1 then + if self.rows[last_row+1] then + table.insert(self.visible_rows, last_row+1) + end + else + if self.rows[first_row-2] and first_row-2 ~= 1 then + table.insert(self.visible_rows, first_row-2) + elseif self.rows[last_row+1] then + table.insert(self.visible_rows, last_row+1) + end + + -- sort for proper subsequent loop interations + table.sort( + self.visible_rows, + function(val1, val2) return val1 < val2 end + ) + + local frow = self.visible_rows[1] + for i, _ in ipairs(self.visible_rows) do + if self.rows[frow] then + self.visible_rows[i] = frow + frow = frow + 1 + end + end + if #self.visible_rows > 1 then + if + self.visible_rows[#self.visible_rows] + == + self.visible_rows[#self.visible_rows-1] + then + table.remove(self.visible_rows, #self.visible_rows) + end + end + end + end +end + +-- Solution to safely remove elements from array table: +-- found at https://stackoverflow.com/a/53038524 +local function array_remove(t, fnKeep) + local j, n = 1, #t; + + for i=1, n do + if (fnKeep(t, i, j)) then + if (i ~= j) then + t[j] = t[i]; + t[i] = nil; + end + j = j + 1; + else + t[i] = nil; + end + end + + return t; +end + +---Remove a given row index from the list. +---@param ridx integer +function ListBox:remove_row(ridx) + if not self.rows[ridx] then return end + + if #self.rows_idx_original > 0 then + MessageBox.error( + "Can not remove row", + "Rows can not be removed when the list is filtered." + ) + return + end + + local last_col = false + local row_y = self.rows[ridx].y + local row_h = self.rows[ridx].h + if ridx == #self.rows then + last_col = true + end + + local fields = { "rows", "positions", "row_data" } + for _, field in ipairs(fields) do + array_remove(self[field], function(_, i, _) + if i == ridx then + return false + end + return true + end) + end + for _, col in ipairs(self.columns) do + if col.longest == ridx then + col.longest = nil + end + end + + if not last_col and #self.rows > 0 then + for idx=ridx, #self.rows, 1 do + self.rows[idx].y = self.rows[idx].y - row_h + end + end + + local visible_removed = false + array_remove(self.visible_rows, function(t, i, _) + if t[i] == ridx then + visible_removed = true + return false + end + return true + end) + + -- make visible rows sequence correctly incremental + if visible_removed and #self.visible_rows > 0 then + local first_row = self.visible_rows[1] + for i, _ in ipairs(self.visible_rows) do + self.visible_rows[i] = first_row + first_row = first_row + 1 + end + self:set_visible_rows() + end +end + +---Set the row that is currently active/selected. +---@param idx? integer +function ListBox:set_selected(idx) + self.selected_row = idx or 0 +end + +---Get the row that is currently active/selected. +---@return integer | nil +function ListBox:get_selected() + if self.selected_row > 0 then + return self.selected_row + end + return nil +end + +---Change the content assigned to a row. +---@param idx integer +---@param row widget.listbox.row +function ListBox:set_row(idx, row) + --TODO: recalculate subsequent row sizes and max col width if needed + if self.rows[idx] then + self.rows[idx] = row + if #self.rows_idx_original > 0 then + self.rows_original[self.rows_idx_original[idx]] = row + end + -- precalculate the row size and position + self:calc_row_size_pos(idx) + end +end + +---Change the data assigned to a row. +---@param idx integer +---@param data any|nil +function ListBox:set_row_data(idx, data) + if self.rows[idx] then + self.row_data[idx] = data + if #self.rows_idx_original > 0 then + self.row_data_original[self.rows_idx_original[idx]] = data + end + end +end + +---Get the data associated with a row. +---@param idx integer +---@return any|nil +function ListBox:get_row_data(idx) + if type(self.row_data[idx]) ~= "nil" then + return self.row_data[idx] + end + return nil +end + +---Get the text only of a styled row. +---@param row integer | table +---@return string +function ListBox:get_row_text(row) + local text = "" + row = type(row) == "table" and row or self.rows[row] + if row then + for _, element in ipairs(row) do + if type(element) == "string" then + text = text .. element + elseif element == ListBox.NEWLINE then + text = text .. "\n" + end + end + end + return text +end + +---Get the starting and ending position of columns in a row table. +---@param row widget.listbox.row +---@return widget.listbox.colpos +function ListBox:get_col_positions(row) + local positions = {} + local idx = 1 + local idx_start = 1 + local row_len = #row + + for _, element in ipairs(row) do + if element == ListBox.COLEND then + table.insert(positions, { idx_start, idx-1 }) + idx_start = idx + 1 + elseif idx == row_len then + table.insert(positions, { idx_start, idx }) + end + idx = idx + 1 + end + + return positions +end + +---Move a row to the desired position if possible. +---@param idx integer +---@param pos integer +---@return boolean moved +function ListBox:move_row_to(idx, pos) + if idx == pos or (pos == #self.rows and #self.rows == 1) then return false end + + if pos < 1 then pos = 1 end + + local row = table.remove(self.rows, idx) + local position = table.remove(self.positions, idx) + + if pos <= #self.rows then + table.insert(self.rows, pos, row) + table.insert(self.positions, pos, position) + else + table.insert(self.rows, row) + table.insert(self.positions, position) + pos = #self.rows + end + + local moved_row_data = self.row_data[idx] + local swapped_row_data = self.row_data[pos] + self.row_data[idx] = swapped_row_data + self.row_data[pos] = moved_row_data + + self.selected_row = pos + + self:recalc_all_rows() + self:set_visible_rows() + + return true +end + +---Move a row one position up if possible. +---@param idx integer +---@return boolean moved +function ListBox:move_row_up(idx) + return self:move_row_to(idx, idx-1) +end + +---Move a row one position down if possible. +---@param idx integer +---@return boolean moved +function ListBox:move_row_down(idx) + self:move_row_to(idx, idx+1) +end + +---Enables expanding the element to total size of parent on content updates. +function ListBox:enable_expand(expand) + self.expand = expand + if expand then + self:resize_to_parent() + end +end + +---Resizes the listbox to match the parent size +function ListBox:resize_to_parent() + self.size.x = self.parent.size.x + - (self.border.width * 2) + + self.size.y = self.parent.size.y + - (self.border.width * 2) + + self:set_visible_rows() +end + +---Remove all the rows from the listbox. +function ListBox:clear() + self.rows = {} + self.row_data = {} + self.positions = {} + self.selected_row = 0 + self.hovered_row = 0 + + for cidx, col in ipairs(self.columns) do + col.width = self:get_col_width(cidx) + col.longest = nil + end + + self:set_visible_rows() +end + +---Render or calculate the size of the specified range of elements in a row. +---@param ridx integer +---@param row widget.listbox.row +---@param start_idx integer +---@param end_idx integer +---@param x integer +---@param y integer +---@param only_calc boolean +---@return integer width +---@return integer height +function ListBox:draw_row_range(ridx, row, start_idx, end_idx, x, y, only_calc) + local font = self:get_font() + local color = self.foreground_color or style.text + local width = 0 + local height = font:get_height() + local new_line = false + local nx = x + + for pos=start_idx, end_idx, 1 do + local element = row[pos] + local ele_type = type(element) + if + ele_type == "userdata" + or + ( + ele_type == "table" + and + (element.container or type(element[1]) == "userdata") + ) + then + if ele_type == "table" and element.container then + font = element.container[element.name] + else + font = element + end + elseif ele_type == "table" then + color = element + elseif element == ListBox.NEWLINE then + y = y + font:get_height() + nx = x + new_line = true + elseif ele_type == "function" then + local w, h = element(self, ridx, nx, y, font, color, only_calc) + nx = nx + width + height = math.max(height, h) + width = width + w + elseif ele_type == "string" then + local rx, ry, w, h = self:draw_text_multiline( + font, element, nx, y, color, only_calc + ) + y = ry + nx = rx + if new_line then + height = height + h + width = math.max(width, w) + new_line = false + else + height = math.max(height, h) + width = width + w + end + end + end + + return width, height +end + +---Calculate the overall width of a column. +---@param col integer +---@return number +function ListBox:get_col_width(col) + if self.columns[col] then + if not self.columns[col].expand then + return self.columns[col].width + else + -- if longest is available don't iterate the entire row list + if self.columns[col].longest then + local id = self.columns[col].longest + local width = self:draw_row_range( + id, + self.rows[id], + self.positions[id][col][1], + self.positions[id][col][2], + 1, + 1, + true + ) + return width + end + + local width = self:get_font():get_width(self.columns[col].name) + for id, row in ipairs(self.rows) do + local w, h = self:draw_row_range( + id, + row, + self.positions[id][col][1], + self.positions[id][col][2], + 1, + 1, + true + ) + width = math.max(width, w) + end + return width + end + end + return 0 +end + +---Draw the column headers of the list if available +---@param w integer +---@param h integer +---@param ox number Horizontal offset +function ListBox:draw_header(w, h, ox) + local x = self.position.x + local y = self.position.y + renderer.draw_rect(x, y, w, h, style.background2) + x = ox + self.position.x + for _, col in ipairs(self.columns) do + renderer.draw_text( + self:get_font(), + col.name, + x + style.padding.x / 2, + y + style.padding.y / 2, + style.accent + ) + x = x + col.width + style.padding.x + end +end + +---Draw or calculate the dimensions of the given row position and stores +---the size and position on the row it self. +---@param row integer +---@param x integer +---@param y integer +---@param only_calc? boolean +---@return integer width +---@return integer height +function ListBox:draw_row(row, x, y, only_calc) + local w, h = 0, 0 + + if not only_calc and self.rows[row].w then + w, h = self.rows[row].w, self.rows[row].h + w = self.largest_row > 0 and self.largest_row or w + + if self.selected_row == row then + renderer.draw_rect(x, y, w, h, style.selection) + end + + local mouse = self.mouse + if + mouse.x >= x + and + mouse.x <= x + w + and + mouse.y >= y + and + mouse.y <= y + h + then + renderer.draw_rect(x, y, w, h, style.line_highlight) + self.hovered_row = row + end + w, h = 0, 0 + end + + -- add padding on top + y = y + (style.padding.y / 2) + + if #self.columns > 0 then + for col, coldata in ipairs(self.columns) do + -- padding on left + w = w + style.padding.x / 2 + local cw, ch = self:draw_row_range( + row, + self.rows[row], + self.positions[row][col][1], + self.positions[row][col][2], + x + w, + y, + only_calc + ) + -- add column width and end with padding on right + w = w + coldata.width + (style.padding.x / 2) + -- only store column height if bigger than previous one + h = math.max(h, ch) + end + else + local cw, ch = self:draw_row_range( + row, + self.rows[row], + 1, + #self.rows[row], + x + style.padding.x / 2, + y, + only_calc + ) + h = ch + w = cw + style.padding.x + end + + -- Add padding on top and bottom + h = h + style.padding.y + + if only_calc or not self.rows[row].w then + -- store the dimensions for inexpensive subsequent hover calculation + self.rows[row].w = w + self.rows[row].h = h + + -- TODO: performance improvement, render only the visible rows on the view? + self.rows[row].x = x + self.rows[row].y = y - (style.padding.y / 2) + end + + -- return height with padding on top and bottom + return w, h +end + +--- +--- Events +--- + +function ListBox:on_mouse_leave(x, y, dx, dy) + ListBox.super.on_mouse_leave(self, x, y, dx, dy) + self.hovered_row = 0 +end + +function ListBox:on_mouse_moved(x, y, dx, dy) + ListBox.super.on_mouse_moved(self, x, y, dx, dy) + self.hovered_row = 0 +end + +function ListBox:on_click(button, x, y) + if button == "left" and self.hovered_row > 0 then + self.selected_row = self.hovered_row + self:on_row_click(self.hovered_row, self.row_data[self.hovered_row]) + end +end + +---You can overwrite this to listen to item clicks +---@param idx integer +---@param data any Data associated with the row +function ListBox:on_row_click(idx, data) end + +function ListBox:update() + if not ListBox.super.update(self) then return false end + + -- only calculate columns width on scale change since this can be expensive + if self.last_scale ~= SCALE then + if #self.columns > 0 then + for col, column in ipairs(self.columns) do + column.width = self:get_col_width(col) + end + end + self:recalc_all_rows() + self.last_scale = SCALE + end + + local _, oy = self:get_content_offset() + if self.last_offset ~= oy then + self:set_visible_rows() + self.last_offset = oy + end + + return true +end + +function ListBox:draw() + if not ListBox.super.draw(self) then return false end + + if #self.rows > 0 and #self.visible_rows <= 0 then + self:set_visible_rows() + end + + local new_width = 0 + local new_height = 0 + local font = self:get_font() + + if #self.columns > 0 then + new_height = new_height + font:get_height() + style.padding.y + for _, col in ipairs(self.columns) do + new_width = new_width + col.width + style.padding.x + end + end + + if self.expand then + self:resize_to_parent() + + self.largest_row = self.size.x + - (self.parent.border.width * 2) + end + + -- Normalize the offset position + local opx, opy = self.parent:get_content_offset() + if not self.parent.parent and not self.defer_draw then + -- TODO: inspect why is this workaround needed + -- Without it wrong offset occurs on child listviews of a single parent + -- like in the case of the recent projects in EmptyView. + opy = 0 + end + + local ox, oy = self:get_content_offset() + + oy = oy - opy + if #self.visible_rows > 0 then + oy = oy + (self.rows[self.visible_rows[1]].y - new_height) + end + oy = oy - (self.position.y - self.parent.position.y) + + ox = ox - opx + ox = ox - (self.position.x - self.parent.position.x) + + local x = ox + self.position.x + self.border.width + local y = oy + self.position.y + self.border.width + new_height + + core.push_clip_rect( + self.position.x, self.position.y, self.size.x, self.size.y + ) + for _, ridx in ipairs(self.visible_rows) do + if self.rows[ridx] then + local w, h = self:draw_row(ridx, x, y) + new_width = math.max(new_width, w) + new_height = new_height + h + y = y + h + end + end + + if not self.expand then + self.largest_row = self:get_width() - (self.border.width*2) + self.size.x = self.largest_row + end + + if #self.columns > 0 then + self:draw_header( + self.largest_row, + font:get_height() + style.padding.y, + ox + ) + end + core.pop_clip_rect() + + self:draw_border() + self:draw_scrollbar() + + return true +end + + +return ListBox diff --git a/resources/amiga/config/lite-xl/libraries/widget/manifest.json b/resources/amiga/config/lite-xl/libraries/widget/manifest.json new file mode 100644 index 00000000..8ed70be1 --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/manifest.json @@ -0,0 +1,17 @@ +{ + "addons": [ + { + "id": "widget", + "name": "Widgets", + "description": "Reusable widget components for plugin developers.", + "version": "0.2.1", + "mod_version": "3", + "type": "library", + "tags": [ + "ui", + "gui", + "widgets" + ] + } + ] +} diff --git a/resources/amiga/config/lite-xl/libraries/widget/messagebox.lua b/resources/amiga/config/lite-xl/libraries/widget/messagebox.lua new file mode 100644 index 00000000..4a089a14 --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/messagebox.lua @@ -0,0 +1,343 @@ +-- +-- MessageBox Widget/Dialog. +-- @copyright Jefferson Gonzalez +-- @license MIT +-- + +local core = require "core" +local common = require "core.common" +local style = require "core.style" +local Widget = require "libraries.widget" +local Button = require "libraries.widget.button" +local Label = require "libraries.widget.label" + +---@class widget.messagebox : widget +---@overload fun(parent?:widget, title?:string, message?:string|widget.styledtext, icon?:widget.messagebox.icontype, icon_color?:renderer.color):widget.messagebox +---@field private title widget.label +---@field private icon widget.label +---@field private message widget.label +---@field private buttons widget.button[] +local MessageBox = Widget:extend() + +MessageBox.icon_huge_font = style.icon_font:copy(50 * SCALE) + +MessageBox.ICON_ERROR = "X" +MessageBox.ICON_INFO = "i" +MessageBox.ICON_WARNING = "!" + +---@alias widget.messagebox.icontype +---|>`MessageBox.ICON_ERROR` +---| `MessageBox.ICON_INFO` +---| `MessageBox.ICON_WARNING` + +MessageBox.BUTTONS_OK = 1 +MessageBox.BUTTONS_OK_CANCEL = 2 +MessageBox.BUTTONS_YES_NO = 3 +MessageBox.BUTTONS_YES_NO_CANCEL = 4 + +---@alias widget.messagebox.buttonstype +---|>`MessageBox.BUTTONS_OK` +---| `MessageBox.BUTTONS_OK_CANCEL` +---| `MessageBox.BUTTONS_YES_NO` +---| `MessageBox.BUTTONS_YES_NO_CANCEL` + +---@alias widget.messagebox.onclosehandler fun(self: widget.messagebox, button_id: integer, button: widget.button) + +---Constructor +---@param parent widget +---@param title string +---@param message string | widget.styledtext +---@param icon widget.messagebox.icontype +---@param icon_color renderer.color +function MessageBox:new(parent, title, message, icon, icon_color) + MessageBox.super.new(self, parent) + self.type_name = "widget.messagebox" + self.draggable = true + self.scrollable = true + self.title = Label(self, "") + self.icon = Label(self, "") + self.message = Label(self, "") + self.buttons = {} + self.last_scale = SCALE + + self:set_title(title or "") + self:set_message(message or "") + self:set_icon(icon or "", icon_color) +end + +---Change the message box title. +---@param text string | widget.styledtext +function MessageBox:set_title(text) + self.title:set_label(text) +end + +---Change the message box icon. +---@param icon widget.messagebox.icontype +---@param color? renderer.color +function MessageBox:set_icon(icon, color) + if not color then + color = style.text + if icon == MessageBox.ICON_WARNING then + color = { common.color "#c7763e" } + elseif icon == MessageBox.ICON_ERROR then + color = { common.color "#c73e3e" } + end + end + self.icon:set_label({ MessageBox.icon_huge_font, color, icon }) +end + +---Change the message box message. +---@param text string | widget.styledtext +function MessageBox:set_message(text) + self.message:set_label(text) +end + +---Adds a new button to the message box. +---@param button_or_label string|widget.button +function MessageBox:add_button(button_or_label) + if type(button_or_label) == "table" then + table.insert(self.buttons, button_or_label) + else + local button = Button(self, button_or_label) + table.insert(self.buttons, button) + end + + local button_id = #self.buttons + local new_button = self.buttons[button_id] + local on_click = new_button.on_click + new_button.on_click = function(this, ...) + on_click(this, ...) + self:on_close(button_id, new_button) + end +end + +---Calculate the width of all buttons combined. +---@return number width +function MessageBox:get_buttons_width() + local width = 0 + if #self.buttons > 0 then + for _, button in ipairs(self.buttons) do + width = width + button:get_width() + end + -- add padding inbetween buttons + if #self.buttons > 1 then + width = width + ((style.padding.x) * (#self.buttons - 1)) + end + end + return width +end + +---Get the height of biggest button. +---@return number height +function MessageBox:get_buttons_height() + local height = 0 + if #self.buttons > 0 then + for _, button in ipairs(self.buttons) do + height = math.max(height, button:get_height()) + end + end + return height +end + +---Position the buttons relative to the message. +function MessageBox:reposition_buttons() + local buttons_width = self:get_buttons_width() + local buttons_x = ((self.size.x / 2) - (buttons_width / 2)) + local buttons_y = self.message:get_bottom() + (style.padding.y * 2) + if self.icon.label[3] ~= "" then + buttons_y = math.max( + buttons_y, self.icon:get_bottom() + (style.padding.y * 2) + ) + end + + for _, button in ipairs(self.buttons) do + button:set_position(buttons_x, buttons_y) + buttons_x = buttons_x + button:get_width() + style.padding.x + end +end + +---Calculate the MessageBox size, centers it relative to screen and shows it. +function MessageBox:show() + MessageBox.super.show(self) + self:update() + self:centered() +end + +---Called when the user clicks one of the buttons in the message box. +---@param button_id integer +---@param button widget.button +---@diagnostic disable-next-line +function MessageBox:on_close(button_id, button) self:hide() end + +function MessageBox:update() + if not MessageBox.super.update(self) then return false end + + if self.last_scale ~= SCALE then + MessageBox.icon_huge_font = style.icon_font:copy(50 * SCALE) + self.last_scale = SCALE + self.icon.label[1] = MessageBox.icon_huge_font + elseif self.updated then + self.updated = true + return + end + + local width = math.max(self.title:get_width()) + width = math.max(width, self.message:get_width() + self.icon:get_width()) + width = math.max(width, self:get_buttons_width()) + + local height = self.title:get_height() + style.padding.y + if self.icon.label[3] == "" then + height = height + self.message:get_height() + (style.padding.y * 2) + else + height = height + + math.max(self.icon:get_height(), self.message:get_height()) + + (style.padding.y * 2) + end + height = height + self:get_buttons_height() + + self:set_size(width + style.padding.x * 2, height + style.padding.y * 2) + + self.title:set_position( + style.padding.x / 2, + style.padding.y / 2 + ) + + self.icon:set_position( + style.padding.x, + self.title:get_bottom() + style.padding.y + ) + + if self.icon.label[3] == "" then + self.message:set_position( + style.padding.x, + self.title:get_bottom() + style.padding.y + ) + else + local msg_y = self.title:get_bottom() + style.padding.y + 10 + if self.icon:get_height() > self.message:get_height() then + msg_y = (self.icon:get_height() / 2) + - (self.message:get_height() / 2) + + self.title:get_bottom() + style.padding.y + end + self.message:set_position( + self.icon:get_width() + (style.padding.x * 2) - (style.padding.x / 2), + msg_y + ) + end + + self:reposition_buttons() + + return true +end + +---We overwrite default draw function to draw the title background. +function MessageBox:draw() + if not self:is_visible() then return false end + + Widget.super.draw(self) + + self:draw_border() + + if self.background_color then + self:draw_background(self.background_color) + else + self:draw_background( + self.parent and style.background or style.background2 + ) + end + + if #self.childs > 0 then + core.push_clip_rect( + self.position.x, + self.position.y, + self.size.x, + self.size.y + ) + end + + -- draw the title background + renderer.draw_rect( + self.position.x, + self.position.y, + self.size.x, self.title:get_height() + style.padding.y, + style.selection + ) + + for i=#self.childs, 1, -1 do + self.childs[i]:draw() + end + + if #self.childs > 0 then + core.pop_clip_rect() + end + + self:draw_scrollbar() + + return true +end + +---Wrapper to easily show a message box. +---@param title string | widget.styledtext +---@param message string | widget.styledtext +---@param icon widget.messagebox.icontype +---@param icon_color? renderer.color +---@param on_close? widget.messagebox.onclosehandler +---@param buttons? widget.messagebox.buttonstype +function MessageBox.alert(title, message, icon, icon_color, on_close, buttons) + buttons = buttons or MessageBox.BUTTONS_OK + ---@type widget.messagebox + local msgbox = MessageBox(nil, title, message, icon, icon_color) + if buttons == MessageBox.BUTTONS_OK_CANCEL then + msgbox:add_button("Ok") + msgbox:add_button("Cancel") + elseif buttons == MessageBox.BUTTONS_YES_NO then + msgbox:add_button("Yes") + msgbox:add_button("No") + elseif buttons == MessageBox.BUTTONS_YES_NO_CANCEL then + msgbox:add_button("Yes") + msgbox:add_button("No") + msgbox:add_button("Cancel") + else + msgbox:add_button("Ok") + end + + local msgbox_on_close = msgbox.on_close + msgbox.on_close = function(self, button_id, button) + if on_close then + on_close(self, button_id, button) + end + msgbox_on_close(self, button_id, button) + self:destroy() + end + msgbox:show() +end + +---Wrapper to easily show a info message box. +---@param title string | widget.styledtext +---@param message string | widget.styledtext +---@param on_close? widget.messagebox.onclosehandler +---@param buttons? widget.messagebox.buttonstype +function MessageBox.info(title, message, on_close, buttons) + MessageBox.alert(title, message, MessageBox.ICON_INFO, nil, on_close, buttons) +end + +---Wrapper to easily show a warning message box. +---@param title string | widget.styledtext +---@param message string | widget.styledtext +---@param on_close? widget.messagebox.onclosehandler +---@param buttons? widget.messagebox.buttonstype +function MessageBox.warning(title, message, on_close, buttons) + MessageBox.alert(title, message, MessageBox.ICON_WARNING, nil, on_close, buttons) +end + +---Wrapper to easily show an error message box. +---@param title string | widget.styledtext +---@param message string | widget.styledtext +---@param on_close? widget.messagebox.onclosehandler +---@param buttons? widget.messagebox.buttonstype +function MessageBox.error(title, message, on_close, buttons) + MessageBox.alert(title, message, MessageBox.ICON_ERROR, nil, on_close, buttons) +end + + +return MessageBox diff --git a/resources/amiga/config/lite-xl/libraries/widget/notebook.lua b/resources/amiga/config/lite-xl/libraries/widget/notebook.lua new file mode 100644 index 00000000..1b64a429 --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/notebook.lua @@ -0,0 +1,188 @@ +-- +-- NoteBook Widget. +-- @copyright Jefferson Gonzalez +-- @license MIT +-- + +local style = require "core.style" +local Widget = require "libraries.widget" +local Button = require "libraries.widget.button" + +local HSPACING = 2 +local VSPACING = 2 + +---Represents a notebook pane +---@class widget.notebook.pane +---@field public name string +---@field public tab widget.button +---@field public container widget +local NoteBookPane = {} + +---@class widget.notebook : widget +---@overload fun(parent?:widget):widget.notebook +---@field public panes widget.notebook.pane[] +---@field public active_pane widget.notebook.pane +local NoteBook = Widget:extend() + +---Notebook constructor +---@param parent widget +function NoteBook:new(parent) + NoteBook.super.new(self, parent) + self.type_name = "widget.notebook" + self.panes = {} + self.active_pane = nil +end + +---Called when a tab is clicked. +---@param pane widget.notebook.pane +---@diagnostic disable-next-line +function NoteBook:on_tab_click(pane) end + +---Adds a new pane to the notebook and returns a container widget where +---you can add more child elements. +---@param name string +---@param label string +---@return widget container +function NoteBook:add_pane(name, label) + ---@type widget.button + local tab = Button(self, label) + tab.border.width = 0 + + if #self.panes > 0 then + tab:set_position(self.panes[#self.panes].tab:get_right() + HSPACING, 0) + end + + local container = Widget(self) + container.scrollable = true + container:set_position(0, tab:get_bottom() + VSPACING) + container:set_size( + self:get_width(), + self:get_height() - tab:get_height() - VSPACING + ) + + local pane = { + name = name, + tab = tab, + container = container + } + + if not self.active_pane then + self.active_pane = pane + end + + tab.on_click = function() + self.active_pane = pane + self:on_tab_click(pane) + end + + table.insert(self.panes, pane) + + return container +end + +---Search the pane for the given name and return it. +---@param name string +---@return widget.notebook.pane | nil +function NoteBook:get_pane(name) + for _, pane in pairs(self.panes) do + if pane.name == name then + return pane + end + end + return nil +end + +---Activates the given pane. +---@param name string +---@return boolean +function NoteBook:set_pane(name) + local pane = self:get_pane(name) + if pane then + self.active_pane = pane + return true + end + return false +end + +---Change the tab label of the given pane. +---@param name string +---@param label string +function NoteBook:set_pane_label(name, label) + local pane = self:get_pane(name) + if pane then + pane.tab:set_label(label) + return true + end + return false +end + +---Set or remove the icon for the given pane. +---@param name string +---@param icon? string|nil +---@param color? renderer.color|nil +---@param hover_color? renderer.color|nil +function NoteBook:set_pane_icon(name, icon, color, hover_color) + local pane = self:get_pane(name) + if pane then + pane.tab:set_icon(icon, color, hover_color) + return true + end + return false +end + +---Recalculate the position of the elements on resizing or position +---changes and also make changes to properly render active pane. +function NoteBook:update() + if not NoteBook.super.update(self) then return false end + + for pos, pane in pairs(self.panes) do + if pos ~= 1 then + pane.tab:set_position( + self.panes[pos-1].tab:get_right() + HSPACING, 0 + ) + else + pane.tab:set_position(0, 0) + end + if pane ~= self.active_pane then + if pane.container.visible then + pane.tab.background_color = style.background + pane.tab.foreground_color = style.text + pane.container:hide() + end + elseif not pane.container.visible then + pane.container:show() + end + end + + if self.active_pane then + self.active_pane.tab.foreground_color = style.accent + + self.active_pane.container:set_position( + 0, self.active_pane.tab:get_bottom() + VSPACING + ) + self.active_pane.container:set_size( + self:get_width(), + self:get_height() - self.active_pane.tab:get_height() - VSPACING + ) + self.active_pane.container.border.color = style.divider + end + + return true +end + +---Here we draw the bottom line on the tab of active pane. +function NoteBook:draw() + if not NoteBook.super.draw(self) then return false end + + if self.active_pane then + local x = self.active_pane.tab.position.x + local y = self.active_pane.tab.position.y + self.active_pane.tab:get_bottom() + local w = self.active_pane.tab:get_width() + renderer.draw_rect(x, y, w, HSPACING, style.caret) + end + + return true +end + + +return NoteBook diff --git a/resources/amiga/config/lite-xl/libraries/widget/numberbox.lua b/resources/amiga/config/lite-xl/libraries/widget/numberbox.lua new file mode 100644 index 00000000..31f885ca --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/numberbox.lua @@ -0,0 +1,238 @@ +-- +-- NumberBox Widget. +-- @copyright Jefferson Gonzalez +-- @license MIT +-- + +local core = require "core" +local Widget = require "libraries.widget" +local Button = require "libraries.widget.button" +local TextBox = require "libraries.widget.textbox" + +---@class widget.numberbox : widget +---@overload fun(parent?:widget, value:number, min?:number, max?:number, step?:number):widget.numberbox +---@field private textbox widget.textbox +---@field private decrease_button widget.button +---@field private increase_button widget.button +---@field private minimum number +---@field private maximum number +---@field private step number +local NumberBox = Widget:extend() + +---Constructor +---@param parent widget +---@param value number +---@param min? number +---@param max? number +---@param step? number +function NumberBox:new(parent, value, min, max, step) + NumberBox.super.new(self, parent) + + self.type_name = "widget.numberbox" + + self:set_range(min, max) + self:set_step(step) + + self.textbox = TextBox(self, "") + self.textbox.scrollable = true + + self.decrease_button = Button(self, "-") + self.increase_button = Button(self, "+") + + self:set_value(value) + + local this = self + function self.textbox.textview.doc:on_text_change(type) + if not tonumber(this.textbox:get_text()) then + if not this.coroutine_run then + this.coroutine_run = true + core.add_thread(function() + this.textbox:set_text(this.current_text) + this.coroutine_run = false + end) + end + else + this.textbox.placeholder_active = false + this.current_text = this.textbox:get_text() + this:on_change(tonumber(this.current_text)) + end + end + function self.textbox:on_mouse_wheel(y) + if self.active then + if y > 0 then this:increase() else this:decrease() end + return true + end + return false + end + function self.decrease_button:on_mouse_pressed(button, x, y, clicks) + if Button.super.on_mouse_pressed(self, button, x, y, clicks) then + this.mouse_is_pressed = true + this:mouse_pressed(false) + return true + end + return false + end + function self.increase_button:on_mouse_pressed(button, x, y, clicks) + if Button.super.on_mouse_pressed(self, button, x, y, clicks) then + this.mouse_is_pressed = true + this:mouse_pressed(true) + return true + end + return false + end + function self.decrease_button:on_mouse_released(button, x, y) + if Button.super.on_mouse_released(self, button, x, y) then + this.mouse_is_pressed = false + return true + end + return false + end + function self.increase_button:on_mouse_released(button, x, y) + if Button.super.on_mouse_released(self, button, x, y) then + this.mouse_is_pressed = false + return true + end + return false + end + + self.border.width = 0 + + self:set_size( + self.textbox:get_width() - 100 + + self.decrease_button:get_width() + + self.increase_button:get_width() + ) +end + +---Set a new value. +---@param value number +function NumberBox:set_value(value) + if type(value) == "number" then + self.textbox:set_text(tostring(value)) + elseif type(value) == "string" and tonumber(value) then + self.textbox:set_text(value) + else + self.textbox:set_text(tostring(self.minimum)) + end + self.textbox.placeholder_active = false + self.current_text = self.textbox:get_text() + self:on_change(tonumber(self.current_text)) +end + +---Get the current value. +---@return number +function NumberBox:get_value() + return tonumber(self.textbox:get_text()) or self.minimum +end + +---Set the minimum and maximum values allowed. +---@param min? number +---@param max? number +function NumberBox:set_range(min, max) + self.minimum = min or math.mininteger + self.maximum = max or math.maxinteger +end + +---Set the value used to increase or decrease the number when the +---buttons are pressed. +---@param step number +function NumberBox:set_step(step) + self.step = step or 1 +end + +---Decrease the current value. +function NumberBox:decrease() + self.textbox.placeholder_active = false + local value = tonumber(self.textbox:get_text()) or self.maximum + if (value - self.step) >= self.minimum then + self:set_value(value - self.step) + end +end + +---Increase the current value. +function NumberBox:increase() + self.textbox.placeholder_active = false + local value = tonumber(self.textbox:get_text()) or self.minimum + if (value + self.step) <= self.maximum then + self:set_value(value + self.step) + end +end + +---Triggered when the mouse is pressed on the increase/decrease buttons. +---@param increase boolean +function NumberBox:mouse_pressed(increase) + if increase then self:increase() else self:decrease() end + + local elapsed = system.get_time() + 0.3 + local this = self + + core.add_thread(function() + while this.mouse_is_pressed do + if elapsed < system.get_time() then + if increase then + this:increase() + else + this:decrease() + end + core.redraw = true + elapsed = system.get_time() + 0.1 + end + coroutine.yield() + end + end) +end + +---Overrided to enforce minimum allowed size. +---@param width integer +---@param height? integer Ignored on the number box +function NumberBox:set_size(width, height) + local buttons_w = self.decrease_button:get_width() + + self.increase_button:get_width() + + -- permit a minimum of 100 pixels wide for textbox + if width < (buttons_w + 100) then + width = 100 + buttons_w + end + + self.textbox:set_size(width - buttons_w) + + NumberBox.super.set_size( + self, + width, + math.max( + self.textbox:get_height(), + self.decrease_button:get_height(), + self.increase_button:get_height() + ) + -- TODO: check what causes the need for this border size addition since + -- it shouldn't be needed but for now fixes occasional bottom border cut. + + self.increase_button.border.width + ) +end + +function NumberBox:update() + if not NumberBox.super.update(self) then return false end + + self:set_size( + self.textbox:get_width() + + self.decrease_button:get_width() + + self.increase_button:get_width() + ) + + self.textbox:set_position(0, 0) + + self.decrease_button:set_position( + self.textbox:get_right(), + 0 + ) + + self.increase_button:set_position( + self.decrease_button:get_right(), + 0 + ) + + return true +end + + +return NumberBox diff --git a/resources/amiga/config/lite-xl/libraries/widget/progressbar.lua b/resources/amiga/config/lite-xl/libraries/widget/progressbar.lua new file mode 100644 index 00000000..5c880363 --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/progressbar.lua @@ -0,0 +1,95 @@ +-- +-- ProgressBar Widget. +-- @copyright Jefferson Gonzalez +-- @license MIT +-- + +local style = require "core.style" +local Widget = require "libraries.widget" + +---@class widget.progressbar : widget +---@overload fun(parent?:widget, percent?:number, width?:number):widget.progressbar +---@field public percent number +---@field public show_percent boolean +---@field private percent_width number +---@field private percent_x number +---@field private percent_y number +local ProgressBar = Widget:extend() + +---Constructor +---@param parent widget +---@param percent number +---@param width number +function ProgressBar:new(parent, percent, width) + ProgressBar.super.new(self, parent) + self.type_name = "widget.progressbar" + self.clickable = false + self.percent = percent or 0 + self.percent_width = 0 + self.show_percent = true + self:set_size(width or 200, self:get_font():get_height() + style.padding.y) +end + +---@param percent number +function ProgressBar:set_percent(percent) + self.percent = percent +end + +---@return number +function ProgressBar:get_percent() + return self.percent +end + +function ProgressBar:update() + if not ProgressBar.super.update(self) then return false end + + local font = self:get_font() + + -- update the size + self:set_label(self.percent .. "%") + + self:set_size( + nil, + font:get_height() + style.padding.y + ) + + local percent_width = (self.size.x * (self.percent / 100)) + + self:move_towards(self, "percent_width", percent_width, 0.2) + + if self.show_percent then + self.percent_x = (self:get_width() / 2) + - (font:get_width(self.label) / 2) + + self.percent_y = style.padding.y / 2 + end + + return true +end + +function ProgressBar:draw() + if not ProgressBar.super.draw(self) then return false end + + renderer.draw_rect( + self.position.x, + self.position.y, + self.percent_width, + self.size.y, + style.dim + ) + + if self.show_percent then + renderer.draw_text( + self:get_font(), + self.label, + self.position.x + self.percent_x, + self.position.y + self.percent_y, + style.text + ) + end + + return true +end + + +return ProgressBar diff --git a/resources/amiga/config/lite-xl/libraries/widget/scrollbar.lua b/resources/amiga/config/lite-xl/libraries/widget/scrollbar.lua new file mode 100644 index 00000000..5f9c181d --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/scrollbar.lua @@ -0,0 +1,33 @@ +-- +-- Extends core.scrollbar to allow propagating force status to child elements. +-- + +---@type core.scrollbar +local CoreScrollBar = require "core.scrollbar" + +---@class widget.scrollbar : core.scrollbar +---@overload fun(parent?:widget, options?:table):widget.scrollbar +---@field super widget.scrollbar +---@field widget_parent widget +local ScrollBar = CoreScrollBar:extend() + +function ScrollBar:new(parent, options) + self.widget_parent = parent + ScrollBar.super.new(self, options) +end + +function ScrollBar:set_forced_status(status) + ScrollBar.super.set_forced_status(self, status) + if self.widget_parent and self.widget_parent.childs then + for _, child in pairs(self.widget_parent.childs) do + if self.direction == "v" then + child.v_scrollbar:set_forced_status(status) + else + child.h_scrollbar:set_forced_status(status) + end + end + end +end + + +return ScrollBar diff --git a/resources/amiga/config/lite-xl/libraries/widget/searchreplacelist.lua b/resources/amiga/config/lite-xl/libraries/widget/searchreplacelist.lua new file mode 100644 index 00000000..658f5a22 --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/searchreplacelist.lua @@ -0,0 +1,607 @@ +-- +-- SearchReplaceList Widget. +-- @copyright Jefferson Gonzalez +-- @license MIT +-- + +local core = require "core" +local common = require "core.common" +local style = require "core.style" +local Widget = require "widget" + +---@class widget.searchreplacelist.lineposition +---@field col1 integer +---@field col2 integer +---@field checked boolean? + +---@class widget.searchreplacelist.line +---@field line integer +---@field text string +---@field positions widget.searchreplacelist.lineposition[] + +---@class widget.searchreplacelist.file +---@field path string +---@field lines widget.searchreplacelist.line[] +---@field expanded boolean + +---@class widget.searchreplacelist.item +---@field checked boolean +---@field file widget.searchreplacelist.file? +---@field parent widget.searchreplacelist.item? +---@field line widget.searchreplacelist.line? +---@field position widget.searchreplacelist.lineposition? + +---@class widget.searchreplacelist : widget +---@field replacement string? +---@field selected integer +---@field hovered integer +---@field items widget.searchreplacelist.item[] +---@field total_files integer +---@field total_results integer +---@field base_dir string +---@overload fun(parent:widget, find:string, replacement:string?):widget.searchreplacelist +local SearchReplaceList = Widget:extend() + +---Colors to use when performing a replacement. +local DIFF = { + ADD = { common.color "#72b886" }, + DEL = { common.color "#F36161" }, + TEXT = { common.color "#000000" } +} + +---Constructor +---@param parent widget +---@param replacement string? +function SearchReplaceList:new(parent, replacement) + SearchReplaceList.super.new(self, parent) + + self.type_name = "widget.searchreplacelist" + + self.replacement = replacement or nil + self.selected = 0 + self.hovered = 0 + self.items = {} + self.max_width = 0 + self.hovered_checkbox = false + self.hovered_expander = false + self.scrollable = true + self.total_files = 0 + self.total_results = 0 + self.base_dir = "" +end + +---Overridable event triggered when an item is clicked. +---@param item widget.searchreplacelist.item +---@param clicks integer +function SearchReplaceList:on_item_click(item, clicks) end + +---Add a new file with all the matching lines and positions. +---@param path string +---@param lines widget.searchreplacelist.line[] +---@param expanded boolean? +function SearchReplaceList:add_file(path, lines, expanded) + if type(expanded) == "nil" then expanded = true end + table.insert(self.items, { + checked = true, + file = { + path = path, + lines = lines, + expanded = false + } + }) + self.total_files = self.total_files + 1 + -- expand results and count them + if expanded and #lines > 0 then + self:expand(#self.items, true) + -- count the results only + else + for _, line in ipairs(self.items[#self.items]) do + self.total_results = self.total_results + #line.positions + end + end +end + +---Removes all elements from the list and reset it. +function SearchReplaceList:clear() + self.selected = 0 + self.hovered = 0 + self.items = {} + self.max_width = 0 + self.hovered_checkbox = false + self.hovered_expander = false + self.total_files = 0 + self.total_results = 0 +end + +---Uncollapse a file line results. +---@param position integer +function SearchReplaceList:expand(position, count_results) + local parent = self.items[position] + if + parent and parent.file + and + not parent.file.expanded + then + local insert_pos = position+1 + local items = {} + for _, line in ipairs(parent.file.lines) do + for _, line_pos in ipairs(line.positions) do + table.insert(items, { + parent = parent, + line = line, + position = line_pos + }) + if count_results then + self.total_results = self.total_results + 1 + end + end + end + common.splice(self.items, insert_pos, 0, items) + parent.file.expanded = true + end +end + +---Collapse a file line results. +---@param position integer +function SearchReplaceList:contract(position) + local parent = self.items[position] + if + parent and parent.file + and + parent.file.expanded + then + local start_pos = position+1 + local end_pos = 0 + for _, line in ipairs(parent.file.lines) do + for _, _ in ipairs(line.positions) do + end_pos = end_pos + 1 + end + end + common.splice(self.items, start_pos, end_pos) + parent.file.expanded = false + end +end + +---Collapse/uncollapse a file line results. +---@param position integer +function SearchReplaceList:toggle_expand(position) + local parent = self.items[position] + if parent and parent.file then + if parent.file.expanded then + self:contract(position) + else + self:expand(position) + end + end +end + +---Toggle the checkbox of the given item position. +---@param pos integer +function SearchReplaceList:toggle_check(pos) + local item = self.items[pos] + if not item or self.replacement == nil then return end + if item.file then + item.checked = not item.checked + for _, line in ipairs(item.file.lines) do + for _, position in ipairs(line.positions) do + position.checked = item.checked + end + end + else + if type(item.position.checked) == "nil" then + item.position.checked = false + else + item.position.checked = not item.position.checked + end + local parent_checked = true + for _, line in ipairs(item.parent.file.lines) do + for _, position in ipairs(line.positions) do + if position.checked == false then + parent_checked = false + break + end + end + end + item.parent.checked = parent_checked + end +end + +---Replace a position on a string with a given replacement. +---@param str string +---@param s integer +---@param e integer +---@param rep string +local function replace_substring(str, s, e, rep) + local head = s <= 1 and "" or string.sub(str, 1, s - 1) + local tail = e >= #str and "" or string.sub(str, e + 1) + return head .. rep .. tail +end + +---Applies the replacement on the given item position but not on the real file. +--- +---The purpose of this function is to reflect the changes on the listed items. +---@param position integer +function SearchReplaceList:apply_replacement(position) + local item = self.items[position] + if item and item.file and self.replacement then + local replacement = self.replacement + local replacement_len = #self.replacement + for _, line in ipairs(item.file.lines) do + local offset = 0 + for _, pos in ipairs(line.positions) do + local col1 = pos.col1 + offset + local col2 = pos.col2 + offset + if pos.checked or type(pos.checked) == "nil" then + line.text = replace_substring(line.text, col1, col2, replacement) + local current_len = col2 - col1 + 1 + local len_diff = 0 + if current_len > replacement_len then + len_diff = current_len - replacement_len + offset = offset - len_diff + col2 = col2 - len_diff + elseif current_len < replacement_len then + len_diff = replacement_len - current_len + offset = offset + len_diff + col2 = col2 + len_diff + end + end + pos.col1, pos.col2 = col1, col2 + end + end + end +end + +---Select the item that follows currently selected item. +---@return widget.searchreplacelist.item? +function SearchReplaceList:select_next() + local items_count = #self.items + if items_count <= 0 then return nil end + local selected = self.selected+1 + if selected > items_count then selected = 1 end + self.selected = common.clamp(selected, 1, items_count) + self:scroll_to_selected() + return self.items[self.selected] +end + +---Select the item that precedes currently selected item. +---@return widget.searchreplacelist.item? +function SearchReplaceList:select_prev() + local items_count = #self.items + if items_count <= 0 then return nil end + local selected = self.selected-1 + if selected < 1 then selected = items_count end + self.selected = common.clamp(selected, 1, items_count) + self:scroll_to_selected() + return self.items[self.selected] +end + +---Get the currently selected item. +---@return widget.searchreplacelist.item? +function SearchReplaceList:get_selected() + if self.selected > 0 and self.selected <= #self.items then + return self.items[self.selected] + end + return nil +end + +---Get the line height used when drawing each item row. +---@return number height +function SearchReplaceList:get_line_height() + return style.padding.y + style.font:get_height() +end + +---Used when calculating if vertical scrolling is needed. +---@return number size +function SearchReplaceList:get_scrollable_size() + return #self.items * self:get_line_height() + style.padding.y +end + +---Used when calculating if horizontal scrolling is needed. +---@return number size +function SearchReplaceList:get_h_scrollable_size() + local width = style.padding.x / 2 + + style.icon_font:get_width("-") + + style.padding.x / 2 + + self.max_width + if self.replacement then + local cb_w = self:get_checkbox_size() + width = width + + cb_w + style.padding.x / 2 + + style.font:get_width(self.replacement) + end + return width +end + +---Get the checkbox size based on the line height. +---@param y? number +---@return number w +---@return number h +---@return number y Vertically center align coord based on given y param +function SearchReplaceList:get_checkbox_size(y) + if not y then y = 0 end + local lh = self:get_line_height() + local w = lh * 0.6 + local h = w + y = y + ((lh / 2) - (h / 2)) + return w, h, y +end + +---Get the position of first and last visible items. +---@return integer first +---@return integer last +function SearchReplaceList:get_visible_items_range() + local lh = self:get_line_height() + local min = math.max(1, math.floor(self.scroll.y / lh)) + return min, min + math.floor(self.size.y / lh) + 1 +end + +---Allows iterating the currently visible items only. +---@return fun():integer,widget.searchreplacelist.item,number,number,number,number +function SearchReplaceList:each_visible_item() + return coroutine.wrap(function() + local lh = self:get_line_height() + local x, y = self:get_content_offset() + local min, max = self:get_visible_items_range() + y = y + lh * (min - 1) + for i = min, max do + local item = self.items[i] + if not item then break end + coroutine.yield(i, item, x, y, self.size.x, lh) + y = y + lh + end + end) +end + +---Iterates over all files, only those that have a position checked on replacement mode. +---@return fun():integer,widget.searchreplacelist.file +function SearchReplaceList:each_file() + return coroutine.wrap(function() + local replacement = self.replacement + for i, item in ipairs(self.items) do + if item.file then + if replacement then + local none_checked = true + for _, line in ipairs(item.file.lines) do + for _, pos in ipairs(line.positions) do + if type(pos.checked) == "nil" or pos.checked then + none_checked = false + break + end + end + end + if none_checked then + goto continue + end + end + coroutine.yield(i, item.file) + end + ::continue:: + end + end) +end + +---Scroll to currently selected item only if not already visible. +function SearchReplaceList:scroll_to_selected() + if self.selected == 0 then return end + local h = self:get_line_height() + local y = h * (self.selected - 1) + self.scroll.to.y = math.min(self.scroll.to.y, y) + self.scroll.to.y = math.max(self.scroll.to.y, y + h - self.size.y) +end + +function SearchReplaceList:on_mouse_moved(mx, my, ...) + if not SearchReplaceList.super.on_mouse_moved(self, mx, my, ...) then + return false + end + self.hovered = 0 + for i, item, x,y,w,h in self:each_visible_item() do + w = self.size.x + if mx >= x and my >= y and mx < x + w and my < y + h then + self.hovered = i + x = x + style.padding.x / 2 + w = style.icon_font:get_width('-') + if item.file and mx >= x and my >= y and mx < x + w and my < y + h then + self.hovered_expander = true + self.hovered_checkbox = false + else + self.hovered_expander = false + end + if not self.hovered_expander and self.replacement then + x = x + w + style.padding.x / 2 + w, h, y = self:get_checkbox_size(y) + if mx >= x and my >= y and mx < x + w and my < y + h then + self.hovered_checkbox = true + self.hovered_expander = false + else + self.hovered_checkbox = false + end + end + break + end + end + return true +end + +function SearchReplaceList:on_mouse_pressed(button, x, y, clicks) + if + not SearchReplaceList.super.on_mouse_pressed(self, button, x, y, clicks) + then + return false + end + if self:scrollbar_hovering() then return true end + local item = self.items[self.hovered] + if not item then return true end + self.selected = self.hovered + if self.hovered_checkbox then + self:toggle_check(self.selected) + elseif item.file and (clicks > 1 or self.hovered_expander) then + if not item.file.expanded then + self:expand(self.hovered) + else + self:contract(self.hovered) + end + else + self:on_item_click(item, clicks) + end + return true +end + +function SearchReplaceList:draw_checkbox(checked, hovered, x, y, lh) + local w, h, cy = self:get_checkbox_size(y) + renderer.draw_rect(x, cy, w, h, style.text) + renderer.draw_rect( + x + 2, cy + 2, w-4, h-4, + hovered and style.dim or style.background + ) + if checked then + renderer.draw_rect(x + 5, cy + 5, w-10, h-10, style.caret) + end + return x + w +end + +function SearchReplaceList:draw() + if not SearchReplaceList.super.draw(self) then return false end + + core.push_clip_rect( + self.position.x, + self.position.y, + self.size.x, + self.size.y + ) + + local font = self:get_font() + + local replacement = self.replacement + local replacement_width = font:get_width(replacement or "") + local file_path + + self.max_width = 0 + + for i, item, x,y,w,h in self:each_visible_item() do + if item.file then + file_path = common.relative_path(self.base_dir, item.file.path) + end + + -- add left padding + x = x + style.padding.x / 2 + + local text_color = style.text + + if i == self.selected then + renderer.draw_rect(self.position.x, y, self.size.x, h, style.selection) + elseif i == self.hovered then + renderer.draw_rect(self.position.x, y, self.size.x, h, style.line_highlight) + text_color = style.accent + end + + -- draw collapse/contract symbol + if item.file then + common.draw_text( + style.icon_font, + (self.hovered == i and self.hovered_expander) and style.accent or style.text, + item.file.expanded and "-" or "+", + "left", + x, y, w, h + ) + x = x + style.icon_font:get_width("-") + else + x = x + style.icon_font:get_width("-") + end + + x = x + style.padding.x / 2 + + -- draw checkbox + if replacement then + local checked = false + if item.file then + checked = item.checked + else + if type(item.position.checked) == "nil" then + checked = true + else + checked = item.position.checked + end + end + x = style.padding.x / 2 + self:draw_checkbox( + checked, + i == self.hovered and self.hovered_checkbox or false, + x, y, h + ) + end + + -- draw text + local text = item.file and file_path or item.line.text + local all_text = "" + + if item.line then + local start_pos, end_pos = 1, #text + local prefix, postfix = "", "" + -- truncate long lines to keep good rendering performance + if #text > 120 then + start_pos = math.max(item.position.col1 - 50, 1) + end_pos = math.min(item.position.col2 + 50, end_pos) + if start_pos ~= 1 then prefix = "..." end + if end_pos ~= #text then postfix = "..." end + end + x = common.draw_text( + font, + style.syntax["number"], + tostring(item.line.line) .. ": ", + "left", + x, y, w, h + ) + local start_text = item.position.col1 ~= 1 and + prefix .. text:sub(start_pos, item.position.col1-1) + or + "" + local end_text = item.position.col2 ~= #text and + text:sub(item.position.col2+1, end_pos) .. postfix + or + "" + local found_text = text:sub(item.position.col1, item.position.col2) + local found_width = style.font:get_width(found_text) + if start_text ~= "" then + x = common.draw_text( + font, + text_color, + start_text, + "left", + x, y, w, h + ) + end + local found_color = not replacement and style.dim or DIFF.DEL + local found_text_color = not replacement and text_color or DIFF.TEXT + renderer.draw_rect(x, y, found_width, h, found_color) + x = common.draw_text(font, found_text_color, found_text, "left", x, y, w, h) + if replacement then + renderer.draw_rect(x, y, replacement_width, h, DIFF.ADD) + x = common.draw_text(font, DIFF.TEXT, replacement, "left", x, y, w, h) + end + if end_text ~= "" then + x = common.draw_text( + font, + text_color, + end_text, + "left", + x, y, w, h + ) + end + all_text = item.line.line .. ": " .. start_text .. found_text .. end_text + else + x = common.draw_text(font, text_color, text, "left", x, y, w, h) + all_text = file_path + end + + -- recalc max_width for horizontal scrollbar + self.max_width = math.max(self.max_width, style.font:get_width(all_text)) + end + + core.pop_clip_rect() + + self:draw_scrollbar() + + return true +end + + +return SearchReplaceList diff --git a/resources/amiga/config/lite-xl/libraries/widget/selectbox.lua b/resources/amiga/config/lite-xl/libraries/widget/selectbox.lua new file mode 100644 index 00000000..45314c81 --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/selectbox.lua @@ -0,0 +1,288 @@ +-- +-- SelectBox Widget. +-- @copyright Jefferson Gonzalez +-- @license MIT +-- + +local core = require "core" +local common = require "core.common" +local style = require "core.style" +local Widget = require "libraries.widget" +local ListBox = require "libraries.widget.listbox" + +---@class widget.selectbox : widget +---@overload fun(parent?:widget, label?:string):widget.selectbox +---@field private list_container widget +---@field private list widget.listbox +---@field private selected integer +---@field private hover_text renderer.color +local SelectBox = Widget:extend() + +---Constructor +---@param parent widget +---@param label string +function SelectBox:new(parent, label) + SelectBox.super.new(self, parent) + self.type_name = "widget.selectbox" + self.size.x = 200 + (style.padding.x * 2) + self.size.y = self:get_font():get_height() + (style.padding.y * 2) + self.list_container = Widget() + self.list_container.name = self:get_name() + self.list_container:set_size( + self.size.x - self.list_container.border.width, + 150 + ) + self.list = ListBox(self.list_container) + self.list.border.width = 0 + self.list:enable_expand(true) + self.selected = 0 + + local list_on_row_click = self.list.on_row_click + self.list.on_row_click = function(this, idx, data) + list_on_row_click(this, idx, data) + if idx ~= 1 then + self.selected = idx-1 + self:on_selected(idx-1, data) + self:on_change(self.selected) + end + self.list_container:hide_animated(true) + end + + -- Hide list if mouse clicked outside + self.list_container:force_event("mouse_released") + self.list_container.on_mouse_released = function(this, button, x, y) + if + this:is_visible() + and + not this:mouse_on_top(x, y) and not self:mouse_on_top(x, y) + then + this:hide() + return false + end + if this:is_visible() and this:mouse_on_top(x, y) then + return Widget.on_mouse_released(this, button, x, y) + end + end + + self:set_label(label or "select") +end + +---Set the text displayed when no item is selected. +---@param text string +function SelectBox:set_label(text) + SelectBox.super.set_label(self, "- "..text.." -") + if not self.list.rows[1] then + self.list:add_row({"- "..text.." -"}) + else + self.list.rows[1][1] = "- "..text.." -" + end +end + +---Add selectable option to the selectbox. +---@param text widget.styledtext|string +---@param data any +function SelectBox:add_option(text, data) + if type(text) == "string" then + self.list:add_row({ text }, data) + else + self.list:add_row(text, data) + end +end + +---Check if a text is longer than the given maximum width and if larger returns +---a chopped version with the overflow_chars appended to it. +---@param text string +---@param max_width number +---@param font widget.font Default is style.font +---@param overflow_chars? string Default is '...' +---@return string chopped_text +---@return boolean overflows True if the text overflows +function SelectBox:text_overflow(text, max_width, font, overflow_chars) + font = self:get_font(font) + overflow_chars = overflow_chars or "..." + + local overflow = false + local overflow_chars_width = font:get_width(overflow_chars) + + if font:get_width(text) > max_width then + overflow = true + for i = 1, #text do + local reduced_text = text:sub(1, #text - i) + if + font:get_width(reduced_text) + overflow_chars_width + <= + max_width + then + text = reduced_text .. "…" + break + end + end + end + + return text, overflow +end + +---Set the active option index. +---@param idx integer +function SelectBox:set_selected(idx) + if self.list.rows[idx+1] then + self.selected = idx + self.list:set_selected(idx+1) + else + self.selected = 0 + self.list:set_selected() + end + self:on_change(self.selected) +end + +---Get the currently selected option index. +---@return integer +function SelectBox:get_selected() + return self.selected +end + +---Get the currently selected option text. +---@return string|nil +function SelectBox:get_selected_text() + if self.selected > 0 then + return self.list:get_row_text(self.selected + 1) + end + return nil +end + +---Get the currently selected option associated data. +---@return any|nil +function SelectBox:get_selected_data() + if self.selected > 0 then + return self.list:get_row_data(self.selected + 1) + end + return nil +end + +---Repositions the listbox container according to the selectbox position +---and available screensize. +function SelectBox:reposition_container() + local y1 = self.position.y + self:get_height() + local y2 = self.position.y - self.list:get_height() + + local _, h = system.get_window_size(core.window) + + if y1 + self.list:get_height() <= h then + self.list_container:set_position( + self.position.x, + y1 + ) + else + self.list_container:set_position( + self.position.x, + y2 + ) + end + + self.list_container.size.x = self.size.x - (self.border.width * 2) +end + +---Overrided to destroy the floating listbox container. +function SelectBox:destroy_childs() + SelectBox.super.destroy_childs(self) + self.list_container:destroy() +end + +-- +-- Events +-- + +---Overwrite to listen to on_selected events. +---@param item_idx integer +---@param item_data widget.listbox.row +---@diagnostic disable-next-line +function SelectBox:on_selected(item_idx, item_data) end + +function SelectBox:on_mouse_enter(...) + SelectBox.super.on_mouse_enter(self, ...) + self.hover_text = style.accent +end + +function SelectBox:on_mouse_leave(...) + SelectBox.super.on_mouse_leave(self, ...) + self.hover_text = nil +end + +function SelectBox:on_click(button, x, y) + SelectBox.super.on_click(self, button, x, y) + + if button == "left" then + self:reposition_container() + + self.list_container.border.color = style.caret + + self.list_container:toggle_visible(true, true) + + self.list:resize_to_parent() + end +end + +function SelectBox:update() + if not SelectBox.super.update(self) then return false end + + local font = self:get_font() + + self.size.y = font:get_height() + style.padding.y * 2 + + if + self.list_container.visible + and + self.list_container.position.y ~= self.position.y + self:get_height() + then + self:reposition_container() + end + + return true +end + +function SelectBox:draw() + if not SelectBox.super.draw(self) then return false end + + local font = self:get_font() + + local icon_width = style.icon_font:get_width("+") + + local max_width = self.size.x + - icon_width + - (style.padding.x * 2) -- left/right paddings + - (style.padding.x / 2) -- space between icon and text + + local item_text = self.selected == 0 and + self.label or self.list:get_row_text(self.selected+1) + + local text = self:text_overflow(item_text, max_width, font) + + -- draw label or selected item + common.draw_text( + font, + self.hover_text or self.foreground_color or style.text, + text, + "left", + self.position.x + style.padding.x, + self.position.y, + self.size.x - style.padding.x, + self.size.y + ) + + -- draw arrow down icon + common.draw_text( + style.icon_font, + self.hover_text or self.foreground_color or style.text, + "-", + "right", + self.position.x, + self.position.y, + self.size.x - style.padding.x, + self.size.y + ) + + return true +end + + +return SelectBox diff --git a/resources/amiga/config/lite-xl/libraries/widget/textbox.lua b/resources/amiga/config/lite-xl/libraries/widget/textbox.lua new file mode 100644 index 00000000..08abddd0 --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/textbox.lua @@ -0,0 +1,308 @@ +-- +-- TextBox widget re-using code from lite's DocView. +-- + +local core = require "core" +local style = require "core.style" +local translate = require "core.doc.translate" +local Doc = require "core.doc" +local DocView = require "core.docview" +local View = require "core.view" +local Widget = require "libraries.widget" + + +---@class widget.textbox.SingleLineDoc : core.doc +---@overload fun():widget.textbox.SingleLineDoc +---@field super core.doc +local SingleLineDoc = Doc:extend() + +function SingleLineDoc:insert(line, col, text) + SingleLineDoc.super.insert(self, line, col, text:gsub("\n", "")) +end + +---@class widget.textbox.TextView : core.docview +---@overload fun():widget.textbox.TextView +---@field super core.docview +local TextView = DocView:extend() + +function TextView:new() + TextView.super.new(self, SingleLineDoc()) + self.gutter_width = 0 + self.hide_lines_gutter = true + self.gutter_text_brightness = 0 + self.scrollable = true + self.font = "font" + self.name = View.get_name(self) + + self.size.y = 0 + self.label = "" +end + +function TextView:get_name() + return self.name +end + +function TextView:get_scrollable_size() + return 0 +end + +function TextView:get_text() + return self.doc:get_text(1, 1, 1, math.huge) +end + +function TextView:set_text(text, select) + self.doc:remove(1, 1, math.huge, math.huge) + self.doc:text_input(text) + if select then + self.doc:set_selection(math.huge, math.huge, 1, 1) + end +end + +function TextView:get_gutter_width() + return self.gutter_width or 0 +end + +function TextView:get_line_height() + return math.floor(self:get_font():get_height() * 1.2) +end + +function TextView:draw_line_gutter(idx, x, y) + if self.hide_lines_gutter then + return + end + TextView.super.draw_line_gutter(self, idx, x, y) +end + +function TextView:draw_line_highlight() + -- no-op function to disable this functionality +end + +-- Overwrite this function just to disable the core.push_clip_rect +function TextView:draw() + self:draw_background(style.background) + local _, indent_size = self.doc:get_indent_info() + self:get_font():set_tab_size(indent_size) + + local minline, maxline = self:get_visible_line_range() + local lh = self:get_line_height() + + local x, y = self:get_line_screen_position(minline) + for i = minline, maxline do + self:draw_line_gutter(i, self.position.x, y) + y = y + lh + end + + x, y = self:get_line_screen_position(minline) + for i = minline, maxline do + self:draw_line_body(i, x, y) + y = y + lh + end + self:draw_overlay() + + self:draw_scrollbar() +end + +---@class widget.textbox : widget +---@overload fun(parent?:widget, text?:string, placeholder?:string):widget.textbox +---@field textview widget.textbox.TextView +---@field placeholder string +---@field placeholder_active boolean +local TextBox = Widget:extend() + +function TextBox:new(parent, text, placeholder) + TextBox.super.new(self, parent) + self.type_name = "widget.textbox" + self.textview = TextView() + self.textview.name = parent.name + self.size.x = 200 + (style.padding.x * 2) + self.textview.size.x = self.size.x + self.size.y = self:get_font():get_height() + (style.padding.y * 2) + self.placeholder = placeholder or "" + self.placeholder_active = false + -- this widget is for text input + self.input_text = true + self.cursor = "ibeam" + self.active = false + self.drag_select = false + + if text ~= "" then + self.textview:set_text(text, select) + else + self.placeholder_active = true + self.textview:set_text(self.placeholder) + end + + local this = self + + function self.textview.doc:on_text_change() + if not this.placeholder_active then + this:on_change(this:get_text()) + end + end + + -- more granular listening of text changing events + local doc_raw_insert = self.textview.doc.raw_insert + function self.textview.doc:raw_insert(...) + doc_raw_insert(self, ...) + this:on_text_change("insert", ...) + end + + local doc_raw_remove = self.textview.doc.raw_remove + function self.textview.doc:raw_remove(...) + doc_raw_remove(self, ...) + this:on_text_change("remove", ...) + end +end + +---@param width integer +function TextBox:set_size(width) + TextBox.super.set_size( + self, + width, + self:get_font():get_height() + (style.padding.y * 2) + ) + self.textview.size.x = self.size.x +end + +--- Get the text displayed on the textbox. +---@return string +function TextBox:get_text() + if self.placeholder_active then + return "" + end + return self.textview:get_text() +end + +--- Set the text displayed on the textbox. +---@param text string +---@param select? boolean +function TextBox:set_text(text, select) + self.textview:set_text(text, select) +end + +-- +-- Events +-- + +function TextBox:on_mouse_pressed(button, x, y, clicks) + if TextBox.super.on_mouse_pressed(self, button, x, y, clicks) then + self.textview:on_mouse_pressed(button, x, y, clicks) + local line, col = self.textview:resolve_screen_position(x, y) + self.drag_select = { line = line, col = col } + self.textview.doc:set_selection(line, col, line, col) + if clicks == 2 then + local line1, col1 = translate.start_of_word(self.textview.doc, line, col) + local line2, col2 = translate.end_of_word(self.textview.doc, line1, col1) + self.textview.doc:set_selection(line2, col2, line1, col1) + elseif clicks == 3 then + self.textview.doc:set_selection(1, 1, 1, math.huge) + end + if core.active_view ~= self.textview then + self.textview:on_mouse_released(button, x, y) + end + return true + end + return false +end + +function TextBox:on_mouse_released(button, x, y) + if TextBox.super.on_mouse_released(self, button, x, y) then + self.drag_select = false + self.textview:on_mouse_released(button, x, y) + return true + end + return false +end + +function TextBox:on_mouse_moved(x, y, dx, dy) + if self.drag_select then + local line, col = self.textview:resolve_screen_position(x, y) + self.textview.doc:set_selection( + self.drag_select.line, self.drag_select.col, line, col + ) + end + if TextBox.super.on_mouse_moved(self, x, y, dx, dy) then + if self.active or core.active_view == self.textview then + self.textview:on_mouse_moved(x, y, dx, dy) + end + return true + end + return false +end + +function TextBox:activate() + self.hover_border = style.caret + if self.placeholder_active then + self.placeholder_active = false + self:set_text("") + end + self.active = true + core.request_cursor("ibeam") +end + +function TextBox:deactivate() + self.hover_border = nil + self.drag_select = false + if self:get_text() == "" then + self.placeholder_active = true + self:set_text(self.placeholder) + end + self.active = false + core.request_cursor("arrow") +end + +function TextBox:on_text_input(text) + TextBox.super.on_text_input(self, text) + self.textview:on_text_input(text) +end + +---Event fired on any text change event. +---@param action string Can be "insert" or "remove", +---insert arguments (see Doc:raw_insert): +--- line, col, text, undo_stack, time +---remove arguments (see Doc:raw_remove): +--- line1, col1, line2, col2, undo_stack, time +---@diagnostic disable-next-line +function TextBox:on_text_change(action, ...) end + +function TextBox:update() + if not TextBox.super.update(self) then return false end + + if + self.drag_select + or + (self.active and self:mouse_on_top(self.mouse.x, self.mouse.y)) + then + core.request_cursor("ibeam") + end + + self.textview:update() + self.size.y = self:get_font():get_height() + (style.padding.y * 2) + + return true +end + +function TextBox:draw() + if not self:is_visible() then return false end + + self.border.color = self.hover_border or style.text + TextBox.super.draw(self) + self.textview.position.x = self.position.x + (style.padding.x / 2) + self.textview.position.y = self.position.y - (style.padding.y/2.5) + self.textview.size.x = self.size.x + self.textview.size.y = self.size.y - (style.padding.y * 2) + + core.push_clip_rect( + self.position.x, + self.position.y, + self.size.x, + self.size.y + ) + self.textview:draw() + core.pop_clip_rect() + + return true +end + + +return TextBox diff --git a/resources/amiga/config/lite-xl/libraries/widget/toggle.lua b/resources/amiga/config/lite-xl/libraries/widget/toggle.lua new file mode 100644 index 00000000..f3ed952e --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/toggle.lua @@ -0,0 +1,140 @@ +-- +-- Toggle Widget. +-- @copyright Jefferson Gonzalez +-- @license MIT +-- + +local style = require "core.style" +local Widget = require "libraries.widget" +local Label = require "libraries.widget.label" + +-- Hold dimensions of rendered toggle +local BOX = 40 +local TOGGLE = 15 +local BORDER = 3 + +---@class widget.toggle : widget +---@overload fun(parent?:widget, label?:string, enable?:boolean):widget.toggle +---@field public enabled boolean +---@field private caption_label widget.textbox +---@field private padding integer +---@field private switch_x number +---@field private toggle_bg renderer.color +local Toggle = Widget:extend() + +---Constructor +---@param parent widget +---@param label string +---@param enable boolean +function Toggle:new(parent, label, enable) + Toggle.super.new(self, parent) + + self.type_name = "widget.toggle" + + self.enabled = enable or false + self.label = label or "" + + self.caption_label = Label(self, self.label) + self.caption_label:set_position(0, 0) + + self.padding = 2 + self.border.width = 0 + + self:set_size( + self.caption_label:get_width() + (style.padding.x / 2) + (BOX * SCALE), + self.caption_label:get_height() + ((self.padding * 2) * SCALE) + ) + + self.animate_switch = false + self.toggle_x = 0 +end + +---@param enabled boolean +function Toggle:set_toggle(enabled) + self.enabled = enabled + self.animate_switch = true + self:on_change(self.enabled) +end + +---@return boolean +function Toggle:is_toggled() + return self.enabled +end + +function Toggle:toggle() + self.enabled = not self.enabled + self.animate_switch = true + self:on_change(self.enabled) +end + +---@param text string|widget.styledtext +function Toggle:set_label(text) + Toggle.super.set_label(self, text) + self.caption_label:set_label(text) +end + +function Toggle:on_click() + self:toggle() +end + +function Toggle:update() + if not Toggle.super.update(self) then return false end + + local px = style.padding.x / 2 + + self:set_size( + self.caption_label:get_width() + px + (BOX * SCALE), + self.caption_label:get_height() + ((self.padding * 2) * SCALE) + ) + + self.toggle_x = self.caption_label:get_right() + px + + local switch_x = self.enabled and + self.position.x + self.toggle_x + + ((BOX - TOGGLE - BORDER) * SCALE) + or + self.position.x + self.toggle_x + (BORDER * SCALE) + + if not self.animate_switch then + self.switch_x = switch_x + self.toggle_bg = {} + local color = self.enabled and style.caret or style.line_number + for i=1, 4, 1 do self.toggle_bg[i] = color[i] end + else + local color = self.enabled and style.caret or style.line_number + self:move_towards(self, "switch_x", switch_x, 0.2) + for i=1, 4, 1 do + self:move_towards(self.toggle_bg, i, color[i], 0.2) + end + if self.switch_x == switch_x then + self.animate_switch = false + end + end + + return true +end + +function Toggle:draw() + if not Toggle.super.draw(self) then return false end + + renderer.draw_rect( + self.position.x + self.toggle_x, + self.position.y, + BOX * SCALE, + self.size.y, + self.toggle_bg + ) + + renderer.draw_rect( + self.switch_x, + self.position.y + (BORDER * SCALE), + TOGGLE * SCALE, + self.size.y - ((BORDER * 2) * SCALE), + style.line_highlight + ) + + return true +end + + +return Toggle diff --git a/resources/amiga/config/lite-xl/libraries/widget/treelist.lua b/resources/amiga/config/lite-xl/libraries/widget/treelist.lua new file mode 100644 index 00000000..2b930642 --- /dev/null +++ b/resources/amiga/config/lite-xl/libraries/widget/treelist.lua @@ -0,0 +1,609 @@ +-- +-- TreeList Widget heavily based on TreeView plugin. +-- @copyright Jefferson Gonzalez +-- @license MIT +-- + +local core = require "core" +local common = require "core.common" +local style = require "core.style" +local Widget = require "widget" + +---@class widget.treelist.item +---@field name string +---@field label string +---@field data any? +---@field expanded boolean? +---@field visible boolean? +---@field tooltip string? +---@field depth integer? +---@field childs widget.treelist.item[]? +---@field parent widget.treelist.item[]? +---@field icon string? + +---@class widget.treelist : widget +---@field items widget.treelist.item[] +---@field selected_item widget.treelist.item? +---@field hovered_item widget.treelist.item? +---@field icon_font renderer.font? +---@field private count_lines integer +---@field private scroll_width integer +---@overload fun(parent:widget?):widget.treelist +local TreeList = Widget:extend() + +---Constructor +---@param parent? widget +function TreeList:new(parent) + TreeList.super.new(self, parent) + + self.type_name = "widget.treelist" + + self.scrollable = true + self.init_size = true + self.count_lines = 0 + self.scroll_width = 0 + self.items = {} + self.tooltip = { x = 0, y = 0, begin = 0, alpha = 0 } + self.last_scroll_y = 0 + + self.item_icon_width = 0 + self.item_text_spacing = 0 +end + +---Get the height of a single item. +---@return number h +function TreeList:get_item_height() + return style.font:get_height() + style.padding.y +end + +---Add new item to to tree list +---@param item widget.treelist.item +function TreeList:add_item(item) + table.insert(self.items, item) +end + +---Remove all items from the tree +function TreeList:clear() + self.items = {} + self.selected_item = nil + self.hovered_item = nil +end + +---Retrieve the amount of visible items and also yield them. +---@param item widget.treelist.item +---@param x number +---@param y number +---@param w number +---@param h number +---@param depth? number +---@return integer items_count +function TreeList:get_items(item, x, y, w, h, depth) + if not depth then depth = 0 else depth = depth + 1 end + item.depth = depth + coroutine.yield(item, x, y, w, h) + local count_lines = 1 + if item and item.childs and item.expanded then + for _, child in ipairs(item.childs) do + if child.visible ~= false then + count_lines = count_lines + self:get_items( + child, x, y + count_lines * h, w, h, depth + ) + end + end + end + return count_lines +end + +---Allows iterating the currently visible items only. +---@return fun():widget.treelist.item,number,number,number,number +function TreeList:each_item() + return coroutine.wrap(function() + local count_lines = 0 + local ox, oy = self:get_content_offset() + local h = self:get_item_height() + if #self.items > 0 then + for i=1, #self.items do + count_lines = count_lines + self:get_items( + self.items[i], + ox, oy + style.padding.y + h * count_lines, + self.size.x, h + ) + end + end + self.count_lines = count_lines + end) +end + +---Retrieve an item by name using the query format: +---"parent_name>child_name_2>child_name_2>etc..." +---@param query string +---@param items? widget.treelist.item[] +---@param separator? string Use a different separator (default: >) +---@return widget.treelist.item? +function TreeList:query_item(query, items, separator) + local parent = items or self.items + local item = nil + separator = separator or ">" + for name in query:gmatch("([^"..separator.."]+)") do + if parent then + local found = false + for _, child in ipairs(parent) do + if name == child.name then + item = child + parent = child.childs + found = true + break + end + end + if not found then return nil end + else + return nil + end + end + return item +end + +---Set the active item. +---@param selection widget.treelist.item +---@param selection_y? number +---@param center? boolean +---@param instant? boolean +function TreeList:set_selection(selection, selection_y, center, instant) + self.selected_item = selection + if selection and selection_y + and (selection_y <= 0 or selection_y >= self.size.y) then + local lh = self:get_item_height() + if not center and selection_y >= self.size.y - lh then + selection_y = selection_y - self.size.y + lh + end + if center then + selection_y = selection_y - (self.size.y - lh) / 2 + end + local _, y = self:get_content_offset() + self.scroll.to.y = selection_y - y + self.scroll.to.y = common.clamp( + self.scroll.to.y, 0, self:get_scrollable_size() - self.size.y + ) + if instant then + self.scroll.y = self.scroll.to.y + end + end +end + +---Sets the selection to the file with the specified path. +---TODO: Not tested, idea is to allow query like selections using item names +---by introducing a get_item/set_item methods that accepts a string query, +---For the moment we leave this inherited TreeView function here. +---@param path string #Absolute path of item to select +---@param expand boolean #Expand dirs leading to the item +---@param scroll_to boolean #Scroll to make the item visible +---@param instant boolean #Don't animate the scroll +---@return table? #The selected item +function TreeList:set_selection_from_path(path, expand, scroll_to, instant) + local separator = "||" + local to_select, to_select_y + local let_it_finish, done + ::restart:: + for item, _,y,_,_ in self:each_item() do + if not done then + if item.childs and #item.childs > 0 then + local _, to = string.find(path, item.name..separator, 1, true) + if to and to == #item.name + #separator then + to_select, to_select_y = item, y + if expand and not item.expanded then + self:toggle_expand(true, item) + -- Because we altered the size of the TreeList + -- and because TreeList:get_scrollable_size uses self.count_lines + -- which gets updated only when TreeList:each_item finishes, + -- we can't stop here or we risk that the scroll + -- gets clamped by View:clamp_scroll_position. + let_it_finish = true + -- We need to restart the process because if TreeList:toggle_expand + -- altered the cache, TreeList:each_item risks looping indefinitely. + goto restart + end + end + else + if item.name == path then + to_select, to_select_y = item, y + done = true + if not let_it_finish then break end + end + end + end + end + if to_select then + self:set_selection(to_select, scroll_to and to_select_y, true, instant) + end + return to_select +end + +---Set the icon font used to render the items icon. +---@param font renderer.font +function TreeList:set_icon_font(font) + if font:get_size() ~= style.icon_font:get_size() then + font:set_size(style.icon_font:get_size()) + end + self.icon_font = font +end + +---Keep the icon font size updated to match current scale. +function TreeList:on_scale_change() + if self.icon_font then + self.icon_font:set_size(style.icon_font:get_size()) + end +end + +function TreeList:on_mouse_moved(px, py, ...) + if TreeList.super.on_mouse_moved(self, px, py, ...) then + self.hovered_item = nil + else + return false + end + + local position = self:get_position() + local item_changed, tooltip_changed + for item, x,y,w,h in self:each_item() do + if px > x and py > y and px <= (self.size.x + position.x) and py <= y + h then + item_changed = true + self.hovered_item = item + + x = math.max(x, self.position.x) + if px > x and py > y and px <= x + w and py <= y + h then + tooltip_changed = true + self.tooltip.x, self.tooltip.y = px, py + self.tooltip.begin = system.get_time() + end + break + end + end + if not item_changed then self.hovered_item = nil end + if not tooltip_changed then self.tooltip.x, self.tooltip.y = nil, nil end + + return true +end + +---Override to listen for item click events. +---@param item widget.treelist.item +---@param button string +---@param clicks integer +function TreeList:on_item_click(item, button, x, y, clicks) end + +function TreeList:on_mouse_pressed(button, x, y, clicks) + if TreeList.super.on_mouse_pressed(self, button, x, y, clicks) then + if self:scrollbar_hovering() then return true end + self:set_selection(self.hovered_item) + if self.hovered_item then + local emit_click = false + if clicks > 1 then + self:toggle_expand() + else + if self.hovered_item.childs then + local x1, x2 = self:get_chevron_position(self.hovered_item) + if x >= x1 and x <= x2 then + self:toggle_expand() + else + emit_click = true + end + else + emit_click = true + end + end + if emit_click then + self:on_item_click(self.hovered_item, button, x, y, clicks) + end + end + return true + end + return false +end + +function TreeList:on_mouse_left() + TreeList.super.on_mouse_left(self) + self.hovered_item = nil +end + +function TreeList:update() + if not self:is_visible() then return end + + local duration = system.get_time() - self.tooltip.begin + local tooltip_delay = 0.5 + if self.hovered_item and self.tooltip.x and duration > tooltip_delay then + self:move_towards(self.tooltip, "alpha", 255, 1, "treeview") + else + self.tooltip.alpha = 0 + end + + self.item_icon_width = style.icon_font:get_width("D") + self.item_text_spacing = style.icon_font:get_width("f") / 2 + + -- this will make sure hovered_item is updated + local dy = math.abs(self.last_scroll_y - self.scroll.y) + if dy > 0 then + self:on_mouse_moved(core.root_view.mouse.x, core.root_view.mouse.y, 0, 0) + self.last_scroll_y = self.scroll.y + end + + TreeList.super.update(self) +end + +function TreeList:get_scrollable_size() + return self.count_lines and self:get_item_height() * (self.count_lines + 1) or math.huge +end + +function TreeList:get_h_scrollable_size() + local _, _, v_scroll_w = self.v_scrollbar:get_thumb_rect() + return self.scroll_width + ( + self.size.x > self.scroll_width + v_scroll_w and 0 or style.padding.x + ) +end + +local function replace_alpha(color, alpha) + local r, g, b = table.unpack(color) + return { r, g, b, alpha } +end + +function TreeList:draw_tooltip() + if not self.hovered_item or not self.hovered_item.tooltip then + return + end + + local text = common.home_encode(self.hovered_item.tooltip) + local w, h = style.font:get_width(text), style.font:get_height() + + local tooltip_offset = style.font:get_height() + local x, y = self.tooltip.x + tooltip_offset, self.tooltip.y + tooltip_offset + w, h = w + style.padding.x, h + style.padding.y + + if x + w > core.root_view.root_node.size.x then -- check if we can span right + x = x - w -- span left instead + end + + local tooltip_border = 1 + local bx, by = x - tooltip_border, y - tooltip_border + local bw, bh = w + 2 * tooltip_border, h + 2 * tooltip_border + renderer.draw_rect( + bx, by, bw, bh, replace_alpha(style.text, self.tooltip.alpha) + ) + renderer.draw_rect( + x, y, w, h, replace_alpha(style.background2, self.tooltip.alpha) + ) + common.draw_text( + style.font, + replace_alpha(style.text, self.tooltip.alpha), + text, "center", x, y, w, h + ) +end + +---@param item widget.treelist.item +---@param active boolean +---@param hovered boolean +function TreeList:get_item_icon(item, active, hovered) + local character = item.icon + local font = self.icon_font or style.icon_font + local color = style.text + if active or hovered then + color = style.accent + end + return character, font, color +end + +---@param item widget.treelist.item +---@param active boolean +---@param hovered boolean +function TreeList:get_item_text(item, active, hovered) + local text = item.label + local font = style.font + local color = style.text + if active or hovered then + color = style.accent + end + return text, font, color +end + +---@param item widget.treelist.item +---@param active boolean +---@param hovered boolean +---@param x number +---@param y number +---@param w number +---@param h number +function TreeList:draw_item_text(item, active, hovered, x, y, w, h) + local item_text, item_font, item_color = self:get_item_text(item, active, hovered) + return common.draw_text(item_font, item_color, item_text, "left", x, y, 0, h) +end + +---@param item widget.treelist.item +---@param active boolean +---@param hovered boolean +---@param x number +---@param y number +---@param w number +---@param h number +function TreeList:draw_item_icon(item, active, hovered, x, y, w, h) + local icon_char, icon_font, icon_color = self:get_item_icon(item, active, hovered) + if not icon_char then return 0 end + common.draw_text(icon_font, icon_color, icon_char, "left", x, y, 0, h) + return self.item_icon_width + self.item_text_spacing +end + +---@param item widget.treelist.item +---@param active boolean +---@param hovered boolean +---@param x number +---@param y number +---@param w number +---@param h number +function TreeList:draw_item_body(item, active, hovered, x, y, w, h) + x = x + self:draw_item_icon(item, active, hovered, x, y, w, h) + return self:draw_item_text(item, active, hovered, x, y, w, h) +end + +---@param item widget.treelist.item +---@param active boolean +---@param hovered boolean +---@param x number +---@param y number +---@param w number +---@param h number +function TreeList:draw_item_chevron(item, active, hovered, x, y, w, h) + if item.childs and #item.childs > 0 then + local chevron_icon = item.expanded and "-" or "+" + local chevron_color = hovered and style.accent or style.text + common.draw_text(style.icon_font, chevron_color, chevron_icon, "left", x, y, 0, h) + end + return style.padding.x +end + +---Get an item chevron starting and ending positions. +---@return number x1 +---@return number x2 +function TreeList:get_chevron_position(item) + local ox = self:get_content_offset() + local x1 = ox + item.depth * style.padding.x + local x2 = x1 + style.padding.x * 2 + return x1, x2 +end + +---@param item widget.treelist.item +---@param active boolean +---@param hovered boolean +---@param x number +---@param y number +---@param w number +---@param h number +function TreeList:draw_item_background(item, active, hovered, x, y, w, h) + if hovered then + local hover_color = { table.unpack(style.line_highlight) } + hover_color[4] = 160 + renderer.draw_rect(self.position.x, y, self.size.x, h, hover_color) + elseif active then + renderer.draw_rect(self.position.x, y, self.size.x, h, style.line_highlight) + end +end + +---@param item widget.treelist.item +---@param active boolean +---@param hovered boolean +---@param x number +---@param y number +---@param w number +---@param h number +function TreeList:draw_item(item, active, hovered, x, y, w, h) + self:draw_item_background(item, active, hovered, x, y, w, h) + + x = x + item.depth * style.padding.x + style.padding.x + x = x + self:draw_item_chevron(item, active, hovered, x, y, w, h) + + return self:draw_item_body(item, active, hovered, x, y, w, h) +end + +function TreeList:draw() + if not TreeList.super.draw(self) then return end + + local position, ox = self:get_position(), self:get_content_offset() + local _y, _h, sw = position.y, self.size.y, 0 + for item, x,y,w,h in self:each_item() do + if y + h >= _y and y < _y + _h then + w = self:draw_item( + item, + item == self.selected_item, + item == self.hovered_item, + x, y, w, h + ) - position.x + (position.x - ox) + sw = math.max(w, sw) + end + end + self.scroll_width = sw + + self:draw_scrollbar() + + if + self.hovered_item and self.hovered_item.tooltip + and + self.tooltip.x and self.tooltip.alpha > 0 + then + core.root_view:defer_draw(self.draw_tooltip, self) + end +end + +---@param item? widget.treelist.item +---@param where integer +---@return widget.treelist.item item +---@return number x +---@return number y +---@return number w +---@return number h +function TreeList:get_item(item, where) + item = item or self.selected_item + + local last_item, last_x, last_y, last_w, last_h + local stop = false + + for it, x, y, w, h in self:each_item() do + if not item and where >= 0 then + return it, x, y, w, h + end + if item == it then + if where < 0 and last_item then + break + elseif where == 0 or (where < 0 and not last_item) then + return it, x, y, w, h + end + stop = true + elseif stop then + item = it + return it, x, y, w, h + end + last_item, last_x, last_y, last_w, last_h = it, x, y, w, h + end + return last_item, last_x, last_y, last_w, last_h +end + +---@param item? widget.treelist.item +---@return widget.treelist.item item +---@return number x +---@return number y +---@return number w +---@return number h +function TreeList:get_next(item) + return self:get_item(item, 1) +end + +---@param item? widget.treelist.item +---@return widget.treelist.item item +---@return number x +---@return number y +---@return number w +---@return number h +function TreeList:get_previous(item) + return self:get_item(item, -1) +end + +function TreeList:select_next() + self.selected_item = self:get_next() +end + +function TreeList:select_prev() + self.selected_item = self:get_previous() +end + +---Expand or collapse the currently selected or given item. +---@param toggle? boolean +---@param item? widget.treelist.item +function TreeList:toggle_expand(toggle, item) + item = item or self.selected_item + + if not item then return end + + if item.childs and #item.childs > 0 then + if type(toggle) == "boolean" then + item.expanded = toggle + else + item.expanded = not item.expanded + end + end +end + + +return TreeList