-- mod-version:3 --priority:0 local core = require "core" local config = require "core.config" local common = require "core.common" local command = require "core.command" local keymap = require "core.keymap" local style = require "core.style" local View = require "core.view" local DocView = require "core.docview" -- check if widget is installed before proceeding local widget_found, Widget = pcall(require, "libraries.widget") if not widget_found then core.error("Widget library not found: https://github.com/lite-xl/lite-xl-widgets") return false end local Label = require "libraries.widget.label" local Line = require "libraries.widget.line" local NoteBook = require "libraries.widget.notebook" local Button = require "libraries.widget.button" local TextBox = require "libraries.widget.textbox" local SelectBox = require "libraries.widget.selectbox" local NumberBox = require "libraries.widget.numberbox" local Toggle = require "libraries.widget.toggle" local ListBox = require "libraries.widget.listbox" local FoldingBook = require "libraries.widget.foldingbook" local FontsList = require "libraries.widget.fontslist" local ItemsList = require "libraries.widget.itemslist" local KeybindingDialog = require "libraries.widget.keybinddialog" local Fonts = require "libraries.widget.fonts" local FilePicker = require "libraries.widget.filepicker" local ColorPicker = require "libraries.widget.colorpicker" local MessageBox = require "libraries.widget.messagebox" ---@class plugins.settings local settings = {} settings.core = {} settings.plugins = {} settings.sections = {} settings.plugin_sections = {} settings.config = {} settings.default_keybindings = {} ---Enumeration for the different types of settings. ---@type table<string, integer> settings.type = { STRING = 1, NUMBER = 2, TOGGLE = 3, SELECTION = 4, LIST_STRINGS = 5, BUTTON = 6, FONT = 7, FILE = 8, DIRECTORY = 9, COLOR = 10 } ---@alias settings.types ---| `settings.type.STRING` ---| `settings.type.NUMBER` ---| `settings.type.TOGGLE` ---| `settings.type.SELECTION` ---| `settings.type.LIST_STRINGS` ---| `settings.type.BUTTON` ---| `settings.type.FONT` ---| `settings.type.FILE` ---| `settings.type.DIRECTORY` ---| `settings.type.COLOR` ---Represents a setting to render on a settings pane. ---@class settings.option ---Title displayed to the user eg: "My Option" ---@field public label string ---Description of the option eg: "Modifies the document indentation" ---@field public description string ---Config path in the config table, eg: section.myoption, myoption, etc... ---@field public path string ---Type of option that will be used to render an appropriate control ---@field public type settings.types | integer ---Default value of the option ---@field public default string | number | boolean | table<integer, string> | table<integer, integer> ---Used for NUMBER to indicate the minimum number allowed ---@field public min number ---Used for NUMBER to indiciate the maximum number allowed ---@field public max number ---Used for NUMBER to indiciate the increment/decrement amount ---@field public step number ---Used in a SELECTION to provide the list of valid options ---@field public values table ---Optionally used for FONT to store the generated font group. ---@field public fonts_list table<string, renderer.font> ---Flag set to true when loading user defined fonts fail ---@field public font_error boolean ---Optional function that is used to manipulate the current value on retrieval. ---@field public get_value nil | fun(value:any):any ---Optional function that is used to manipulate the saved value on save. ---@field public set_value nil | fun(value:any):any ---The icon set for a BUTTON ---@field public icon string ---Command or function executed when a BUTTON is clicked ---@field public on_click nil | string | fun(button:string, x:integer, y:integer) ---Optional function executed when the option value is applied. ---@field public on_apply nil | fun(value:any) ---When FILE or DIRECTORY this flag tells the path should exist. ---@field public exists boolean ---Lua patterns used on FILE or DIRECTORY to filter browser results and ---also force the selection to match one of the filters. ---@field public filters table<integer,string> ---Add a new settings section to the settings UI ---@param section string ---@param options settings.option[] ---@param plugin_name? string Optional name of plugin ---@param overwrite? boolean Overwrite previous section options function settings.add(section, options, plugin_name, overwrite) local category = "" if plugin_name ~= nil then category = "plugins" else category = "core" end if overwrite and settings[category][section] then settings[category][section] = {} end if not settings[category][section] then settings[category][section] = {} if category ~= "plugins" then table.insert(settings.sections, section) else table.insert(settings.plugin_sections, section) end end if plugin_name ~= nil then if not settings[category][section][plugin_name] then settings[category][section][plugin_name] = {} end for _, option in ipairs(options) do table.insert(settings[category][section][plugin_name], option) end else for _, option in ipairs(options) do table.insert(settings[category][section], option) end end end -------------------------------------------------------------------------------- -- Add Core Settings -------------------------------------------------------------------------------- settings.add("General", { { label = "User Module", description = "Open your init.lua for customizations.", type = settings.type.BUTTON, icon = "P", on_click = "core:open-user-module" }, { label = "Clear Fonts Cache", description = "Delete current font cache and regenerate a fresh one.", type = settings.type.BUTTON, icon = "C", on_click = function() if Fonts.cache_is_building() then MessageBox.warning( "Clear Fonts Cache", { "The font cache is already been built,\n" .. "status will be logged on the core log." } ) else MessageBox.info( "Clear Fonts Cache", { "Re-building the font cache can take some time,\n" .. "it is needed when you have installed new fonts\n" .. "which are not listed on the font picker tool.\n\n" .. "Do you want to continue?" }, function(_, button_id, _) if button_id == 1 then Fonts.clean_cache() end end, MessageBox.BUTTONS_YES_NO ) end end }, { label = "Maximum Project Files", description = "The maximum amount of project files to register.", path = "max_project_files", type = settings.type.NUMBER, default = 2000, min = 1, max = 100000, on_apply = function() core.rescan_project_directories() end }, { label = "File Size Limit", description = "The maximum file size in megabytes allowed for editing.", path = "file_size_limit", type = settings.type.NUMBER, default = 10, min = 1, max = 50 }, { label = "Ignore Files", description = "List of lua patterns matching files to be ignored by the editor.", path = "ignore_files", type = settings.type.LIST_STRINGS, default = { -- folders "^%.svn/", "^%.git/", "^%.hg/", "^CVS/", "^%.Trash/", "^%.Trash%-.*/", "^node_modules/", "^%.cache/", "^__pycache__/", -- files "%.pyc$", "%.pyo$", "%.exe$", "%.dll$", "%.obj$", "%.o$", "%.a$", "%.lib$", "%.so$", "%.dylib$", "%.ncb$", "%.sdf$", "%.suo$", "%.pdb$", "%.idb$", "%.class$", "%.psd$", "%.db$", "^desktop%.ini$", "^%.DS_Store$", "^%.directory$", }, on_apply = function() core.rescan_project_directories() end }, { label = "Maximum Clicks", description = "The maximum amount of consecutive clicks that are registered by the editor.", path = "max_clicks", type = settings.type.NUMBER, default = 3, min = 1, max = 10 }, } ) settings.add("Graphics", { { label = "Frames Per Second", description = "Lower value for low end machines and higher for a smoother experience.", path = "fps", type = settings.type.NUMBER, default = 60, min = 10, max = 300 }, { label = "Transitions", description = "If disabled turns off all transitions but improves rendering performance.", path = "transitions", type = settings.type.TOGGLE, default = true }, { label = "Animation Rate", description = "The amount of time it takes for a transition to finish.", path = "animation_rate", type = settings.type.NUMBER, default = 1.0, min = 0.5, max = 3.0, step = 0.1 }, { label = "Animate Mouse Drag Scroll", description = "Causes higher cpu usage but smoother scroll transition.", path = "animate_drag_scroll", type = settings.type.TOGGLE, default = false }, { label = "Disable Scrolling Transitions", path = "disabled_transitions.scroll", type = settings.type.TOGGLE, default = false }, { label = "Disable Command View Transitions", path = "disabled_transitions.commandview", type = settings.type.TOGGLE, default = false }, { label = "Disable Context Menu Transitions", path = "disabled_transitions.contextmenu", type = settings.type.TOGGLE, default = false }, { label = "Disable Log View Transitions", path = "disabled_transitions.logview", type = settings.type.TOGGLE, default = false }, { label = "Disable Nag Bar Transitions", path = "disabled_transitions.nagbar", type = settings.type.TOGGLE, default = false }, { label = "Disable Tab Transitions", path = "disabled_transitions.tabs", type = settings.type.TOGGLE, default = false }, { label = "Disable Tab Drag Transitions", path = "disabled_transitions.tab_drag", type = settings.type.TOGGLE, default = false }, { label = "Disable Status Bar Transitions", path = "disabled_transitions.statusbar", type = settings.type.TOGGLE, default = false }, } ) settings.add("User Interface", { { label = "Font", description = "The font and fallbacks used on non code text.", path = "font", type = settings.type.FONT, fonts_list = style, default = { fonts = { { name = "Fira Sans Regular", path = DATADIR .. "/fonts/FiraSans-Regular.ttf" } }, options = { size = 15, antialiasing = "subpixel", hinting = "slight" } } }, { label = "Borderless", description = "Use built-in window decorations.", path = "borderless", type = settings.type.TOGGLE, default = false, on_apply = function() core.configure_borderless_window() end }, { label = "Always Show Tabs", description = "Shows tabs even if a single document is opened.", path = "always_show_tabs", type = settings.type.TOGGLE, default = true }, { label = "Maximum Tabs", description = "The maximum amount of visible document tabs.", path = "max_tabs", type = settings.type.NUMBER, default = 8, min = 1, max = 100 }, { label = "Close Button on Tabs", description = "Display the close button on tabs.", path = "tab_close_button", type = settings.type.TOGGLE, default = true }, { label = "Mouse wheel scroll rate", description = "The amount to scroll when using the mouse wheel.", path = "mouse_wheel_scroll", type = settings.type.NUMBER, default = 50, min = 10, max = 200, get_value = function(value) return value / SCALE end, set_value = function(value) return value * SCALE end }, { label = "Force Scrollbar Status", description = "Choose a fixed scrollbar state instead of resizing it on mouse hover.", path = "force_scrollbar_status", type = settings.type.SELECTION, default = false, values = { {"Disabled", false}, {"Expanded", "expanded"}, {"Contracted", "contracted"} }, on_apply = function(value) local mode = config.force_scrollbar_status_mode or "global" local globally = mode == "global" local views = core.root_view.root_node:get_children() for _, view in ipairs(views) do if globally or view:extends(DocView) then view.h_scrollbar:set_forced_status(value) view.v_scrollbar:set_forced_status(value) else view.h_scrollbar:set_forced_status(false) view.v_scrollbar:set_forced_status(false) end end end }, { label = "Force Scrollbar Status Mode", description = "Choose between applying globally or document views only.", path = "force_scrollbar_status_mode", type = settings.type.SELECTION, default = "global", values = { {"Documents", "docview"}, {"Globally", "global"} }, on_apply = function(value) local globally = value == "global" local views = core.root_view.root_node:get_children() for _, view in ipairs(views) do if globally or view:extends(DocView) then view.h_scrollbar:set_forced_status(config.force_scrollbar_status) view.v_scrollbar:set_forced_status(config.force_scrollbar_status) else view.h_scrollbar:set_forced_status(false) view.v_scrollbar:set_forced_status(false) end end end }, { label = "Disable Cursor Blinking", description = "Disables cursor blinking on text input elements.", path = "disable_blink", type = settings.type.TOGGLE, default = false }, { label = "Cursor Blinking Period", description = "Interval in seconds in which the cursor blinks.", path = "blink_period", type = settings.type.NUMBER, default = 0.8, min = 0.3, max = 2.0, step = 0.1 } } ) settings.add("Editor", { { label = "Code Font", description = "The font and fallbacks used on the code editor.", path = "code_font", type = settings.type.FONT, fonts_list = style, default = { fonts = { { name = "JetBrains Mono Regular", path = DATADIR .. "/fonts/JetBrainsMono-Regular.ttf" } }, options = { size = 15, antialiasing = "subpixel", hinting = "slight" } } }, { label = "Indentation Type", description = "The character inserted when pressing the tab key.", path = "tab_type", type = settings.type.SELECTION, default = "soft", values = { {"Space", "soft"}, {"Tab", "hard"} } }, { label = "Indentation Size", description = "Amount of spaces shown per indentation.", path = "indent_size", type = settings.type.NUMBER, default = 2, min = 1, max = 10 }, { label = "Keep Newline Whitespace", description = "Do not remove whitespace when pressing enter.", path = "keep_newline_whitespace", type = settings.type.TOGGLE, default = false }, { label = "Line Limit", description = "Amount of characters at which the line breaking column will be drawn.", path = "line_limit", type = settings.type.NUMBER, default = 80, min = 1 }, { label = "Line Height", description = "The amount of spacing between lines.", path = "line_height", type = settings.type.NUMBER, default = 1.2, min = 1.0, max = 3.0, step = 0.1 }, { label = "Highlight Line", description = "Highlight the current line.", path = "highlight_current_line", type = settings.type.SELECTION, default = true, values = { {"Yes", true}, {"No", false}, {"No Selection", "no_selection"} }, set_value = function(value) if type(value) == "nil" then return false end return value end }, { label = "Maximum Undo History", description = "The amount of undo elements to keep.", path = "max_undos", type = settings.type.NUMBER, default = 10000, min = 100, max = 100000 }, { label = "Undo Merge Timeout", description = "Time in seconds before applying an undo action.", path = "undo_merge_timeout", type = settings.type.NUMBER, default = 0.3, min = 0.1, max = 1.0, step = 0.1 }, { label = "Symbol Pattern", description = "A lua pattern used to match symbols in the document.", path = "symbol_pattern", type = settings.type.STRING, default = "[%a_][%w_]*" }, { label = "Non Word Characters", description = "A string of characters that do not belong to a word.", path = "non_word_chars", type = settings.type.STRING, default = " \\t\\n/\\()\"':,.;<>~!@#$%^&*|+=[]{}`?-", get_value = function(value) return value:gsub("\n", "\\n"):gsub("\t", "\\t") end, set_value = function(value) return value:gsub("\\n", "\n"):gsub("\\t", "\t") end }, { label = "Scroll Past the End", description = "Allow scrolling beyond the document ending.", path = "scroll_past_end", type = settings.type.TOGGLE, default = true } } ) settings.add("Development", { { label = "Core Log", description = "Open the list of logged messages.", type = settings.type.BUTTON, icon = "f", on_click = "core:open-log" }, { label = "Log Items", description = "The maximum amount of entries to keep on the log UI.", path = "max_log_items", type = settings.type.NUMBER, default = 800, min = 150, max = 2000 }, { label = "Skip Plugins Version", description = "Do not verify the plugins required versions at startup.", path = "skip_plugins_version", type = settings.type.TOGGLE, default = false } } ) settings.add("Status Bar", { { label = "Enabled", description = "Toggle the default visibility of the status bar.", path = "statusbar.enabled", type = settings.type.TOGGLE, default = true, on_apply = function(enabled) if enabled then core.status_view:show() else core.status_view:hide() end end }, { label = "Show Notifications", description = "Toggle the visibility of status messages.", path = "statusbar.messages", type = settings.type.TOGGLE, default = true, on_apply = function(enabled) core.status_view:display_messages(enabled) end }, { label = "Messages Timeout", description = "The amount in seconds before a notification dissapears.", path = "message_timeout", type = settings.type.NUMBER, default = 5, min = 1, max = 30 } } ) ---Retrieve from given config the associated value using the given path. ---@param conf table ---@param path string ---@param default any ---@return any | nil local function get_config_value(conf, path, default) local sections = {}; for match in (path.."."):gmatch("(.-)%.") do table.insert(sections, match); end local element = conf for _, section in ipairs(sections) do if type(element[section]) ~= "nil" then element = element[section] else return default end end if type(element) == "nil" then return default end return element end ---Loops the given config table using the given path and store the value. ---@param conf table ---@param path string ---@param value any local function set_config_value(conf, path, value) local sections = {}; for match in (path.."."):gmatch("(.-)%.") do table.insert(sections, match); end local sections_count = #sections if sections_count == 1 then conf[sections[1]] = value return elseif type(conf[sections[1]]) ~= "table" then conf[sections[1]] = {} end local element = conf for idx, section in ipairs(sections) do if type(element[section]) ~= "table" then element[section] = {} element = element[section] else element = element[section] end if idx + 1 == sections_count then break end end element[sections[sections_count]] = value end ---Get a list of system and user installed plugins. ---@return table<integer, string> local function get_installed_plugins() local files, ordered = {}, {} for _, root_dir in ipairs {DATADIR, USERDIR} do local plugin_dir = root_dir .. "/plugins" for _, filename in ipairs(system.list_dir(plugin_dir) or {}) do local valid = false local file_info = system.get_file_info(plugin_dir .. "/" .. filename) if file_info then if file_info.type == "file" and filename:match("%.lua$") and not filename:match("^language_") then valid = true filename = filename:gsub("%.lua$", "") elseif file_info.type == "dir" then if system.get_file_info(plugin_dir .. "/" .. filename .. "/init.lua") then valid = true end end end if valid then if not files[filename] then table.insert(ordered, filename) end files[filename] = true end end end table.sort(ordered) return ordered end ---Get a list of system and user installed colors. ---@return table<integer, table> local function get_installed_colors() local files, ordered = {}, {} for _, root_dir in ipairs {DATADIR, USERDIR} do local dir = root_dir .. "/colors" for _, filename in ipairs(system.list_dir(dir) or {}) do local file_info = system.get_file_info(dir .. "/" .. filename) if file_info and file_info.type == "file" and filename:match("%.lua$") then -- read colors local contents = io.open(dir .. "/" .. filename):read("*a") local colors = {} for r, g, b in contents:gmatch("#(%x%x)(%x%x)(%x%x)") do r = tonumber(r, 16) g = tonumber(g, 16) b = tonumber(b, 16) table.insert(colors, { r, g, b, 0xff }) end -- sort colors from darker to lighter table.sort(colors, function(a, b) return a[1] + a[2] + a[3] < b[1] + b[2] + b[3] end) -- remove duplicate colors local b = {} for i = #colors, 1, -1 do local a = colors[i] if a[1] == b[1] and a[2] == b[2] and a[3] == b[3] then table.remove(colors, i) else b = colors[i] end end -- insert color to ordered table if not duplicate filename = filename:gsub("%.lua$", "") if not files[filename] then table.insert(ordered, {name = filename, colors = colors}) end files[filename] = true end end end table.sort(ordered, function(a, b) return a.name < b.name end) return ordered end ---Capitalize first letter of every word. ---Taken from core.command. ---@param words string ---@return string local function capitalize_first(words) return words:sub(1, 1):upper() .. words:sub(2) end ---Similar to command prettify_name but also takes care of underscores. ---@param name string ---@return string local function prettify_name(name) name = name:gsub("[%-_]", " "):gsub("%S+", capitalize_first) return name end ---Load config options from the USERDIR user_settings.lua and store them on ---settings.config for later usage. local function load_settings() local ok, t = pcall(dofile, USERDIR .. "/user_settings.lua") settings.config = ok and t.config or {} end ---Save current config options into the USERDIR user_settings.lua local function save_settings() local fp = io.open(USERDIR .. "/user_settings.lua", "w") if fp then local output = "{\n [\"config\"] = " .. common.serialize( settings.config, { pretty = true, escape = true, sort = true, initial_indent = 1 } ):gsub("^%s+", "") .. "\n}\n" fp:write("return ", output) fp:close() end end ---Apply a keybinding and optionally save it. ---@param cmd string ---@param bindings table<integer, string> ---@param skip_save? boolean ---@return table | nil local function apply_keybinding(cmd, bindings, skip_save) local row_value = nil local changed = false local original_bindings = { keymap.get_binding(cmd) } for _, binding in ipairs(original_bindings) do keymap.unbind(binding, cmd) end if #bindings > 0 then if not skip_save and settings.config.custom_keybindings and settings.config.custom_keybindings[cmd] then settings.config.custom_keybindings[cmd] = {} end local shortcuts = "" for _, binding in ipairs(bindings) do if not binding:match("%+$") and binding ~= "" and binding ~= "none" then keymap.add({[binding] = cmd}) shortcuts = shortcuts .. binding .. "\n" if not skip_save then if not settings.config.custom_keybindings then settings.config.custom_keybindings = {} settings.config.custom_keybindings[cmd] = {} elseif not settings.config.custom_keybindings[cmd] then settings.config.custom_keybindings[cmd] = {} end table.insert(settings.config.custom_keybindings[cmd], binding) changed = true end end end if shortcuts ~= "" then local bindings_list = shortcuts:gsub("\n$", "") row_value = { style.text, cmd, ListBox.COLEND, style.dim, bindings_list } end elseif not skip_save and settings.config.custom_keybindings and settings.config.custom_keybindings[cmd] then settings.config.custom_keybindings[cmd] = nil changed = true end if changed then save_settings() end if not row_value then row_value = { style.text, cmd, ListBox.COLEND, style.dim, "none" } end return row_value end ---Load the saved fonts into the config path or fonts_list table. ---@param option settings.option ---@param path string ---@param saved_value any local function merge_font_settings(option, path, saved_value) local font_options = saved_value.options or { size = 15, antialiasing = "supixel", hinting = "slight" } font_options.size = font_options.size or 15 font_options.antialiasing = font_options.antialiasing or "subpixel" font_options.hinting = font_options.hinting or "slight" local fonts = {} local font_loaded = true for _, font in ipairs(saved_value.fonts) do local font_data = nil font_loaded = core.try(function() font_data = renderer.font.load( font.path, font_options.size * SCALE, font_options ) end) if font_loaded then table.insert(fonts, font_data) else option.font_error = true core.error("Settings: could not load %s\n'%s - %s'", path, font.name, font.path) break end end if font_loaded then if option.fonts_list then set_config_value(option.fonts_list, option.path, renderer.font.group(fonts)) else set_config_value(config, path, renderer.font.group(fonts)) end end end ---Load the user_settings.lua stored options for a plugin into global config. ---@param plugin_name string ---@param options settings.option[] local function merge_plugin_settings(plugin_name, options) for _, option in pairs(options) do if type(option.path) == "string" then local path = "plugins." .. plugin_name .. "." .. option.path local saved_value = get_config_value(settings.config, path) if type(saved_value) ~= "nil" then if option.type == settings.type.FONT or option.type == "font" then merge_font_settings(option, path, saved_value) else set_config_value(config, path, saved_value) end if option.on_apply then option.on_apply(saved_value) end end end end end ---Merge previously saved settings without destroying the config table. local function merge_settings() if type(settings.config) ~= "table" then return end -- merge core settings for _, section in ipairs(settings.sections) do local options = settings.core[section] for _, option in ipairs(options) do if type(option.path) == "string" then local saved_value = get_config_value(settings.config, option.path) if type(saved_value) ~= "nil" then if option.type == settings.type.FONT or option.type == "font" then merge_font_settings(option, option.path, saved_value) else set_config_value(config, option.path, saved_value) end if option.on_apply then option.on_apply(saved_value) end end end end end -- merge plugin settings table.sort(settings.plugin_sections) for _, section in ipairs(settings.plugin_sections) do local plugins = settings.plugins[section] for plugin_name, options in pairs(plugins) do merge_plugin_settings(plugin_name, options) end end -- apply custom keybindings if settings.config.custom_keybindings then for cmd, bindings in pairs(settings.config.custom_keybindings) do apply_keybinding(cmd, bindings, true) end end end ---Scan all plugins to check if they define a config_spec and load it. local function scan_plugins_spec() for plugin, conf in pairs(config.plugins) do if type(conf) == "table" and conf.config_spec then settings.add( conf.config_spec.name, conf.config_spec, plugin ) end end end ---Called at core first run to store the default keybindings. local function store_default_keybindings() for name, _ in pairs(command.map) do local keys = { keymap.get_binding(name) } if #keys > 0 then settings.default_keybindings[name] = keys end end end ---@class settings.ui : widget ---@field private notebook widget.notebook ---@field private core widget ---@field private colors widget ---@field private plugins widget ---@field private keybinds widget ---@field private about widget ---@field private core_sections widget.foldingbook ---@field private plugin_sections widget.foldingbook local Settings = Widget:extend() ---Constructor function Settings:new() Settings.super.new(self, nil, false) self.name = "Settings" self.defer_draw = false self.border.width = 0 self.draggable = false self.scrollable = false ---@type widget.notebook self.notebook = NoteBook(self) self.notebook.size.x = 250 self.notebook.size.y = 300 self.notebook.border.width = 0 self.core = self.notebook:add_pane("core", "Core") self.colors = self.notebook:add_pane("colors", "Colors") self.plugins = self.notebook:add_pane("plugins", "Plugins") self.keybinds = self.notebook:add_pane("keybindings", "Keybindings") self.about = self.notebook:add_pane("about", "About") self.notebook:set_pane_icon("core", "P") self.notebook:set_pane_icon("colors", "W") self.notebook:set_pane_icon("plugins", "B") self.notebook:set_pane_icon("keybindings", "M") self.notebook:set_pane_icon("about", "i") self.core_sections = FoldingBook(self.core) self.core_sections.border.width = 0 self.core_sections.scrollable = false self.plugin_sections = FoldingBook(self.plugins) self.plugin_sections.border.width = 0 self.plugin_sections.scrollable = false self:load_core_settings() self:load_color_settings() self:load_plugin_settings() self:load_keymap_settings() self:setup_about() end ---Helper function to add control for both core and plugin settings. ---@oaram pane widget ---@param option settings.option ---@param plugin_name? string | nil local function add_control(pane, option, plugin_name) local found = false local path = type(plugin_name) ~= "nil" and "plugins." .. plugin_name .. "." .. option.path or option.path local option_value = nil if type(path) ~= "nil" then option_value = get_config_value(config, path, option.default) end if option.get_value then option_value = option.get_value(option_value) end ---@type widget local widget = nil if type(option.type) == "string" then option.type = settings.type[option.type:upper()] end if option.type == settings.type.NUMBER then ---@type widget.label Label(pane, option.label .. ":") ---@type widget.numberbox local number = NumberBox(pane, option_value, option.min, option.max, option.step) widget = number found = true elseif option.type == settings.type.TOGGLE then ---@type widget.toggle local toggle = Toggle(pane, option.label, option_value) widget = toggle found = true elseif option.type == settings.type.STRING then ---@type widget.label Label(pane, option.label .. ":") ---@type widget.textbox local string = TextBox(pane, option_value or "") widget = string found = true elseif option.type == settings.type.SELECTION then ---@type widget.label Label(pane, option.label .. ":") ---@type widget.selectbox local select = SelectBox(pane) for _, data in pairs(option.values) do select:add_option(data[1], data[2]) end for idx, _ in ipairs(select.list.rows) do if select.list:get_row_data(idx) == option_value then select:set_selected(idx-1) break end end widget = select found = true elseif option.type == settings.type.BUTTON then ---@type widget.button local button = Button(pane, option.label) if option.icon then button:set_icon(option.icon) end if option.on_click then local command_type = type(option.on_click) if command_type == "string" then function button:on_click() command.perform(option.on_click) end elseif command_type == "function" then button.on_click = option.on_click end end widget = button found = true elseif option.type == settings.type.LIST_STRINGS then ---@type widget.label Label(pane, option.label .. ":") ---@type widget.itemslist local list = ItemsList(pane) if type(option_value) == "table" then for _, value in ipairs(option_value) do list:add_item(value) end end widget = list found = true elseif option.type == settings.type.FONT then --get fonts without conversion to renderer.font if type(path) ~= "nil" then if not option.font_error then option_value = get_config_value(settings.config, path, option.default) else --fallback to default fonts if error loading user defined ones option_value = option.default end end ---@type widget.label Label(pane, option.label .. ":") ---@type widget.fontslist local fonts = FontsList(pane) if type(option_value) == "table" then for _, font in ipairs(option_value.fonts) do fonts:add_font(font) end local font_options = option_value.options or { size = 15, antialiasing = "supixel", hinting = "slight" } font_options.size = font_options.size or 15 font_options.antialiasing = font_options.antialiasing or "subpixel" font_options.hinting = font_options.hinting or "slight" fonts:set_options(font_options) end widget = fonts found = true elseif option.type == settings.type.FILE then ---@type widget.label Label(pane, option.label .. ":") ---@type widget.filepicker local file = FilePicker(pane, option_value or "") if option.exists then file:set_mode(FilePicker.mode.FILE_EXISTS) else file:set_mode(FilePicker.mode.FILE) end file.filters = option.filters or {} widget = file found = true elseif option.type == settings.type.DIRECTORY then ---@type widget.label Label(pane, option.label .. ":") ---@type widget.filepicker local file = FilePicker(pane, option_value or "") if option.exists then file:set_mode(FilePicker.mode.DIRECTORY_EXISTS) else file:set_mode(FilePicker.mode.DIRECTORY) end file.filters = option.filters or {} widget = file found = true elseif option.type == settings.type.COLOR then ---@type widget.label Label(pane, option.label .. ":") ---@type widget.colorpicker local color = ColorPicker(pane, option_value) widget = color found = true end if widget and type(path) ~= "nil" then function widget:on_change(value) if self:is(SelectBox) then value = self:get_selected_data() elseif self:is(ItemsList) then value = self:get_items() elseif self:is(FontsList) then value = { fonts = self:get_fonts(), options = self:get_options() } end if option.set_value then value = option.set_value(value) end if self:is(FontsList) then local fonts = {} for _, font in ipairs(value.fonts) do table.insert(fonts, renderer.font.load( font.path, value.options.size * SCALE, value.options )) end if option.fonts_list then set_config_value(option.fonts_list, path, renderer.font.group(fonts)) else set_config_value(config, path, renderer.font.group(fonts)) end else set_config_value(config, path, value) end set_config_value(settings.config, path, value) save_settings() if option.on_apply then option.on_apply(value) end end end if (option.description or option.default) and found then local text = option.description or "" local default = "" local default_type = type(option.default) if default_type ~= "table" and default_type ~= "nil" then if text ~= "" then text = text .. " " end default = string.format("(default: %s)", option.default) end ---@type widget.label local description = Label(pane, text .. default) description.desc = true end end ---Generate all the widgets for core settings. function Settings:load_core_settings() for _, section in ipairs(settings.sections) do local options = settings.core[section] ---@type widget|widget.foldingbook.pane|nil local pane = self.core_sections:get_pane(section) if not pane then pane = self.core_sections:add_pane(section, section) else pane = pane.container end for _, opt in ipairs(options) do ---@type settings.option local option = opt add_control(pane, option) end end end ---Function in charge of rendering the colors column of the color pane. ---@param self widget.listbox ---@oaram row integer ---@param x integer ---@param y integer ---@param font renderer.font ---@param color renderer.color ---@param only_calc boolean ---@return number width ---@return number height local function on_color_draw(self, row, x, y, font, color, only_calc) local w = self:get_width() - (x - self.position.x) - style.padding.x local h = font:get_height() if not only_calc then local row_data = self:get_row_data(row) local width = w/#row_data.colors for i = 1, #row_data.colors do renderer.draw_rect(x + ((i - 1) * width), y, width, h, row_data.colors[i]) end end return w, h end ---Generate the list of all available colors with preview function Settings:load_color_settings() self.colors.scrollable = false local colors = get_installed_colors() ---@type widget.listbox local listbox = ListBox(self.colors) listbox.border.width = 0 listbox:enable_expand(true) listbox:add_column("Theme") listbox:add_column("Colors") for idx, details in ipairs(colors) do local name = details.name if settings.config.theme and settings.config.theme == name then listbox:set_selected(idx) end listbox:add_row({ style.text, name, ListBox.COLEND, on_color_draw }, {name = name, colors = details.colors}) end function listbox:on_row_click(idx, data) core.reload_module("colors." .. data.name) settings.config.theme = data.name save_settings() end end ---Unload a plugin settings from plugins section. ---@param plugin string function Settings:disable_plugin(plugin) for _, section in ipairs(settings.plugin_sections) do local plugins = settings.plugins[section] for plugin_name, options in pairs(plugins) do if plugin_name == plugin then self.plugin_sections:delete_pane(section) end end end if type(settings.config.enabled_plugins) == "table" and settings.config.enabled_plugins[plugin] then settings.config.enabled_plugins[plugin] = nil end if type(settings.config.disabled_plugins) ~= "table" then settings.config.disabled_plugins = {} end settings.config.disabled_plugins[plugin] = true save_settings() end ---Load plugin and append its settings to the plugins section. ---@param plugin string function Settings:enable_plugin(plugin) local loaded = false local config_type = type(config.plugins[plugin]) if config_type == "boolean" or config_type == "nil" then config.plugins[plugin] = {} loaded = true end require("plugins." .. plugin) if config.plugins[plugin] and config.plugins[plugin].config_spec then local conf = config.plugins[plugin].config_spec settings.add(conf.name, conf, plugin, true) end for _, section in ipairs(settings.plugin_sections) do local plugins = settings.plugins[section] for plugin_name, options in pairs(plugins) do if plugin_name == plugin then ---@type widget|widget.foldingbook.pane|nil local pane = self.plugin_sections:get_pane(section) if not pane then pane = self.plugin_sections:add_pane(section, section) else pane = pane.container end merge_plugin_settings(plugin, options) for _, opt in ipairs(options) do ---@type settings.option local option = opt add_control(pane, option, plugin_name) end end end end if type(settings.config.disabled_plugins) == "table" and settings.config.disabled_plugins[plugin] then settings.config.disabled_plugins[plugin] = nil end if type(settings.config.enabled_plugins) ~= "table" then settings.config.enabled_plugins = {} end settings.config.enabled_plugins[plugin] = true save_settings() if loaded then core.log("Loaded '%s' plugin", plugin) end end ---Generate all the widgets for plugin settings. function Settings:load_plugin_settings() ---@type widget|widget.foldingbook.pane|nil local pane = self.plugin_sections:get_pane("enable_disable") if not pane then pane = self.plugin_sections:add_pane("enable_disable", "Installed") else pane = pane.container end -- requires earlier access to startup process Label( pane, "Notice: disabling plugins will not take effect until next restart" ) Line(pane, 2, 10) local plugins = get_installed_plugins() for _, plugin in ipairs(plugins) do if plugin ~= "settings" then local enabled = false if ( type(config.plugins[plugin]) ~= "nil" and config.plugins[plugin] ~= false ) or ( settings.config.enabled_plugins and settings.config.enabled_plugins[plugin] ) then enabled = true end local this = self ---@type widget.toggle local toggle = Toggle(pane, prettify_name(plugin), enabled) function toggle:on_change(value) if value then this:enable_plugin(plugin) else this:disable_plugin(plugin) end end end end table.sort(settings.plugin_sections) for _, section in ipairs(settings.plugin_sections) do local plugins = settings.plugins[section] for plugin_name, options in pairs(plugins) do ---@type widget|widget.foldingbook.pane|nil local pane = self.plugin_sections:get_pane(section) if not pane then pane = self.plugin_sections:add_pane(section, section) else pane = pane.container end for _, opt in ipairs(options) do ---@type settings.option local option = opt add_control(pane, option, plugin_name) end end end end ---@type widget.keybinddialog local keymap_dialog = KeybindingDialog() function keymap_dialog:on_save(bindings) local row_value = apply_keybinding(self.command, bindings) if row_value then self.listbox:set_row(self.row_id, row_value) end end function keymap_dialog:on_reset() local default_keys = settings.default_keybindings[self.command] local current_keys = { keymap.get_binding(self.command) } for _, binding in ipairs(current_keys) do keymap.unbind(binding, self.command) end if default_keys and #default_keys > 0 then local cmd = self.command if not settings.config.custom_keybindings then settings.config.custom_keybindings = {} settings.config.custom_keybindings[cmd] = {} elseif not settings.config.custom_keybindings[cmd] then settings.config.custom_keybindings[cmd] = {} end local shortcuts = "" for _, binding in ipairs(default_keys) do keymap.add({[binding] = cmd}) shortcuts = shortcuts .. binding .. "\n" table.insert(settings.config.custom_keybindings[cmd], binding) end local bindings_list = shortcuts:gsub("\n$", "") self.listbox:set_row(self.row_id, { style.text, cmd, ListBox.COLEND, style.dim, bindings_list }) else self.listbox:set_row(self.row_id, { style.text, self.command, ListBox.COLEND, style.dim, "none" }) end if settings.config.custom_keybindings and settings.config.custom_keybindings[self.command] then settings.config.custom_keybindings[self.command] = nil save_settings() end end ---Generate the list of all available commands and allow editing their keymaps. function Settings:load_keymap_settings() self.keybinds.scrollable = false local ordered = {} for name, _ in pairs(command.map) do table.insert(ordered, name) end table.sort(ordered) ---@type widget.textbox local textbox = TextBox(self.keybinds, "", "filter bindings...") ---@type widget.listbox local listbox = ListBox(self.keybinds) listbox.border.width = 0 listbox:add_column("Command") listbox:add_column("Bindings") for _, name in ipairs(ordered) do local keys = { keymap.get_binding(name) } local binding = "" if #keys == 1 then binding = keys[1] elseif #keys > 1 then binding = keys[1] for idx, key in ipairs(keys) do if idx ~= 1 then binding = binding .. "\n" .. key end end elseif #keys < 1 then binding = "none" end listbox:add_row({ style.text, name, ListBox.COLEND, style.dim, binding }, name) end function textbox:on_change(value) listbox:filter(value) end function listbox:on_mouse_pressed(button, x, y, clicks) listbox.super.on_mouse_pressed(self, button, x, y, clicks) local idx = listbox:get_selected() local data = listbox:get_row_data(idx) if clicks == 2 and not keymap_dialog:is_visible() then local bindings = { keymap.get_binding(data) } keymap_dialog:set_bindings(bindings) keymap_dialog.row_id = idx keymap_dialog.command = data keymap_dialog.listbox = self keymap_dialog:show() end end ---@param self widget function self.keybinds:update_positions() textbox:set_position(0, 0) textbox:set_size(self:get_width() - self.border.width * 2) listbox:set_position(0, textbox:get_bottom()) listbox:set_size(self:get_width() - self.border.width * 2, self:get_height() - textbox:get_height()) end end function Settings:setup_about() ---@type widget.label local title = Label(self.about, "Lite XL") title.font = "big_font" ---@type widget.label local version = Label(self.about, "version " .. VERSION) ---@type widget.label local description = Label( self.about, "A lightweight text editor written in Lua, adapted from lite." ) local function open_link(link) local platform_filelauncher if PLATFORM == "Windows" then platform_filelauncher = "start" elseif PLATFORM == "Mac OS X" then platform_filelauncher = "open" else platform_filelauncher = "xdg-open" end system.exec(platform_filelauncher .. " " .. link) end ---@type widget.button local button = Button(self.about, "Visit Website") button:set_tooltip("Open https://lite-xl.com/") function button:on_click() open_link("https://lite-xl.com/") end ---@type widget.listbox local contributors = ListBox(self.about) contributors.scrollable = true contributors:add_column("Contributors") contributors:add_column("") contributors:add_column("Website") function contributors:on_row_click(_, data) open_link(data) end local contributors_list = { { "Rxi", "Lite Founder", "https://github.com/rxi" }, { "Francesco Abbate", "Lite XL Founder", "https://github.com/franko" }, { "Adam Harrison", "Core", "https://github.com/adamharrison" }, { "Andrea Zanellato", "CI, Website", "https://github.com/redtide" }, { "Björn Buckwalter", "MacOS Support", "https://github.com/bjornbm" }, { "boppyt", "Contributor", "https://github.com/boppyt" }, { "Cukmekerb", "Contributor", "https://github.com/vincens2005" }, { "Daniel Rocha", "Contributor", "https://github.com/dannRocha" }, { "daubaris", "Contributor", "https://github.com/daubaris" }, { "Dheisom Gomes", "Contributor", "https://github.com/dheisom" }, { "Evgeny Petrovskiy", "Contributor", "https://github.com/eugenpt" }, { "Ferdinand Prantl", "Contributor", "https://github.com/prantlf" }, { "Jan", "Build System", "https://github.com/Jan200101" }, { "Janis-Leuenberger", "MacOS Support", "https://github.com/Janis-Leuenberger" }, { "Jefferson", "Contributor", "https://github.com/jgmdev" }, { "Jipok", "Contributor", "https://github.com/Jipok" }, { "Joshua Minor", "Contributor", "https://github.com/jminor" }, { "George Linkovsky", "Contributor", "https://github.com/Timofffee" }, { "Guldoman", "Core", "https://github.com/Guldoman" }, { "liquidev", "Contributor", "https://github.com/liquidev" }, { "Mat Mariani", "MacOS Support", "https://github.com/mathewmariani" }, { "Nightwing", "Contributor", "https://github.com/Nightwing13" }, { "Nils Kvist", "Contributor", "https://github.com/budRich" }, { "Not-a-web-Developer", "Contributor", "https://github.com/Not-a-web-Developer" }, { "Robert Štojs", "CI", "https://github.com/netrobert" }, { "sammyette", "Plugins", "https://github.com/TorchedSammy" }, { "Takase", "Core", "https://github.com/takase1121" }, { "xwii", "Contributor", "https://github.com/xcb-xwii" } } for _, c in ipairs(contributors_list) do contributors:add_row({ c[1], ListBox.COLEND, c[2], ListBox.COLEND, c[3] }, c[3]) end ---@param self widget function self.about:update_positions() local center = self:get_width() / 2 title:set_label("Lite XL") title:set_position( center - (title:get_width() / 2), style.padding.y ) version:set_position( center - (version:get_width() / 2), title:get_bottom() + (style.padding.y / 2) ) description:set_position( center - (description:get_width() / 2), version:get_bottom() + (style.padding.y / 2) ) button:set_position( center - (button:get_width() / 2), description:get_bottom() + style.padding.y ) contributors:set_position( style.padding.x, button:get_bottom() + style.padding.y ) contributors:set_size( self:get_width() - (style.padding.x * 2), self:get_height() - (button:get_bottom() + (style.padding.y * 2)) ) contributors:set_visible_rows() end end ---Reposition and resize core and plugin widgets. function Settings:update() if not Settings.super.update(self) then return end self.notebook:set_size(self.size.x, self.size.y) for _, section in ipairs({self.core_sections, self.plugin_sections}) do if section.parent:is_visible() then section:set_size( section.parent.size.x - (style.padding.x), section:get_real_height() ) section:set_position(style.padding.x / 2, 0) for _, pane in ipairs(section.panes) do local prev_child = nil for pos=#pane.container.childs, 1, -1 do local child = pane.container.childs[pos] local x, y = 10, (10 * SCALE) if prev_child then if (prev_child:is(Label) and not prev_child.desc) or (child:is(Label) and child.desc) then y = prev_child:get_bottom() + (10 * SCALE) else y = prev_child:get_bottom() + (30 * SCALE) end end if child:is(Line) then x = 0 elseif child:is(ItemsList) or child:is(FilePicker) or child:is(TextBox) then child:set_size(pane.container:get_width() - 20, child.size.y) end child:set_position(x, y) prev_child = child end end end end if self.keybinds:is_visible() then self.keybinds:update_positions() end if self.about:is_visible() then self.about:update_positions() end end -------------------------------------------------------------------------------- -- overwrite core run to inject previously saved settings -------------------------------------------------------------------------------- local core_run = core.run function core.run() store_default_keybindings() -- load plugins disabled by default and enabled by user if settings.config.enabled_plugins then for name, _ in pairs(settings.config.enabled_plugins) do if not config.plugins[name] then require("plugins." .. name) end end end -- append all settings defined in the plugins spec scan_plugins_spec() -- merge custom settings into config merge_settings() ---@type settings.ui settings.ui = Settings() -- apply user chosen color theme if settings.config.theme and settings.config.theme ~= "default" then core.try(function() core.reload_module("colors." .. settings.config.theme) end) end -- re-apply user settings core.load_user_directory() core.load_project_module() core_run() end -------------------------------------------------------------------------------- -- Disable plugins at startup, only works if this file is the first -- required on user module, or priority tag is obeyed by lite-xl. -------------------------------------------------------------------------------- -- load custom user settings that include list of disabled plugins load_settings() -- only disable non already loaded plugins if settings.config.disabled_plugins then for name, _ in pairs(settings.config.disabled_plugins) do if not package.loaded[name] then config.plugins[name] = false end end end -- properly apply skip_plugins_version before other plugins are loaded if settings.config.skip_plugins_version then config.skip_plugins_version = true else config.skip_plugins_version = false end -------------------------------------------------------------------------------- -- Add command and keymap to load settings view -------------------------------------------------------------------------------- command.add(nil, { ["ui:settings"] = function() settings.ui:show() local node = core.root_view:get_active_node_default() local found = false for _, view in ipairs(node.views) do if view == settings.ui then found = true node:set_active_view(view) break end end if not found then node:add_view(settings.ui) end end, }) keymap.add { ["ctrl+alt+p"] = "ui:settings" } -------------------------------------------------------------------------------- -- Overwrite toolbar preferences command to open the settings gui -------------------------------------------------------------------------------- if config.plugins.toolbarview ~= false then local ToolbarView = require "plugins.toolbarview" local toolbarview_on_mouse_moved = ToolbarView.on_mouse_moved function ToolbarView:on_mouse_moved(px, py, ...) toolbarview_on_mouse_moved(self, px, py, ...) if self.hovered_item and self.hovered_item.command == "core:open-user-module" then self.hovered_item.command = "ui:settings" end end end -------------------------------------------------------------------------------- -- Overwrite View:new to allow setting force scrollbar status globally -------------------------------------------------------------------------------- local view_new = View.new function View:new() view_new(self) local mode = config.force_scrollbar_status_mode or "global" local globally = mode == "global" if globally then --This is delayed to allow widgets to also apply it to child views/widgets core.add_thread(function() self.v_scrollbar:set_forced_status(config.force_scrollbar_status) self.h_scrollbar:set_forced_status(config.force_scrollbar_status) end) end end return settings;