Added widget library

This commit is contained in:
George Sokianos 2024-12-26 18:12:10 +00:00
parent 757aa983cf
commit b6a64d9f72
37 changed files with 10111 additions and 0 deletions

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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
})

View File

@ -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

View File

@ -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",
}

View File

@ -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
})

View File

@ -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<integer,string>
---@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<string,integer>
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<integer, string>
---@return table<integer,string>
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

View File

@ -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

View File

@ -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

View File

@ -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<integer, string>
---@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

View File

@ -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

View File

@ -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<integer, string> | 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

View File

@ -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<integer, string>
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

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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<integer, string>
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

View File

@ -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<integer, string>
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<integer, string>
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

View File

@ -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

View File

@ -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

View File

@ -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<integer, renderer.font|widget.fontreference|renderer.color|integer|string|widget.listbox.drawcol>
---@alias widget.listbox.colpos table<integer,integer>
---@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<integer, integer>
---@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

View File

@ -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"
]
}
]
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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