--------------------------------------------------------------------------- --- Modified Prompt module. -- @author Julien Danjou <julien@danjou.info> -- @copyright 2008 Julien Danjou --------------------------------------------------------------------------- local akey = require("awful.key") local keygrabber = require("awful.keygrabber") local gobject = require("gears.object") local gdebug = require('gears.debug') local gtable = require("gears.table") local gcolor = require("gears.color") local gstring = require("gears.string") local gfs = require("gears.filesystem") local wibox = require("wibox") local beautiful = require("beautiful") local io = io local table = table local math = math local ipairs = ipairs local unpack = unpack or table.unpack -- luacheck: globals unpack (compatibility with Lua 5.1) local capi = { selection = selection } local prompt = { mt = {} } --- Private data local data = {} data.history = {} local function itera(inc,a, i) i = i + inc local v = a[i] if v then return i,v end end local function history_check_load(id, max) if id and id ~= "" and not data.history[id] then data.history[id] = { max = 50, table = {} } if max then data.history[id].max = max end local f = io.open(id, "r") if not f then return end -- Read history file for line in f:lines() do if gtable.hasitem(data.history[id].table, line) == nil then table.insert(data.history[id].table, line) if #data.history[id].table >= data.history[id].max then break end end end f:close() end 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 history_save(id) if data.history[id] then gfs.make_parent_directories(id) local f = io.open(id, "w") if not f then gdebug.print_warning("Failed to write the history to "..id) return end for i = 1, math.min(#data.history[id].table, data.history[id].max) do f:write(data.history[id].table[i] .. "\n") end f:close() end end local function history_items(id) if data.history[id] then return #data.history[id].table else return -1 end end local function history_add(id, command) if data.history[id] and command ~= "" then local index = gtable.hasitem(data.history[id].table, command) if index == nil then table.insert(data.history[id].table, command) -- Do not exceed our max_cmd if #data.history[id].table > data.history[id].max then table.remove(data.history[id].table, 1) end history_save(id) else -- Bump this command to the end of history table.remove(data.history[id].table, index) table.insert(data.history[id].table, command) history_save(id) end end end local function have_multibyte_char_at(text, position) return text:sub(position, position):wlen() == -1 end local function prompt_text_with_cursor(args) local char, spacer, text_start, text_end, ret local text = args.text or "" local _prompt = args.prompt or "" local underline = args.cursor_ul or "none" if args.select_all then if #text == 0 then char = " " else char = gstring.xml_escape(text) end spacer = " " text_start = "" text_end = "" elseif #text < args.cursor_pos then char = " " spacer = "" text_start = gstring.xml_escape(text) text_end = "" else local offset = 0 if have_multibyte_char_at(text, args.cursor_pos) then offset = 1 end char = gstring.xml_escape(text:sub(args.cursor_pos, args.cursor_pos + offset)) spacer = " " text_start = gstring.xml_escape(text:sub(1, args.cursor_pos - 1)) text_end = gstring.xml_escape(text:sub(args.cursor_pos + 1 + offset)) end local cursor_color = gcolor.ensure_pango_color(args.cursor_color) local text_color = gcolor.ensure_pango_color(args.text_color) if args.highlighter then text_start, text_end = args.highlighter(text_start, text_end) end ret = _prompt .. text_start .. "" .. char .. "" .. text_end .. spacer return ret end local function update(self) self.textbox:set_font(self.font) self.textbox:set_markup(prompt_text_with_cursor{ text = self.command, text_color = self.fg_cursor, cursor_color = self.bg_cursor, cursor_pos = self._private_cur_pos, cursor_ul = self.ul_cursor, select_all = self.select_all, prompt = self.prompt, highlighter = self.highlighter }) end local function exec(self, cb, command_to_history) self.textbox:set_markup("") history_add(self.history_path, command_to_history) keygrabber.stop(self._private.grabber) if cb then cb(self.command) end if self.done_callback then self.done_callback() end end function prompt:start() -- The cursor position if self.reset_on_stop == true or self._private_cur_pos == nil then self._private_cur_pos = (self.select_all and 1) or self.text:wlen() + 1 end if self.reset_on_stop == true then self.text = "" self.command = "" end self.textbox:set_font(self.font) self.textbox:set_markup(prompt_text_with_cursor{ text = self.reset_on_stop and self.text or self.command, text_color = self.fg_cursor, cursor_color = self.bg_cursor, cursor_pos = self._private_cur_pos, cursor_ul = self.ul_cursor, select_all = self.select_all, prompt = self.prompt, highlighter = self.highlighter}) self._private.search_term = nil history_check_load(self.history_path, self.history_max) local history_index = history_items(self.history_path) + 1 -- The completion element to use on completion request. local ncomp = 1 local command_before_comp local cur_pos_before_comp self._private.grabber = keygrabber.run(function(modifiers, key, event) -- Convert index array to hash table local mod = {} for _, v in ipairs(modifiers) do mod[v] = true end if event ~= "press" then if self.keyreleased_callback then self.keyreleased_callback(mod, key, self.command) end return end -- Call the user specified callback. If it returns true as -- the first result then return from the function. Treat the -- second and third results as a new command and new prompt -- to be set (if provided) if self.keypressed_callback then local user_catched, new_command, new_prompt = self.keypressed_callback(mod, key, self.command) if new_command or new_prompt then if new_command then self.command = new_command end if new_prompt then self.prompt = new_prompt end update(self) end if user_catched then if self.changed_callback then self.changed_callback(self.command) end return end end local filtered_modifiers = {} -- User defined cases if self.hooks[key] then -- Remove caps and num lock for _, m in ipairs(modifiers) do if not gtable.hasitem(akey.ignore_modifiers, m) then table.insert(filtered_modifiers, m) end end for _,v in ipairs(self.hooks[key]) do if #filtered_modifiers == #v[1] then local match = true for _,v2 in ipairs(v[1]) do match = match and mod[v2] end if match then local cb local ret, quit = v[3](self.command) local original_command = self.command -- Support both a "simple" and a "complex" way to -- control if the prompt should quit. quit = quit == nil and (ret ~= true) or (quit~=false) -- Allow the callback to change the command self.command = (ret ~= true) and ret or self.command -- Quit by default, but allow it to be disabled if ret and type(ret) ~= "boolean" then cb = self.exe_callback if not quit then self._private_cur_pos = ret:wlen() + 1 update(self) end elseif quit then -- No callback. cb = function() end end -- Execute the callback if cb then exec(self, cb, original_command) end return end end end end -- Get out cases if (mod.Control and (key == "c" or key == "g")) or (not mod.Control and key == "Escape") then self:stop() return false elseif (mod.Control and (key == "j" or key == "m")) -- or (not mod.Control and key == "Return") -- or (not mod.Control and key == "KP_Enter") then exec(self, self.exe_callback, self.command) -- We already unregistered ourselves so we don't want to return -- true, otherwise we may unregister someone else. return end -- Control cases if mod.Control then self.select_all = nil if key == "v" then local selection = capi.selection() if selection then -- Remove \n local n = selection:find("\n") if n then selection = selection:sub(1, n - 1) end self.command = self.command:sub(1, self._private_cur_pos - 1) .. selection .. self.command:sub(self._private_cur_pos) self._private_cur_pos = self._private_cur_pos + #selection end elseif key == "a" then self._private_cur_pos = 1 elseif key == "b" then if self._private_cur_pos > 1 then self._private_cur_pos = self._private_cur_pos - 1 if have_multibyte_char_at(self.command, self._private_cur_pos) then self._private_cur_pos = self._private_cur_pos - 1 end end elseif key == "d" then if self._private_cur_pos <= #self.command then self.command = self.command:sub(1, self._private_cur_pos - 1) .. self.command:sub(self._private_cur_pos + 1) end elseif key == "p" then if history_index > 1 then history_index = history_index - 1 self.command = data.history[self.history_path].table[history_index] self._private_cur_pos = #self.command + 2 end elseif key == "n" then if history_index < history_items(self.history_path) then history_index = history_index + 1 self.command = data.history[self.history_path].table[history_index] self._private_cur_pos = #self.command + 2 elseif history_index == history_items(self.history_path) then history_index = history_index + 1 self.command = "" self._private_cur_pos = 1 end elseif key == "e" then self._private_cur_pos = #self.command + 1 elseif key == "r" then self._private.search_term = self._private.search_term or self.command:sub(1, self._private_cur_pos - 1) for i,v in (function(a,i) return itera(-1,a,i) end), data.history[self.history_path].table, history_index do if v:find(self._private.search_term,1,true) ~= nil then self.command=v history_index=i self._private_cur_pos=#self.command+1 break end end elseif key == "s" then self._private.search_term = self._private.search_term or self.command:sub(1, self._private_cur_pos - 1) for i,v in (function(a,i) return itera(1,a,i) end), data.history[self.history_path].table, history_index do if v:find(self._private.search_term,1,true) ~= nil then self.command=v history_index=i self._private_cur_pos=#self.command+1 break end end elseif key == "f" then if self._private_cur_pos <= #self.command then if have_multibyte_char_at(self.command, self._private_cur_pos) then self._private_cur_pos = self._private_cur_pos + 2 else self._private_cur_pos = self._private_cur_pos + 1 end end elseif key == "h" then if self._private_cur_pos > 1 then local offset = 0 if have_multibyte_char_at(self.command, self._private_cur_pos - 1) then offset = 1 end self.command = self.command:sub(1, self._private_cur_pos - 2 - offset) .. self.command:sub(self._private_cur_pos) self._private_cur_pos = self._private_cur_pos - 1 - offset end elseif key == "k" then self.command = self.command:sub(1, self._private_cur_pos - 1) elseif key == "u" then self.command = self.command:sub(self._private_cur_pos, #self.command) self._private_cur_pos = 1 elseif key == "Prior" then self._private.search_term = self.command:sub(1, self._private_cur_pos - 1) or "" for i,v in (function(a,i) return itera(-1,a,i) end), data.history[self.history_path].table, history_index do if v:find(self._private.search_term,1,true) == 1 then self.command=v history_index=i break end end elseif key == "Next" then self._private.search_term = self.command:sub(1, self._private_cur_pos - 1) or "" for i,v in (function(a,i) return itera(1,a,i) end), data.history[self.history_path].table, history_index do if v:find(self._private.search_term,1,true) == 1 then self.command=v history_index=i break end end elseif key == "w" or key == "BackSpace" then local wstart = 1 local wend = 1 local cword_start_pos = 1 local cword_end_pos = 1 while wend < self._private_cur_pos do wend = self.command:find("[{[(,.:;_-+=@/ ]", wstart) if not wend then wend = #self.command + 1 end if self._private_cur_pos >= wstart and self._private_cur_pos <= wend + 1 then cword_start_pos = wstart cword_end_pos = self._private_cur_pos - 1 break end wstart = wend + 1 end self.command = self.command:sub(1, cword_start_pos - 1) .. self.command:sub(cword_end_pos + 1) self._private_cur_pos = cword_start_pos elseif key == "Delete" then -- delete from history only if: -- we are not dealing with a new command -- the user has not edited an existing entry if self.command == data.history[self.history_path].table[history_index] then table.remove(data.history[self.history_path].table, history_index) if history_index <= history_items(self.history_path) then self.command = data.history[self.history_path].table[history_index] self._private_cur_pos = #self.command + 2 elseif history_index > 1 then history_index = history_index - 1 self.command = data.history[self.history_path].table[history_index] self._private_cur_pos = #self.command + 2 else self.command = "" self._private_cur_pos = 1 end end end elseif mod.Mod1 or mod.Mod3 then if key == "b" then self._private_cur_pos = cword_start(self.command, self._private_cur_pos) elseif key == "f" then self._private_cur_pos = cword_end(self.command, self._private_cur_pos) elseif key == "d" then self.command = self.command:sub(1, self._private_cur_pos - 1) .. self.command:sub(cword_end(self.command, self._private_cur_pos)) elseif key == "BackSpace" then local wstart = cword_start(self.command, self._private_cur_pos) self.command = self.command:sub(1, wstart - 1) .. self.command:sub(self._private_cur_pos) self._private_cur_pos = wstart end else if self.completion_callback then if key == "Tab" or key == "ISO_Left_Tab" then if key == "ISO_Left_Tab" or mod.Shift then if ncomp == 1 then return end if ncomp == 2 then self.command = command_before_comp self.textbox:set_font(self.font) self.textbox:set_markup(prompt_text_with_cursor{ text = command_before_comp, text_color = self.fg_cursor, cursor_color = self.bg_cursor, cursor_pos = self._private_cur_pos, cursor_ul = self.ul_cursor, select_all = self.select_all, prompt = self.prompt }) self._private_cur_pos = cur_pos_before_comp ncomp = 1 return end ncomp = ncomp - 2 elseif ncomp == 1 then command_before_comp = self.command cur_pos_before_comp = self._private_cur_pos end local matches self.command, self._private_cur_pos, matches = self.completion_callback(command_before_comp, cur_pos_before_comp, ncomp) ncomp = ncomp + 1 key = "" -- execute if only one match found and autoexec flag set if matches and #matches == 1 and args.autoexec then exec(self, self.exe_callback) return end elseif key ~= "Shift_L" and key ~= "Shift_R" then ncomp = 1 end end -- Typin cases if mod.Shift and key == "Insert" then local selection = capi.selection() if selection then -- Remove \n local n = selection:find("\n") if n then selection = selection:sub(1, n - 1) end self.command = self.command:sub(1, self._private_cur_pos - 1) .. selection .. self.command:sub(self._private_cur_pos) self._private_cur_pos = self._private_cur_pos + #selection end elseif key == "Home" then self._private_cur_pos = 1 elseif key == "End" then self._private_cur_pos = #self.command + 1 elseif key == "BackSpace" then if self._private_cur_pos > 1 then local offset = 0 if have_multibyte_char_at(self.command, self._private_cur_pos - 1) then offset = 1 end self.command = self.command:sub(1, self._private_cur_pos - 2 - offset) .. self.command:sub(self._private_cur_pos) self._private_cur_pos = self._private_cur_pos - 1 - offset end elseif key == "Delete" then self.command = self.command:sub(1, self._private_cur_pos - 1) .. self.command:sub(self._private_cur_pos + 1) elseif key == "Left" then self._private_cur_pos = self._private_cur_pos - 1 elseif key == "Right" then self._private_cur_pos = self._private_cur_pos + 1 elseif key == "Prior" then if history_index > 1 then history_index = history_index - 1 self.command = data.history[self.history_path].table[history_index] self._private_cur_pos = #self.command + 2 end elseif key == "Next" then if history_index < history_items(self.history_path) then history_index = history_index + 1 self.command = data.history[self.history_path].table[history_index] self._private_cur_pos = #self.command + 2 elseif history_index == history_items(self.history_path) then history_index = history_index + 1 self.command = "" self._private_cur_pos = 1 end else -- wlen() is UTF-8 aware but #key is not, -- so check that we have one UTF-8 char but advance the cursor of # position if key:wlen() == 1 then if self.select_all then self.command = "" end self.command = self.command:sub(1, self._private_cur_pos - 1) .. key .. self.command:sub(self._private_cur_pos) self._private_cur_pos = self._private_cur_pos + #key end end if self._private_cur_pos < 1 then self._private_cur_pos = 1 elseif self._private_cur_pos > #self.command + 1 then self._private_cur_pos = #self.command + 1 end self.select_all = nil end update(self) if self.changed_callback then self.changed_callback(self.command) end end) end function prompt:stop() keygrabber.stop(self._private.grabber) history_save(self.history_path) if self.done_callback then self.done_callback() end return false end local function new(args) args = args or {} args.command = args.text or "" args.prompt = args.prompt or "" args.text = args.text or "" args.font = args.font or beautiful.prompt_font or beautiful.font args.bg_cursor = args.bg_cursor or beautiful.prompt_bg_cursor or beautiful.bg_focus or "white" args.fg_cursor = args.fg_cursor or beautiful.prompt_fg_cursor or beautiful.fg_focus or "black" args.ul_cursor = args.ul_cursor or nil args.reset_on_stop = args.reset_on_stop == nil and true or args.reset_on_stop args.select_all = args.select_all or nil args.highlighter = args.highlighter or nil args.hooks = args.hooks or {} args.keypressed_callback = args.keypressed_callback or nil args.changed_callback = args.changed_callback or nil args.done_callback = args.done_callback or nil args.history_max = args.history_max or nil args.history_path = args.history_path or nil args.completion_callback = args.completion_callback or nil args.exe_callback = args.exe_callback or nil args.textbox = args.textbox or wibox.widget.textbox() -- Build the hook map local hooks = {} for _,v in ipairs(args.hooks) do if #v == 3 then local _,key,callback = unpack(v) if type(callback) == "function" then hooks[key] = hooks[key] or {} hooks[key][#hooks[key]+1] = v else gdebug.print_warning("The hook's 3rd parameter has to be a function.") end else gdebug.print_warning("The hook has to have 3 parameters.") end end args.hooks = hooks local ret = gobject({}) ret._private = {} gtable.crush(ret, prompt) gtable.crush(ret, args) return ret end function prompt.mt:__call(...) return new(...) end return setmetatable(prompt, prompt.mt)