Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

hs.chooser callback for Return/Enter pressed #782

Open
ventolin opened this issue Feb 6, 2016 · 11 comments
Open

hs.chooser callback for Return/Enter pressed #782

ventolin opened this issue Feb 6, 2016 · 11 comments

Comments

@ventolin
Copy link

ventolin commented Feb 6, 2016

I'm experimenting with implementing a very simple Notational Velocity replacement using hs.chooser. Basically, right now I am scanning a directory for text files and presenting those files (with contents as subtext) via hs.chooser, then opening the selected file in an editor. This works great for files that already exist. But one thing I'd like to do to mimic Notational Velocity is to create a new file using the search string as the file name (if it doesn't exist), and then edit that.

What would be helpful is to have some kind of callback when the user hits Return/Enter and no choices are selected. Or alternately, perhaps a second argument to the main callback that describes whether the chooser was dismissed or not, along with an hs.chooser setting to make the chooser call that callback when Return/Enter is pressed (then I could check if choice == nil and dismissed == false, for example).

I can sort of do what I want, now, by checking the current query() value in the dismiss callback if the choice is nil, but that's less than ideal because it requires me to hit escape to create the new file (which is confusing) and also no longer allows me to actually cancel the whole process.

@szymonkaliski
Copy link
Contributor

Maybe related: #748 If we decide to have separate hs.input you could use that to create notes, and hs.chooser to browse them. Otherwise if we implement this callback for "enter" with empty list, I'd love to have UI where I can have no rows, so we could use hs.chooser as pure input... Something to figure out.

@cmsj
Copy link
Member

cmsj commented Feb 8, 2016

I was trying to avoid complicating the conceptual aspect of chooser by not adding this, but I do think it's a valid use case

@ventolin
Copy link
Author

ventolin commented Feb 8, 2016

I had an idea that I might be able to do what I want by using queryChangedCallback and checking for Return/Enter press there. I haven't had time to try it out yet, though. If that works then perhaps this issue isn't that relevant.

@ventolin
Copy link
Author

ventolin commented Feb 8, 2016

So I was able to get something working the way I wanted. I'm using the queryChangedCallback to do some custom filtering, and if the filter results in an empty list (no files found with the given query, so the chooser list would be empty), I'm inserting an item into the chooser list to "Create new note," so that hitting enter will select that item, which acts as a command that uses the current query text as the new filename and creates the file. This is working well (I was afraid the filtering would be too slow, but it's fine so far), and I'm honestly not sure if my original feature request would make this much easier, because I'd still want to do the custom filtering.

So as far as I'm concerned, this issue could be closed and you could avoid complicating the chooser further. Thanks for considering it, though!

@heptal
Copy link
Contributor

heptal commented Feb 8, 2016

+1 on easily being able to take action on typed input/the proposal in #748

In my opinion I don't think this particular feature overcomplicates hs.chooser's domain.

@yoavg
Copy link

yoavg commented Jun 5, 2016

+1 on having a callback for pressing Enter on an empty list.

@ventolin
Copy link
Author

ventolin commented Jun 6, 2016

In case anyone is looking for a solution, this is basically what I've been using in a number of cases, and it's working very well for me. This is an example from a little scratchpad thing. The "Append" command shows how you can take input from what the user typed into the chooser and do something with it (in this case, it calls append() which appends the typed string to a text file), and the "Edit" command shows just a simple command selected from the chooser list, that ignores what was typed (in this case, it calls edit() which launches a text editor with the text file). The code for append() and edit() isn't included here because I wanted to keep the example simple.

local chooser = nil

local commands = {
  {
    ['text'] = 'Append...',
    ['subText'] = 'Append to Scratchpad',
    ['command'] = 'append',
  },
  {
    ['text'] = 'Edit',
    ['subText'] = 'Edit Scratchpad',
    ['command'] = 'edit',
  },
}

-- This resets the choices to the command table, and has the side effect
-- of resetting the highlighted choice as well.
local function resetChoices()
  chooser:rows(#commands)
  -- add commands
  local choices = {}
  for _, command in ipairs(commands) do
    choices[#choices+1] = command
  end
  chooser:choices(choices)
end

-- The chooser callback
local function choiceCallback(choice)
  if choice.command == 'append' then
    append(chooser:query())
  elseif choice.command == 'edit' then
    edit()
  end

  -- set the chooser back to the default state
  resetChoices()
  chooser:query('')
end


-- on hammerspoon startup:

chooser = hs.chooser.new(choiceCallback)
-- disable built-in search
chooser:queryChangedCallback(function() end)
-- populate the command list
resetChoices()

@yoavg
Copy link

yoavg commented Jun 6, 2016

This is fine but doesn't solve my use-case, which is "choose from list or add new". I want to be displayed a list of items, and then either select one of them to be returned, OR return an entirely new text (if what I entered does not match any of the items). This is very useful for implementing, e.g., tagging-like systems, or for file-selection dialogs (either edit an existing file or create a new one with a given name).

@ventolin
Copy link
Author

ventolin commented Jun 6, 2016

You can do that with just a bit more work. Here's a full example of a file selection dialog that adds a "Create" command at the end of the file list (if no files match the query string, it'll be the only thing listed so hitting enter just creates the file, but if there are other files that match the query, you can use the up arrow to get to the bottom of the list quickly and then hit enter to Create the file). This is basically my Notational Velocity replacement.

local m = {name = 'Notational Velocity'}

local TITLE_MATCH_WEIGHT = 5
local WIDTH = 60
local ROWS = 15

local DEFAULT_PATH = os.getenv('HOME') .. '/Documents/notes'
local lastApp = nil
local chooser = nil
local matchCache = {}
local rankCache = {}
local allChoices = nil
local currentPath = DEFAULT_PATH
local lastQueries = {}
local visible = false

-- COMMANDS
local commands = {
  {
    ['text'] = 'Create...',
    ['subText'] = 'Create a new note with the query as filename',
    ['command'] = 'create',
  }
}

-- filters can't be placed in the command table above because chooser choice
-- tables must be serializable.
local commandFilters = {
  ['create'] = function()
    return not ss.u.fileExists(currentPath .. '/' .. chooser:query())
  end,
}
--------------------

local function choiceSort(a, b)
  if a.rank == b.rank then return a.text < b.text end
  return a.rank > b.rank
end

local function getLastQuery()
  return lastQueries[currentPath] or ''
end

local function getAllChoices()
  local iterFn, dirObj = hs.fs.dir(currentPath)
  local item = iterFn(dirObj)
  local choices = {}

  while item do
    local filePath = currentPath .. '/' .. item
    if string.find(item, '^[^%.].-%.md') then
      local paragraph = {}
      local f = io.open(filePath)
      local line = f:read()
      while line ~= nil do
        if string.len(line) > 0 then
          paragraph[#paragraph+1] = line
        end
        line = f:read()
      end
      f:close()
      local contents = table.concat(paragraph, '\n')
      choices[#choices+1] = {
        ['text'] = item,
        ['additionalSearchText'] = contents,
        ['subText'] = paragraph[1],
        ['rank'] = 0,
        ['path'] = filePath,
      }
    end
    item = iterFn(dirObj)
  end

  table.sort(choices, choiceSort)
  return choices
end

local function refocus()
  if lastApp ~= nil then
    lastApp:activate()
    lastApp = nil
  end
end

local function launchEditor(path)
  if not ss.u.fileExists(path) then
    ss.u.fileCreate(path)
  end
  local task = hs.task.new('/usr/bin/open', nil, {'-t', path})
  task:start()
end

local function choiceCallback(choice)
  local query = chooser:query()
  local path

  refocus()
  visible = false
  lastQueries[currentPath] = query

  if choice.command == 'create' then
    path = currentPath .. '/' .. query
  else
    path = choice.path
  end

  if path ~= nil then
    if not string.find(path, '%.md$') then
      path = path .. '.md'
    end
    launchEditor(path)
  end
end

local function getRank(queries, choice)
  local rank = 0
  local choiceText = choice.text:lower()

  for _, q in ipairs(queries) do
    local qq = q:lower()
    local cacheKey = qq .. '|' .. choiceText

    if rankCache[cacheKey] == nil then
      local _, count1 = string.gsub(choiceText, qq, qq)
      local _, count2 = string.gsub(choice.additionalSearchText:lower(), qq, qq)
      -- title match is much more likely to be relevant
      rankCache[cacheKey] = count1 * TITLE_MATCH_WEIGHT + count2
    end

    -- If any single query term doesn't match then we don't match at all
    if rankCache[cacheKey] == 0 then return 0 end

    rank = rank + rankCache[cacheKey]
  end

  return rank
end

local function queryChangedCallback(query)
  if query == '' then
    chooser:choices(allChoices)
  else
    local choices = {}

    if matchCache[query] == nil then
      local queries = ss.u.strSplit(query, ' ')

      for _, aChoice in ipairs(allChoices) do
        aChoice.rank = getRank(queries, aChoice)
        if aChoice.rank > 0 then
          choices[#choices+1] = aChoice
        end
      end

      table.sort(choices, choiceSort)

      -- add commands last, after sorting
      for _, aCommand in ipairs(commands) do
        local filter = commandFilters[aCommand.command]
        if filter ~= nil and filter() then
          choices[#choices+1] = aCommand
        end
      end

      matchCache[query] = choices
    end

    chooser:choices(matchCache[query])
  end
end

function m.toggle(path)
  if chooser ~= nil then
    if visible then
      m.hide()
    else
      m.show(path)
    end
  end
end

function m.show(path)
  if chooser ~= nil then
    lastApp = hs.application.frontmostApplication()
    matchCache = {}
    rankCache = {}
    currentPath = path or DEFAULT_PATH
    chooser:query(getLastQuery())
    allChoices = getAllChoices()
    chooser:show()
    visible = true
  end
end

function m.hide()
  if chooser ~= nil then
    -- hide calls choiceCallback
    chooser:hide()
  end
end

function m.start()
  chooser = hs.chooser.new(choiceCallback)
  chooser:width(WIDTH)
  chooser:rows(ROWS)
  chooser:queryChangedCallback(queryChangedCallback)
  chooser:choices(allChoices)
end

function m.stop()
  if chooser then chooser:delete() end
  chooser = nil
  lastApp = nil
  matchCache = nil
  rankCache = nil
  allChoices = nil
  lastQueries = nil
  commands = nil
end

return m

@asmagill
Copy link
Member

asmagill commented Jun 7, 2016

@ventolin, looks interesting, and I'd love to check it out... what library/module does ss.u refer to? One of your own or a LuaRock or other? I can guess what strSplit, fileExists, and fileCreate should do, but if something is already coded...

@ventolin
Copy link
Author

ventolin commented Jun 7, 2016

@asmagill, that's just my generic utility module. Some of the functions come straight from lua examples online, so I don't remember which I wrote and which I didn't. But here are the relevant functions:

function utils.splitPath(file)
  local parent = file:match('(.+)/[^/]+$')
  if parent == nil then parent = '.' end
  local filename = file:match('/([^/]+)$')
  if filename == nil then filename = file end
  local ext = filename:match('%.([^.]+)$')
  return parent, filename, ext
end

-- Make a parent dir for a file, don't care if it exists already
function utils.makeParentDir(path)
  local parent, _, _ = utils.splitPath(path)
  local ok, err = hs.fs.mkdir(parent)
  if ok == nil then
    if err == "File exists" then
      ok = true
    end
  end
  return ok, err
end


function utils.fileCreate(path)
  if utils.makeParentDir(path) then
    io.open(path, 'w'):close()
  end
end

function utils.fileExists(name)
  local f = io.open(name,'r')
  if f ~= nil then
    io.close(f)
    return true
  else
    return false
  end
end

function utils.strSplit(str, pat)
  local t = {}  -- NOTE: use {n = 0} in Lua-5.0
  local fpat = "(.-)" .. pat
  local lastEnd = 1
  local s, e, cap = str:find(fpat, 1)
  while s do
    if s ~= 1 or cap ~= "" then
      table.insert(t,cap)
    end
    lastEnd = e+1
    s, e, cap = str:find(fpat, lastEnd)
  end
  if lastEnd <= #str then
    cap = str:sub(lastEnd)
    table.insert(t, cap)
  end
  return t
end

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

6 participants