-
Notifications
You must be signed in to change notification settings - Fork 33
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
Investigate cursor blocks #63
Comments
@yoricfr This is an example of a built https://github.com/asmagill/hs._asm.axuielement |
This is how to get a focused UI element, which might have a |
@dbalatero Thanks for the links. I feel like trying to build the simplest Mac application with a |
That sounds great, thanks for your energy on this! Yeah, the order of operations seems like:
OwnershipOne big thing to figure out: It's one thing for an application to manage its own UITextView caret style. What we want is to reach into other applications and mess around with its caret style from the Hammerspoon process. Is this considered OK by the APIs? Or is it securely isolated? |
Here's some dev notes on it, which is great. It looks like it's patching NSTextView though to achieve this. This Stackoverflow post seems to indicate that you can do it per-application, but not system wide: https://superuser.com/questions/429464/change-the-width-and-color-of-mac-os-x-text-caret-cursor |
@yoricfr I did an experiment, and was able to draw a box over the next character. Downsides:
To try it, bind this code to a hot key, put your cursor somewhere, and fire the hotkey. You should see a box. local ax = require("hs.axuielement")
currentAxElement = function()
local systemElement = ax.systemWideElement()
return systemElement:attributeValue("AXFocusedUIElement")
end
hs.hotkey.bind(super, 'e', function()
local currentElement = currentAxElement()
-- Get the current selection
local range = currentElement:attributeValue("AXSelectedTextRange")
-- Get the range for the next character after the blinking cursor
local caretRange = {
location = range.location,
length = 1,
}
-- get the { h, w, x, y } bounding box for the next character's range
local bounds = currentElement:parameterizedAttributeValue("AXBoundsForRange", caretRange)
-- draw a black rectangle in the bounding box with 20% opacity
local canvas = hs.canvas.new(bounds)
canvas:insertElement(
{
type = 'rectangle',
action = 'fill',
fillColor = { red = 0, green = 0, blue = 0, alpha = 0.2 },
frame = { x = "0%", y = "0%", h = "100%", w = "100%", },
withShadow = false
},
1
)
canvas:level('overlay')
canvas:show()
end) |
I did another pass at this. This time, we redraw the cursor at a 60fps rate, so it captures any movement. intervalTimer = nil
hs.hotkey.bind(super, 'e', function()
-- draw a black rectangle in the bounding box with 20% opacity
local canvas = hs.canvas.new({ x = 0, y = 0, h = 1, w = 1 })
canvas:level('overlay')
canvas:insertElement(
{
type = 'rectangle',
action = 'fill',
fillColor = { red = 0, green = 0, blue = 0, alpha = 0.2 },
frame = { x = "0%", y = "0%", h = "100%", w = "100%", },
withShadow = false
},
1
)
local repositionCursor = function()
local currentElement = currentAxElement()
-- Get the current selection
local range = currentElement:attributeValue("AXSelectedTextRange")
-- Last visible char
local visibleRange = currentElement:attributeValue("AXVisibleCharacterRange")
local lastVisibleIndex = visibleRange.length + visibleRange.location
if range.location == lastVisibleIndex then
-- hide the caret if we're at the end of the text box
canvas:hide()
else
-- Get the range for the next character after the blinking cursor
local caretRange = {
location = range.location,
length = 1,
}
-- get the { h, w, x, y } bounding box for the next character's range
local bounds = currentElement:parameterizedAttributeValue("AXBoundsForRange", caretRange)
-- move the position and resize
canvas:topLeft({ x = bounds.x, y = bounds.y })
canvas:size({ h = bounds.h, w = bounds.w })
-- show if not shown
canvas:show()
end
end
repositionCursor()
refresh = 1 / 60 -- 60fps
intervalTimer = hs.timer.doEvery(refresh, repositionCursor)
end) |
Ok I had another insane idea - what if we covered the blinking cursor with the background color of the text field? Here's what drawing a "cursor cover" looks like, when I make it obvious and red: To get the background color of the UITextInput, you can take a screenshot and get the color at a pixel: local screenshotOfTextInput = hs.screen.mainScreen():snapshot(currentElement:attributeValue("AXFrame"))
local color = screenshotOfTextInput:colorAt({ x = 10, y = 10 }) If we set the color to that instead, you get: A GIF of it in action: This is probably not perfect:
Final lua code (for now I just paste it in currentAxElement = function()
local systemElement = ax.systemWideElement()
return systemElement:attributeValue("AXFocusedUIElement")
end
intervalTimer = nil
hs.hotkey.bind(super, 'e', function()
-- draw a black rectangle in the bounding box with 20% opacity
local canvas = hs.canvas.new({ x = 0, y = 0, h = 1, w = 1 })
canvas:level('overlay')
-- block caret
canvas:insertElement(
{
type = 'rectangle',
action = 'fill',
fillColor = { red = 0, green = 0, blue = 0, alpha = 0.2 },
frame = { x = "0%", y = "0%", h = "100%", w = "100%", },
withShadow = false
},
1
)
-- cursor disabler
local cursorDisableCanvas = hs.canvas.new({ x = 0, y = 0, h = 1, w = 1 })
cursorDisableCanvas:insertElement(
{
type = 'rectangle',
action = 'fill',
fillColor = { red = 255, green = 0, blue = 0, alpha = 1 },
frame = { x = "0%", y = "0%", h = "100%", w = "100%", },
withShadow = false
},
1
)
local repositionCursor = function()
local currentElement = currentAxElement()
-- Get the background color
local screenshotOfTextInput = hs.screen.mainScreen():snapshot(currentElement:attributeValue("AXFrame"))
local color = screenshotOfTextInput:colorAt({ x = 10, y = 10 })
-- Get the current selection
local range = currentElement:attributeValue("AXSelectedTextRange")
-- Last visible char
local visibleRange = currentElement:attributeValue("AXVisibleCharacterRange")
local lastVisibleIndex = visibleRange.length + visibleRange.location
if range.location == lastVisibleIndex then
-- hide the caret if we're at the end of the text box
canvas:hide()
cursorDisableCanvas:hide()
else
-- Get the range for the next character after the blinking cursor
local caretRange = {
location = range.location,
length = 1,
}
-- get the { h, w, x, y } bounding box for the next character's range
local bounds = currentElement:parameterizedAttributeValue("AXBoundsForRange", caretRange)
-- move the position and resize
canvas:topLeft({ x = bounds.x, y = bounds.y })
canvas:size({ h = bounds.h, w = bounds.w })
-- show if not shown
canvas:show()
-- disable the cursor
cursorDisableCanvas:topLeft({ x = bounds.x - 1, y = bounds.y })
cursorDisableCanvas:size({ h = bounds.h, w = 2 })
cursorDisableCanvas:elementAttribute(1, 'fillColor', color)
cursorDisableCanvas:show()
end
end
repositionCursor()
refresh = 1 / 60 -- 60fps
intervalTimer = hs.timer.doEvery(refresh, repositionCursor)
end) |
@dbalatero How on earth do you come up with these ideas? I've copied your proof of concept in my init.lua file and it looks like magic. No matter the font and text size, the block's width is adjusting accordingly. I don't know how you came up with calls like About the simplest Mac application, I simply added a import Cocoa
import SwiftUI
@main
class AppDelegate: NSObject, NSApplicationDelegate {
var window: NSWindow!
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Create the window and set the content view.
window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 640, height: 480),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered, defer: false)
window.center()
window.makeKeyAndOrderFront(nil)
// Code added to add a NSTextView
// Instead of using NSTextView, we use our own class that is a sub-class of NSTextView
let ed = MyTextView(frame: NSMakeRect(20, 30, 360, 280))
ed.font = NSFont(name:"Chalkduster", size:20)
ed.string = "Chalkduster"
ed.isEditable = true
ed.isSelectable = true
window.contentView!.addSubview(ed)
}
func applicationWillTerminate(_ aNotification: Notification) {
// Insert code here to tear down your application
}
}
// Our customised NSTextView
class MyTextView: NSTextView {
var caretSize: CGFloat = 10
open override func drawInsertionPoint(in rect: NSRect, color: NSColor, turnedOn flag: Bool) {
var rect = rect
let customColor = NSColor(red: 1.0, green: 0.3, blue: 0.1, alpha: 0.5)
rect.size.width = caretSize
super.drawInsertionPoint(in: rect, color: customColor, turnedOn: flag)
}
open override func setNeedsDisplay(_ rect: NSRect, avoidAdditionalLayout flag: Bool) {
var rect = rect
rect.size.width += caretSize
super.setNeedsDisplay(rect, avoidAdditionalLayout: flag)
}
} I tried to play a little with I have no clue. For now, I am in awe with your solution. |
For one, the AX documentation is really bad so I made a debug helper: function debugElement(currentElement)
local role = currentElement:attributeValue("AXRole")
if role == "AXTextField" or role == "AXTextArea" or role == "AXComboBox" then
logger.i("Currently in text field")
logger.i(inspect(currentElement:parameterizedAttributeNames()))
logger.i("attributes:")
logger.i("-----------")
local attributes = currentElement:allAttributeValues()
local names = {}
for name in pairs(attributes) do table.insert(names, name) end
table.sort(names)
for _, name in ipairs(names) do
logger.i(" " .. name .. ": " .. inspect(attributes[name]))
end
logger.i("action names:")
local names = currentElement:actionNames()
logger.i(inspect(names))
logger.i("action descriptions:")
logger.i("--------------------")
for _, name in ipairs(names) do
logger.i(" " .. name .. ": " .. currentElement:actionDescription(name))
end
else
logger.i("Role = " .. role)
end
end
hs.hotkey.bind(super, 'd', function()
local systemElement = ax.systemWideElement()
local currentElement = systemElement:attributeValue("AXFocusedUIElement")
debugElement(currentElement)
end This dumps out all the parameterized attributes and simple attributes, then I just stare at it and look for interesting combinations. As far as the screenshot thing, I think I just googled for "hammerspoon color at pixel" and ended up on this issue: Hammerspoon/hammerspoon#1559 which linked to here: Hammerspoon/hammerspoon#1868 and ended up with that idea…
I'm not sure actually. I guess all I know is that I intend for it to run every 1/60th of a second, but it could actually be taking longer than To get the solution I have actually beyond a proof of concept, some extra things might need to be done:
|
That is brilliant, thanks for sharing. Up to now I was exploring with : for k,v in pairs(currentElement) do
hs.printf("%s - %s", k, v)
end which obviously don't dig into the table pointers:
All good points.
I ran a few tests, and it obviously depends on how big the FocusedUIElement is: from 300 snap/sec (small text area) to 1/sec (large text area) hs.hotkey.bind({}, 'd', function()
local systemElement = ax.systemWideElement()
local currentElement = systemElement:attributeValue("AXFocusedUIElement")
start = hs.timer.absoluteTime()
nbSnapShot = 0
-- how many loops in 1 sec?
while hs.timer.absoluteTime() - start < 1E9 do
local screenshotOfTextInput = hs.screen.mainScreen():snapshot(currentElement:attributeValue("AXFrame"))
local color = screenshotOfTextInput:colorAt({ x = 10, y = 10 })
nbSnapShot++
end
hs.printf("%s snapshots", nbSnapShot)
end) I also noticed snapshots are 4 times larger (supposedly through the retina effect). For example my console's width is 550px but its snapshot is 2250px. If we take a smaller portion of the UIElement, it get faster: |
Ah thanks so much for doing the timing! That's super helpful. I think taking a smaller portion screenshot is the way to go then! edit: The other possibility is to just leave the cursor blinking for now. I think the next step I'll take is to get this cursor functionality behind a beta config flag and merge it to First priority probably should be knocking down the issues you found in the UTF8 thread though, and wrapping that up – that work I think is higher impact than the cosmetic cursor in here (as cool as it is!) |
I totally rally your point.
Yes I got this same permission request. |
@yoricfr I added the cursor overlay behind a beta flag in If you get the latest A few things I notice so far:
edit: Chrome doesn't seem to support Using the Accessibility API reminds me of writing cross-browser JS, but it's even less consistent somehow. |
@yoricfr How is it feeling with the beta for you? |
@yoricfr ping again? |
have you considered selecting one character for the block cursor vibe? Screen.Recording.2021-05-21.at.20.12.17.mp4works pretty well in my case. some issues depending on the weather, but probably related to the hardcore Accessibility API. |
btw the video is a bit laggy but in the real world it's flawless, on a 10 year old iMac. (P.S.: and yeah the up is not implemented yet :D AX Strategy is much harder than expected if you want to do it flawlessly. many edge cases. and not even talking about handling smileys.) |
Oh sorry, what did you need @godbout ? |
ah, it's me. sorry!
|
From #62, there is a fair amount of discussion from @yoricfr about cursor blocks.
This might be possible to do, but we'd need:
UITextView
https://stackoverflow.com/questions/36311582/caret-cursor-color-and-size
http://programming.jugglershu.net/wp/?p=765
The text was updated successfully, but these errors were encountered: