Added widget library
This commit is contained in:
parent
757aa983cf
commit
b6a64d9f72
|
@ -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.
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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()
|
|
@ -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
|
||||
})
|
|
@ -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
|
|
@ -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",
|
||||
}
|
|
@ -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
|
||||
})
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in New Issue