------------------------------------------- -- @author https://github.com/Kasper24 -- @copyright 2021-2022 Kasper24 ------------------------------------------- local lgi = require('lgi') local Gtk = lgi.require('Gtk', '3.0') local Gdk = lgi.require('Gdk', '3.0') local Pango = lgi.Pango local awful = require("awful") local gtable = require("gears.table") local gtimer = require("gears.timer") local gcolor = require("gears.color") local wibox = require("wibox") local beautiful = require("beautiful") local abs = math.abs local ipairs = ipairs local string = string local capi = { awesome = awesome, root = root, tag = tag, client = client, mouse = mouse, mousegrabber = mousegrabber } local text_input = { mt = {} } local properties = { "unfocus_keys", "unfocus_on_root_clicked", "unfocus_on_client_clicked", "unfocus_on_client_focus", "unfocus_on_mouse_leave", "unfocus_on_tag_change", "focus_on_subject_mouse_enter", "unfocus_on_subject_mouse_leave", "click_timeout", "reset_on_unfocus", "text_color", "placeholder", "initial", "pattern", "obscure", "cursor_blink", "cursor_blink_rate","cursor_size", "cursor_bg", "selection_bg" } text_input.patterns = { numbers = "[%d.]*", numbers_one_decimal = "%d*%.?%d*", round_numbers = "[0-9]*", email = "%S+@%S+%.%S+", time = "%d%d?:%d%d:%d%d?|%d%d?:%d%d", date = "%d%d%d%d%-%d%d%-%d%d|%d%d?/%d%d?/%d%d%d%d|%d%d?%.%d%d?%.%d%d%d%d", phone = "%+?%d[%d%-%s]+%d", url = "https?://[%w-_%.]+%.[%w]+/?[%w-_%.?=%+]*", email = "[%w._%-%+]+@[%w._%-]+%.%w+", alphanumeric = "%w+", letters = "[a-zA-Z]+" } local function build_properties(prototype, prop_names) for _, prop in ipairs(prop_names) do if not prototype["set_" .. prop] then prototype["set_" .. prop] = function(self, value) if self._private[prop] ~= value then self._private[prop] = value self:emit_signal("widget::redraw_needed") self:emit_signal("property::" .. prop, value) end return self end end if not prototype["get_" .. prop] then prototype["get_" .. prop] = function(self) return self._private[prop] end end end end local function has_value(tab, val) for _, value in ipairs(tab) do if val:lower():find(value:lower(), 1, true) then return true end end return false end local function is_word_char(c) if string.find(c, "[{[(,.:;_-+=@/ ]") then return false else return true end end local function cword_start(s, pos) local i = pos if i > 1 then i = i - 1 end while i >= 1 and not is_word_char(s:sub(i, i)) do i = i - 1 end while i >= 1 and is_word_char(s:sub(i, i)) do i = i - 1 end if i <= #s then i = i + 1 end return i end local function cword_end(s, pos) local i = pos while i <= #s and not is_word_char(s:sub(i, i)) do i = i + 1 end while i <= #s and is_word_char(s:sub(i, i)) do i = i + 1 end return i end local function set_mouse_cursor(cursor) capi.root.cursor(cursor) local wibox = capi.mouse.current_wibox if wibox then wibox.cursor = cursor end end local function single_double_triple_tap(self, args) local wp = self._private if wp.click_timer == nil then wp.click_timer = gtimer { timeout = wp.click_timeout, autostart = false, call_now = false, single_shot = true, callback = function() wp.click_count = 0 end } end wp.click_timer:again() wp.click_count = wp.click_count + 1 if wp.click_count == 1 then args.on_single_click() elseif wp.click_count == 2 then args.on_double_click() elseif wp.click_count == 3 then args.on_triple_click() wp.click_count = 0 end end local function run_keygrabber(self) local wp = self._private wp.keygrabber = awful.keygrabber.run(function(modifiers, key, event) if event ~= "press" then self:emit_signal("key::release", modifiers, key, event) return end self:emit_signal("key::press", modifiers, key, event) -- Convert index array to hash table local mod = {} for _, v in ipairs(modifiers) do mod[v] = true end if mod.Control then if key == "a" then self:select_all() elseif key == "c" then self:copy() elseif key == "v" then self:paste() elseif key == "b" or key == "Left" then self:set_cursor_index_to_word_start() elseif key == "f" or key == "Right" then self:set_cursor_index_to_word_end() elseif key == "d" then self:delete_next_word() elseif key == "BackSpace" then self:delete_previous_word() end elseif mod.Shift and key:wlen() ~= 1 then if key =="Left" then self:decremeant_selection_end_index() elseif key == "Right" then self:increamant_selection_end_index() end else if has_value(wp.unfocus_keys, key) then self:unfocus() end if mod.Shift and key == "Insert" then self:paste() elseif key == "Home" then self:set_cursor_index(0) elseif key == "End" then self:set_cursor_index_to_end() elseif key == "BackSpace" then self:delete_text() elseif key == "Delete" then self:delete_text_after_cursor() elseif key == "Left" then self:decremeant_cursor_index() elseif key == "Right" then self:increamant_cursor_index() elseif key:wlen() == 1 then self:update_text(key) end end end) end function text_input:set_widget_template(widget_template) local wp = self._private wp.text_widget = widget_template:get_children_by_id("text_role")[1] wp.text_widget.forced_width = math.huge local text_draw = wp.text_widget.draw if self:get_initial() then self:replace_text(self:get_initial()) end local placeholder_widget = widget_template:get_children_by_id("placeholder_role") if placeholder_widget then placeholder_widget = placeholder_widget[1] end function wp.text_widget:draw(context, cr, width, height) local _, logical_rect = self._private.layout:get_pixel_extents() -- Selection bg cr:set_source(gcolor.change_opacity(wp.selection_bg, wp.selection_opacity)) cr:rectangle( wp.selection_start_x, logical_rect.y - 3, wp.selection_end_x - wp.selection_start_x, logical_rect.y + logical_rect.height + 6 ) cr:fill() -- Cursor cr:set_source(gcolor.change_opacity(wp.cursor_bg, wp.cursor_opacity)) cr:set_line_width(wp.cursor_width) cr:move_to(wp.cursor_x, logical_rect.y - 3) cr:line_to(wp.cursor_x, logical_rect.y + logical_rect.height + 6) cr:stroke() cr:set_source(gcolor(wp.text_color)) text_draw(self, context, cr, width, height) if self:get_text() == "" and placeholder_widget then placeholder_widget.visible = true elseif placeholder_widget then placeholder_widget.visible = false end end local function on_drag(_, lx, ly) lx, ly = wp.hierarchy:get_matrix_from_device():transform_point(lx, ly) if abs(lx - wp.press_pos.lx) > 2 or abs(ly - wp.press_pos.ly) > 2 then if self:get_mode() ~= "overwrite" then self:set_selection_start_index_from_x_y(wp.press_pos.lx, wp.press_pos.ly) end self:set_selection_end_index_from_x_y(lx, ly) end end wp.text_widget:connect_signal("button::press", function(_, lx, ly, button, mods, find_widgets_result) if button == 1 then single_double_triple_tap(self, { on_single_click = function() self:focus() self:set_cursor_index_from_x_y(lx, ly) end, on_double_click = function() self:set_selection_to_word() end, on_triple_click = function() self:select_all() end }) wp.press_pos = { lx = lx, ly = ly } wp.hierarchy = find_widgets_result.hierarchy find_widgets_result.drawable:connect_signal("mouse::move", on_drag) end end) wp.text_widget:connect_signal("button::release", function(_, lx, ly, button, mods, find_widgets_result) if button == 1 then find_widgets_result.drawable:disconnect_signal("mouse::move", on_drag) end end) wp.text_widget:connect_signal("mouse::enter", function() set_mouse_cursor("xterm") end) wp.text_widget:connect_signal("mouse::leave", function(_, find_widgets_result) if self:get_focused() == false then set_mouse_cursor("left_ptr") end find_widgets_result.drawable:disconnect_signal("mouse::move", on_drag) if wp.unfocus_on_mouse_leave then self:unfocus() end end) self:set_widget(widget_template) end function text_input:get_mode() return self._private.mode end function text_input:set_focused(focused) if focused == true then self:focus() else self:unfocus() end end function text_input:set_pattern(pattern) self._private.pattern = text_input.patterns[pattern] end function text_input:set_obscure(obscure) self._private.obscure = obscure self:set_text(self:get_text()) end function text_input:toggle_obscure() self:set_obscure(not self._private.obscure) end function text_input:set_initial(initial) self._private.initial = initial self:replace_text(initial) end function text_input:update_text(text) if self:get_mode() == "insert" then self:insert_text(text) else self:overwrite_text(text) end end function text_input:set_text(text) self._private.text_buffer = text if self:get_obscure() then self:get_text_widget():set_text(string.rep("*", #text)) else self:get_text_widget():set_text(text) end end function text_input:replace_text(text) self._private.text_buffer = text if self:get_obscure() then self:set_text(string.rep("*", #text)) else self:set_text(text) end if self:get_text() == "" then self:set_cursor_index(0) else self:set_cursor_index(#text) end end function text_input:insert_text(text) local wp = self._private local old_text = self:get_text() local cursor_index = self:get_cursor_index() local left_text = old_text:sub(1, cursor_index) .. text local right_text = old_text:sub(cursor_index + 1) local new_text = left_text .. right_text if wp.pattern then new_text = new_text:match(wp.pattern) if new_text then self:set_text(new_text) self:set_cursor_index(self:get_cursor_index() + #text) self:emit_signal("property::text", self:get_text()) end else self:set_text(new_text) self:set_cursor_index(self:get_cursor_index() + #text) self:emit_signal("property::text", self:get_text()) end end function text_input:overwrite_text(text) local wp = self._private local start_pos = wp.selection_start local end_pos = wp.selection_end if start_pos > end_pos then start_pos, end_pos = end_pos, start_pos end local old_text = self:get_text() local left_text = old_text:sub(1, start_pos) local right_text = old_text:sub(end_pos + 1) local new_text = left_text .. text .. right_text if wp.pattern then new_text = new_text:match(wp.pattern) if new_text then self:set_text(new_text) self:set_cursor_index(#left_text + 1) self:emit_signal("property::text", self:get_text()) end else self:set_text(new_text) self:set_cursor_index(#left_text + 1) self:emit_signal("property::text", self:get_text()) end end function text_input:copy() local wp = self._private if self:get_mode() == "overwrite" then local text = self:get_text() local start_pos = self._private.selection_start local end_pos = self._private.selection_end if start_pos > end_pos then start_pos, end_pos = end_pos + 1, start_pos end text = text:sub(start_pos, end_pos) wp.clipboard:set_text(text, -1) end end function text_input:paste() local wp = self._private wp.clipboard:request_text(function(clipboard, text) if text then self:update_text(text) end end) end function text_input:delete_next_word() local old_text = self:get_text() local cursor_index = self:get_cursor_index() local left_text = old_text:sub(1, cursor_index) local right_text = old_text:sub(cword_end(old_text, cursor_index + 1)) self:set_text(left_text .. right_text) self:emit_signal("property::text", self:get_text()) end function text_input:delete_previous_word() local old_text = self:get_text() local cursor_index = self:get_cursor_index() local wstart = cword_start(old_text, cursor_index + 1) - 1 local left_text = old_text:sub(1, wstart) local right_text = old_text:sub(cursor_index + 1) self:set_text(left_text .. right_text) self:set_cursor_index(wstart) self:emit_signal("property::text", self:get_text()) end function text_input:delete_text() if self:get_mode() == "insert" then self:delete_text_before_cursor() else self:overwrite_text("") end end function text_input:delete_text_before_cursor() local cursor_index = self:get_cursor_index() if cursor_index > 0 then local old_text = self:get_text() local left_text = old_text:sub(1, cursor_index - 1) local right_text = old_text:sub(cursor_index + 1) self:set_text(left_text .. right_text) self:set_cursor_index(cursor_index - 1) self:emit_signal("property::text", self:get_text()) end end function text_input:delete_text_after_cursor() local cursor_index = self:get_cursor_index() if cursor_index < #self:get_text() then local old_text = self:get_text() local left_text = old_text:sub(1, cursor_index) local right_text = old_text:sub(cursor_index + 2) self:set_text(left_text .. right_text) self:emit_signal("property::text", self:get_text()) end end function text_input:get_text() return self._private.text_buffer or "" end function text_input:get_text_widget() return self._private.text_widget end function text_input:show_selection() self._private.selection_opacity = 1 self:get_text_widget():emit_signal("widget::redraw_needed") end function text_input:hide_selection() self._private.selection_opacity = 0 self:get_text_widget():emit_signal("widget::redraw_needed") end function text_input:select_all() if self:get_text() == "" then return end self:set_selection_start_index(0) self:set_selection_end_index(#self:get_text()) end function text_input:set_selection_to_word() if self:get_text() == "" then return end local word_start_index = cword_start(self:get_text(), self:get_cursor_index() + 1) - 1 local word_end_index = cword_end(self:get_text(), self:get_cursor_index() + 1) - 1 self:set_selection_start_index(word_start_index) self:set_selection_end_index(word_end_index) end function text_input:set_selection_start_index(index) if #self:get_text() == 0 then return end index = math.max(math.min(index, #self:get_text()), 0) local layout = self:get_text_widget()._private.layout local strong_pos, weak_pos = layout:get_caret_pos(index) if strong_pos then self._private.selection_start = index self._private.selection_start_x = strong_pos.x / Pango.SCALE self._private.selection_start_y = strong_pos.y / Pango.SCALE self:get_text_widget():emit_signal("widget::redraw_needed") end end function text_input:set_selection_end_index(index) if #self:get_text() == 0 then return end index = math.max(math.min(index, #self:get_text()), 0) local layout = self:get_text_widget()._private.layout local strong_pos, weak_pos = layout:get_caret_pos(index) if strong_pos then if self:get_mode() ~= "overwrite" and index ~= self._private.selection_start then self._private.mode = "overwrite" self:show_selection() self:hide_cursor() end self._private.selection_end = index self._private.selection_end_x = strong_pos.x / Pango.SCALE self._private.selection_end_y = strong_pos.y / Pango.SCALE self:get_text_widget():emit_signal("widget::redraw_needed") end end function text_input:increamant_selection_end_index() if self:get_mode() == "insert" then self:set_selection_start_index(self:get_cursor_index()) self:set_selection_end_index(self:get_cursor_index() + 1) else self:set_selection_end_index(self._private.selection_end + 1) end end function text_input:decremeant_selection_end_index() if self:get_mode() == "insert" then self:set_selection_start_index(self:get_cursor_index()) self:set_selection_end_index(self:get_cursor_index() - 1) else self:set_selection_end_index(self._private.selection_end - 1) end end function text_input:set_selection_start_index_from_x_y(x, y) local layout = self:get_text_widget()._private.layout local index, trailing = layout:xy_to_index(x * Pango.SCALE, y * Pango.SCALE) if index then self:set_selection_start_index(index) else local pixel_rect, logical_rect = self:get_text_widget()._private.layout:get_pixel_extents() if x < logical_rect.x + logical_rect.width then self:set_selection_start_index(0) else self:set_selection_start_index(#self:get_text()) end end end function text_input:set_selection_end_index_from_x_y(x, y) local layout = self:get_text_widget()._private.layout local index, trailing = layout:xy_to_index(x * Pango.SCALE, y * Pango.SCALE) if index then self:set_selection_end_index(index + trailing) else local pixel_rect, logical_rect = self:get_text_widget()._private.layout:get_pixel_extents() if x < logical_rect.x + logical_rect.width then self:set_selection_end_index(0) else self:set_selection_end_index(#self:get_text()) end end end function text_input:show_cursor() self._private.cursor_opacity = 1 self:get_text_widget():emit_signal("widget::redraw_needed") end function text_input:hide_cursor() self._private.cursor_opacity = 0 self:get_text_widget():emit_signal("widget::redraw_needed") end function text_input:set_cursor_index(index) index = math.max(math.min(index, #self:get_text()), 0) local layout = self:get_text_widget()._private.layout local strong_pos, weak_pos = layout:get_cursor_pos(index) if strong_pos then if strong_pos == self._private.cursor_index and self._private.mode == "insert" then return end if self:get_focused() and self:get_mode() ~= "insert" then self:show_cursor() end self._private.cursor_index = index self._private.mode = "insert" self._private.cursor_x = strong_pos.x / Pango.SCALE self._private.cursor_y = strong_pos.y / Pango.SCALE self:hide_selection() self:get_text_widget():emit_signal("widget::redraw_needed") end end function text_input:set_cursor_index_from_x_y(x, y) local layout = self:get_text_widget()._private.layout local index, trailing = layout:xy_to_index(x * Pango.SCALE, y * Pango.SCALE) if index then self:set_cursor_index(index) else local pixel_rect, logical_rect = self:get_text_widget()._private.layout:get_pixel_extents() if x < logical_rect.x + logical_rect.width then self:set_cursor_index(0) else self:set_cursor_index(#self:get_text()) end end end function text_input:set_cursor_index_to_word_start() self:set_cursor_index(cword_start(self:get_text(), self:get_cursor_index() + 1) - 1) end function text_input:set_cursor_index_to_word_end() self:set_cursor_index(cword_end(self:get_text(), self:get_cursor_index() + 1) - 1) end function text_input:set_cursor_index_to_end() self:set_cursor_index(#self:get_text()) end function text_input:increamant_cursor_index() if self:get_mode() == "insert" then self:set_cursor_index(self:get_cursor_index() + 1) else local start_pos = self._private.selection_start local end_pos = self._private.selection_end if start_pos > end_pos then start_pos, end_pos = end_pos, start_pos end self:set_cursor_index(end_pos) end end function text_input:decremeant_cursor_index() if self:get_mode() == "insert" then self:set_cursor_index(self:get_cursor_index() - 1) else local start_pos = self._private.selection_start local end_pos = self._private.selection_end if start_pos > end_pos then start_pos, end_pos = end_pos, start_pos end self:set_cursor_index(start_pos) end end function text_input:get_cursor_index() return self._private.cursor_index end function text_input:set_focus_on_subject_mouse_enter(subject) subject:connect_signal("mouse::enter", function() self:focus() end) end function text_input:set_unfocus_on_subject_mouse_leave(subject) subject:connect_signal("mouse::leave", function() self:unfocus() end) end function text_input:get_focused() return self._private.focused end function text_input:focus() local wp = self._private if self:get_focused() == true then return end -- Do it first, so the cursor won't change back when unfocus was called on the focused text input capi.awesome.emit_signal("text_input::focus", self) set_mouse_cursor("xterm") if self:get_mode() == "insert" then self:show_cursor() end run_keygrabber(self) if wp.cursor_blink then if wp.cursor_blink_timer == nil then wp.cursor_blink_timer = gtimer { timeout = wp.cursor_blink_rate, autostart = false, call_now = false, single_shot = false, callback = function() if self._private.cursor_opacity == 1 then self:hide_cursor() elseif self:get_mode() == "insert" then self:show_cursor() end end } end wp.cursor_blink_timer:start() end wp.focused = true self:emit_signal("focus") end function text_input:unfocus(context) local wp = self._private if self:get_focused() == false then return end set_mouse_cursor("left_ptr") self:hide_cursor() wp.cursor_blink_timer:stop() self:hide_selection() if self.reset_on_unfocus == true then self:replace_text("") end awful.keygrabber.stop(wp.keygrabber) wp.focused = false self:emit_signal("unfocus", context or "normal", self:get_text()) end function text_input:toggle() local wp = self._private if self:get_focused() == false then self:focus() else self:unfocus() end end local function new() local widget = wibox.container.background() gtable.crush(widget, text_input, true) local wp = widget._private wp.focused = false wp.clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) wp.cursor_index = 0 wp.mode = "insert" wp.click_count = 0 wp.cursor_x = 0 wp.cursor_y = 0 wp.cursor_opacity = 0 wp.selection_start_x = 0 wp.selection_end_x = 0 wp.selection_start_y = 0 wp.selection_end_y = 0 wp.selection_opacity = 0 wp.click_timeout = 0.2 wp.unfocus_keys = { } wp.unfocus_on_root_clicked = false wp.unfocus_on_client_clicked = false wp.unfocus_on_mouse_leave = false wp.unfocus_on_tag_change = false wp.unfocus_on_other_text_input_focus = false wp.unfocus_on_client_focus = false wp.focus_on_subject_mouse_enter = nil wp.unfocus_on_subject_mouse_leave = nil wp.reset_on_unfocus = true wp.pattern = nil wp.obscure = false wp.placeholder = "" wp.text_color = beautiful.fg_normal wp.text = "" wp.cursor_width = 2 wp.cursor_bg = beautiful.fg_normal wp.cursor_blink = true wp.cursor_blink_rate = 0.6 wp.selection_bg = beautiful.bg_normal widget:set_widget_template(wibox.widget { layout = wibox.layout.stack, { widget = wibox.widget.textbox, id = "placeholder_role", text = wp.placeholder }, { widget = wibox.widget.textbox, id = "text_role", text = wp.text } }) capi.tag.connect_signal("property::selected", function() if wp.unfocus_on_tag_change then widget:unfocus() end end) capi.awesome.connect_signal("text_input::focus", function(text_input) if wp.unfocus_on_other_text_input_focus and text_input ~= widget then widget:unfocus() end end) capi.client.connect_signal("focus", function() if wp.unfocus_on_client_focus then widget:unfocus() end end) awful.mouse.append_global_mousebindings({ awful.button({"Any"}, 1, function() if wp.unfocus_on_root_clicked then widget:unfocus() end end), awful.button({"Any"}, 3, function() if wp.unfocus_on_root_clicked then widget:unfocus() end end) }) capi.client.connect_signal("button::press", function() if wp.unfocus_on_client_clicked then widget:unfocus() end end) return widget end function text_input.mt:__call(...) return new(...) end build_properties(text_input, properties) return setmetatable(text_input, text_input.mt)