Newer
Older
dotfiles / .config / lite-xl / plugins / modal / init.lua
-- mod-version:3
local core = require "core"
local command = require "core.command"
local keymap = require "core.keymap"
local style = require "core.style"
local StatusView = require "core.statusview"
local CommandView = require "core.commandview"
local DocView = require "core.docview"
local RootView = require "core.rootview"
local common = require "core.common"
local config = require "core.config"

local KeyMapView = require "plugins.modal.keymapview"

local modal = {}

core.modal = modal

config.plugins.modal = common.merge({
  base_mode = nil,
  modes = {},
  on_key_callbacks = {},
  carets = {},
  helpers = {},
  keymaps = {},
  show_helpers = true,
  status_bar = {
    mode = true,
    strokes = true,
  }
}, config.plugins.modal)

modal.caret_style = { NORMAL = 0, BAR = 1, }

modal.align = {
  LEFT = 1, CENTER = 2, RIGHT = 3
}
modal.config = {
  helper = {
    show = true,
  },
  notification = {
    timeout = 0.5,
    fading = 0.1,
    show = true,
  }
}

modal.fns = {}

-- Helper styling
style["helper"] = {
  ["background"] = { 0, 0, 0, 200 },
  ["text"] = { 255, 255, 255, 220 },
  ["padding"] = 10,
  ["margin"] = 10,
  ["alignement"] = modal.align.LEFT,
}

function modal.go_to_mode(mode)
  local fn = function()
    modal.set_mode(mode)
  end
  local key = "modal:set-mode-" .. mode
  modal.fns[fn] = key
  return fn
end

local function dv()
  return core.active_view
end

-- local function doc()
--   return core.active_view.doc
-- end

modal.key_mapping = {
  ['escape'] = "<ESC>",
  ['left ctrl'] = 'C-',  -- mod
  ['right ctrl'] = 'C-', -- mod
  ['left alt'] = 'A-',   -- mod
  ['right alt'] = 'A-',  -- mod
  ['left shift'] = '',   -- mod
  ['right shift'] = '',  -- mod
  ['capslock'] = '',     -- mod
  ['left gui'] = 'M-',   -- mod
  ['right gui'] = 'M-',  -- mod
  ['space'] = '<space>',
  ['backspace'] = '<bkspc>',
  ['delete'] = '<del>',
  ['<'] = '\\<',
}

modal.reverse_key_mapping = {
  ['\\<'] = '<',
  ["<space>"] = ' ',
}

modal.key_modifiers = {
  ["left shift"] = false,
  ["right shift"] = false,
  ["capslock"] = false,
  ["left ctrl"] = false,
  ["right ctrl"] = false,
  ["left alt"] = false,
  ["right alt"] = false,
  ["left gui"] = false,
  ["right gui"] = false,
}

modal.shift_keys = {
  [";"] = ":",
  ["§"] = "±",
  ["`"] = "~",
  ["1"] = "!",
  ["2"] = "@",
  ["3"] = "#",
  ["4"] = "$",
  ["5"] = "%",
  ["6"] = "^",
  ["7"] = "&",
  ["8"] = "*",
  ["9"] = "(",
  ["0"] = ")",
  ["-"] = "_",
  ["="] = "+",
  ["["] = "{",
  ["]"] = "}",
  ["'"] = "\"",
  ["\\"] = "|",
  [","] = "<",
  ["."] = ">",
  ["/"] = "?",
}

function modal.get_mode()
  if dv() ~= nil and dv().modal ~= nil then
    return dv().modal.mode
  else
    return nil
  end
end

local function apply_mods(key)
  local shift = modal.key_modifiers['left shift'] or modal.key_modifiers['right shift']
  if shift and modal.shift_keys[key] then
    return modal.shift_keys[key]
  end
  local capslock = modal.key_modifiers['capslock']
  if not (shift == capslock) and #key == 1 then
    key = string.upper(key)
  end
  for label, state in pairs(modal.key_modifiers) do
    if state and modal.key_mapping[label] then
      key = modal.key_mapping[label] .. key
    end
  end
  return key
end

function modal.convert_stroke(key)
  if modal.key_modifiers[key] ~= nil then
    modal.key_modifiers[key] = not modal.key_modifiers[key]
    return ''
  end
  if modal.key_mapping[key] ~= nil then
    return modal.key_mapping[key]
  end

  if #key > 1 then
    return "<" .. key .. ">"
  end
  key = apply_mods(key)
  return key
end

local function statusbar_mode()
  local mode_str = modal.get_mode()
  if mode_str == nil or type(mode_str) ~= "string" then
    return { "NO MODE", }
  else
    return { mode_str, }
  end
end

local function statusbar_strokes()
  local view = dv()
  local str = ""
  if view ~= nil and view.modal ~= nil then
    str = view.modal.keyhelper
  end
  return { str }
end

function modal.set_status_bar()
  if config.plugins.modal.status_bar.mode == true and core.status_view:get_item("status:modal_mode") == nil then
    core.status_view:add_item({
      name = "status:modal_mode",
      alignment = StatusView.Item.LEFT,
      get_item = statusbar_mode,
      position = 1,
      tooltip = "mode",
      separator = core.status_view.separator2
    })
  elseif config.plugins.modal.status_bar.mode == false and core.status_view:get_item("status:modal_mode") ~= nil then
    core.status_view:remove_item("status:modal_mode")
  end

  if config.plugins.modal.status_bar.strokes == true and core.status_view:get_item("status:modal_strokes") == nil then
    core.status_view:add_item({
      name = "status:modal_strokes",
      alignment = StatusView.Item.LEFT,
      get_item = statusbar_strokes,
      position = 2,
      tooltip = "current keystrokes",
      separator = core.status_view.separator2
    })
  elseif config.plugins.modal.status_bar.strokes == false and core.status_view:get_item("status:modal_strokes") ~= nil then
    core.status_view:remove_item("status:modal_strokes")
  end
end

local function is_number(x)
  return x >= '0' and x <= '9'
end

function modal.split_command(cmd)
  local array   = {}
  local i       = 1
  local sub_cmd = ''
  local special = false
  while i <= #cmd do
    local c = string.sub(cmd, i, i)
    if special then
      sub_cmd = sub_cmd .. c
      if c == '>' then
        table.insert(array, sub_cmd)
        sub_cmd = ''
        special = false
      end
    elseif c == '<' then
      if #array >= 1 and array[#array] == '\\' then
        array[#array] = '\\<'
      else
        sub_cmd = c
        special = true
      end
    else
      table.insert(array, c)
    end
    i = i + 1
  end
  local x = ''
  for _, c in ipairs(array) do
    x = x .. c .. ' '
  end
  return array
end

local function match_command(seq, cmd, full)
  local split_seq = modal.split_command(seq)
  local split_cmd = modal.split_command(cmd)
  if (full and #split_seq == #split_cmd) or (not full and #split_seq <= #split_cmd) then
    for i = 1, #split_seq do
      if split_seq[i] ~= split_cmd[i] and split_cmd[i] ~= "<.>" then
        return false
      end
    end
    return true
  end
  return false
end

function modal.is_partial_command(seq)
  local view = dv()
  for key, _ in pairs(view.modal.keymap) do
    if match_command(seq, key, false) then
      return true
    end
  end
  return false
end

function modal.get_command(seq)
  local view = dv()
  if view.modal.keymap[seq] ~= nil then
    return view.modal.keymap[seq]
  end
  for key, cmd in pairs(view.modal.keymap) do
    if match_command(seq, key, true) then
      return cmd
    end
  end
  return nil
end

function modal.eval_cmd(cmd)
  if cmd == nil then
    return nil
  elseif type(cmd) == "table" then
    for _, scmd in pairs(cmd) do
      modal.eval_cmd(scmd)
    end
  elseif type(cmd) == "string" then
    if command.map[cmd] then
      command.perform(cmd)
    else
      local view = dv()
      if view then
        for i = 1, #cmd do
          local scmd = string.sub(cmd, i, i)
          modal.eval_cmd(view.modal.keymap[scmd])
        end
      end
    end
  elseif type(cmd) == "function" then
    cmd()
  end
end

function modal.redo_command()
  local view = dv()
  if view.modal.last_command ~= nil then
    local last_cmd = view.modal.last_command
    view.modal.current_command = last_cmd
    for _ = 1, last_cmd.num do
      modal.eval_cmd(last_cmd.cmd)
    end
  end
end

function modal.eval_helper(seq)
  local view = dv()
  if config.plugins.modal.show_helpers == true and view.modal.helpers ~= nil then
    view.modal.active_help = view.modal.helpers[seq]
  end
end

function modal.eval_current_strokes()
  local view = dv()
  if view.modal then
    view.modal.active_help = nil
    local N_repeat = ''
    local seq = ''
    view.modal.keyhelper = ''
    for i = 1, #view.modal.keystrokes do
      if #seq == 0 and is_number(view.modal.keystrokes[i]) then
        N_repeat = N_repeat .. view.modal.keystrokes[i]
      else
        seq = seq .. view.modal.keystrokes[i]
      end
      view.modal.keyhelper = view.modal.keyhelper .. view.modal.keystrokes[i]
    end
    local cmd = modal.get_command(seq)
    if cmd ~= nil then
      local N = 1
      view.modal.keystrokes = {}
      if #N_repeat > 0 then
        N = math.floor(tonumber(N_repeat) or error("String is not a number"))
      end
      view.modal.current_command = {
        num = N,
        n_repeat = N_repeat,
        cmd = cmd,
        seq = seq,
      }
      for _ = 1, view.modal.current_command.num do
        modal.eval_cmd(cmd)
      end
      view.modal.last_command = view.modal.current_command
    elseif not modal.is_partial_command(seq) then
      view.modal.keystrokes = {}
    else
      modal.eval_helper(seq)
    end
    return cmd ~= nil
  end
end

modal.on_key_released__orig = keymap.on_key_released

function keymap.on_key_released(k)
  if k ~= 'capslock' and modal.key_modifiers[k] ~= nil then
    modal.key_modifiers[k] = false
  end
  return modal.on_key_released__orig(k)
end

modal.on_key_pressed__orig = keymap.on_key_pressed

function modal.on_key_passthrough(k, ...)
  local key = modal.convert_stroke(k)
  if key and #key > 1 then
    dv().modal.keystrokes[#dv().modal.keystrokes + 1] = key
    if modal.eval_current_strokes() then
      return true
    end
  end
  return modal.on_key_pressed__orig(k, ...)
end

function modal.on_key_command_only(k, ...)
  if string.find(k, 'click') or string.find(k, 'wheel') then
    return modal.on_key_pressed__orig(k, ...)
  end
  local key = modal.convert_stroke(k)
  if key and #key > 0 then
    dv().modal.keystrokes[#dv().modal.keystrokes + 1] = key
    modal.eval_current_strokes()
  end
  return true
end

local function convert_action_to_string(action)
  if type(action) == "string" then
    return action
  elseif type(action) == "function" then
    if modal.fns[action] ~= nil then
      return modal.fns[action]
    end
    for k, v in pairs(modal) do
      if v == action then
        return k
      end
    end
    return "unknow:" .. tostring(action)
  else
    return "ERROR<" .. tostring(action) .. ">"
  end
end

local function show_help(mode, keys)
  if dv() ~= nil and dv().modal ~= nil then
    local helper = {}
    for strokes, action in pairs(keys) do
      local action_str = ""
      if type(action) == "table" then
        for i, v in pairs(action) do
          if i > 1 then
            action_str = action_str .. ", "
          end
          action_str = action_str .. convert_action_to_string(v)
        end
      else
        action_str = convert_action_to_string(action)
      end
      helper[#helper + 1] = { strokes, action_str }
    end
    local node = core.root_view:get_active_node_default()
    node:add_view(KeyMapView(mode, helper))
  end
end

local function modal_help()
  local mode = modal:get_mode()
  if mode ~= nil then
    local key_map = config.plugins.modal.keymaps[mode]
    if key_map ~= nil then
      show_help(mode,key_map)
    end
  end
end

modal.help = modal_help

local function get_keymap(mode_id)
  local map = config.plugins.modal.keymaps[mode_id]
  if map ~= nil then
    return map
  end
  return {}
end

local function get_helpers(mode_id)
  local helpers = config.plugins.modal.helpers[mode_id]
  if helpers ~= nil then
    return helpers
  end
  return {}
end

local function get_callback(mode_id)
  local fn = config.plugins.modal.on_key_callbacks[mode_id]
  if fn == nil then
    return modal.on_key_passthrough
  else
    return fn
  end
end

local caret_width__orig = style.caret_width
function modal.set_mode(mode_id)
  if mode_id ~= nil and mode_id ~= 0 then
    local view = dv()
    if view ~= nil then
      local c_style = config.plugins.modal.carets[mode_id]
      if c_style == nil then
        c_style = modal.caret_style.NORMAL
      end
      local width = caret_width__orig
      if c_style == modal.caret_style.BAR and core.active_view.get_font then
        width = core.active_view:get_font():get_width(' ')
      end
      style.caret_width = width
      if view.modal == nil then
        view.modal = {
          mode = mode_id,
          callback = get_callback(mode_id),
          helpers = get_helpers(mode_id),
          keymap = get_keymap(mode_id),
          caret = width,
          keystrokes = {},
          last_command = nil,
          current_command = nil,
          keyhelper = "",
          active_help = nil,
          notifications = {},
        }
      else
        view.modal.keyhelper = ""
        view.modal.mode = mode_id
        view.modal.callback = get_callback(mode_id)
        view.modal.helpers = get_helpers(mode_id)
        view.modal.keymap = get_keymap(mode_id)
        view.modal.caret = width
      end
      if view.modal.callback == nil then
        core.log("MODAL: no callback for mode %s", mode_id)
        view.modal.callback = modal.on_key_pressed__orig
      end
    end
    modal.notify(mode_id, false)
  end
end

function keymap.on_key_pressed(k, ...)
  local view = dv()
  if view:is(CommandView) then
    return modal.on_key_pressed__orig(k, ...)
  end

  if view.modal and view.modal.callback then
    return view.modal.callback(k, ...)
  else
    return modal.on_key_pressed__orig(k, ...)
  end
end

local DocView__update__orig = DocView.update
function DocView:update(...)
  local view = dv()
  if view:is(CommandView) then
    style.caret_width = caret_width__orig
  else
    if view.modal == nil or view.modal.mode == nil then
      modal.set_mode(config.plugins.modal.base_mode)
    end
    if view.modal and view.modal.caret ~= nil then
      style.caret_width = view.modal.caret
    else
      style.caret_width = caret_width__orig
    end
  end
  DocView__update__orig(self, ...)
end

local function pos(win_size, width, height)
  local y = win_size.y - style.helper.margin - style.helper.padding * 2 - height
  if style.helper.alignement == modal.align.RIGHT then
    return style.helper.margin, y
  elseif style.helper.alignement == modal.align.CENTER then
    return win_size.x / 2 - width / 2 - style.helper.padding, y
  else
    return win_size.x - style.helper.margin - style.helper.padding * 2 - width, y
  end
end

local function spaces(key_len, spacing)
  local res = ''
  for _ = 1, spacing - key_len do
    res = res .. ' '
  end
  return res
end


function modal.notify(msg, persistent)
  table.insert(dv().modal.notifications, {
    text = msg,
    mode = persistent,
    timestamp = system.get_time(),
  })
end

local function get_last_notification()
  local view = dv()
  if view.modal then
    local time = system.get_time()
    local to_remove = 0
    local result, fading = nil, 1.0
    for i = #view.modal.notifications, 1, -1 do
      local notification = view.modal.notifications[i]
      local t = time - notification.timestamp
      if notification.mode or t <= modal.config.notification.timeout then
        result = notification
        break
      elseif t < (modal.config.notification.timeout + modal.config.notification.fading) then
        result = notification
        fading = 1.0 - (t - modal.config.notification.timeout) / modal.config.notification.fading
        break
      else
        to_remove = to_remove + 1
      end
    end
    if to_remove > 0 then
      for _ = 1, to_remove do
        table.remove(view.modal.notifications, #view.modal.notifications)
      end
    end
    return result, fading
  else
    return nil, 1.0
  end
end

local function copy_color(color)
  return { color[1], color[2], color[3], color[4] }
end



local rvDraw = RootView.draw
function RootView:draw(...)
  rvDraw(self, ...)
  if dv().modal then
    if modal.config.notification.show then
      local notification, fading = get_last_notification()
      if notification ~= nil then
        local msg = notification.text
        local font = style.code_font
        local h = font:get_height() + style.helper.padding * 2
        local w = font:get_width(msg) + style.helper.padding * 2
        local x = dv().position.x + style.helper.margin
        local y = dv().position.y + dv().size.y - style.helper.margin - h
        local bg = copy_color(style.helper.background)
        local fg = copy_color(style.helper.text)
        bg[4] = math.floor(bg[4] * fading)
        fg[4] = math.floor(fg[4] * fading)
        core.redraw = true
        renderer.draw_rect(x, y, w, h, bg)
        x = x + style.helper.padding
        y = y + style.helper.padding
        renderer.draw_text(font, msg, x, y, fg)
      end
    end
    if dv().modal.active_help then
      local font = style.code_font
      local lines = {}
      local box_h = 0
      local box_w = 0
      local spacing = 8
      for _, kv in ipairs(dv().modal.active_help) do
        if #kv[1] >= spacing - 1 then
          spacing = #kv[1] + 1
        end
      end
      for _, kv in ipairs(dv().modal.active_help) do
        local line = kv[1] .. spaces(#kv[1], spacing) .. kv[2]
        local line_w = font:get_width(line)
        lines[#lines + 1] = line
        box_h = box_h + font:get_height()

        if line_w > box_w then
          box_w = line_w
        end
      end
      local x, y = pos(dv().size, box_w, box_h)
      x = dv().position.x + x
      y = dv().position.y + y
      local w = box_w + style.helper.padding * 2
      local h = box_h + style.helper.padding * 2
      core.redraw = true
      local bg = style.helper.background
      local fg = style.helper.text
      renderer.draw_rect(x, y, w, h, bg)
      x = x + style.helper.padding
      y = y + style.helper.padding
      for _, line in ipairs(lines) do
        renderer.draw_text(font, line, x, y, fg)
        y = y + font:get_height()
      end
    end
  end
end

command.add(nil, {
  ["modal:help"] = modal_help,
  ["modal:redo"] = modal.redo_command,
})

modal.set_status_bar()

return modal