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
Comments
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. |
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 |
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. |
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! |
+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 |
+1 on having a callback for pressing Enter on an empty list. |
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() |
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). |
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
|
@ventolin, looks interesting, and I'd love to check it out... what library/module does |
@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
|
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.
The text was updated successfully, but these errors were encountered: