Package: tapper
tapperis a Python package that allows for convenient, versatile, cross-platform hotkeys, macros and key remaps.FunctionalityOn the tin,tappermight remind you ofpynput,autohotkeyand similar tools - and indeed, they were an inspiration.In practice it aims to do a lot more and is more flexible. Here are some of the advantages:Cross-platform. Your scripts will work on across devices, and you don't need to learn separate tools for each platform.Easy to learn API, convenient for both simple and complex scripts.Responsiveness when rapidly typing or clicking.Per-window or otherwise conditional hotkeys.Suppressing the key that triggered the action. (surprising how commonly this is absent in other tools)Built-in suite of convenient, well-tested helper functions.Examplefromtapperimportroot,Group,Tap,start,helperroot.add({"a":"b","b":"a",# remap "a" and "b""h":"Hello,$(50ms) world!"},# Sleeps for 0.05 sec in the middleGroup(win="notepad").add({"alt+enter":"f11"}),# fullscreen toggleGroup(win_exec="chrome.exe").add(Tap("=","mmb",cursor_in=((0,0),(1700,45)))# close tabs with "="),{"shift+caps":helper.actions.record_toggle(print)}# record actions, print when done)start()InstallationInstall Python 3.10 or later, then install viapip:pip install git+https://github.com/IGalat/tapper.git#egg=tapper[all]For Linux, seethis.How to useHere's a simple example: making thebacktickkey typeunderscoreinstead.fromtapperimportroot,startroot.add({"`":"_"})start()This tellstapperto click (press and release) "_" when "`" is pressed.Ontapper.start, the supplied dictionary gets transformed into aTap, and text action intosend(text).It can be written like this:root.add(Tap("`",lambda:send("_")))Writing it out like this is more verbose, but has additional versatility:root.add(Tap("`","_",win="notepad",toggled_off="caps"))Now this Tap can trigger only whennotepadis the foreground window,
and only ifcaps_lockis off.Forsendmost of the versatility is in text, but an explicit invoke allows to set an interval between clicks:send("hello",interval=0.5)Would sendh, wait 0.5 sec, sende, wait 0.5 sec, and so on.Ok, what if you need to do several actions only fornotepad? Addingwin="notepad"to eachTapis cumbersome, but you canGroupthem:root.add(Group(win="notepad").add({"`":"_","print_screen":"enter"}))Nowprint screenwill clickenterwhen innotepad.The string translates un-bracketed letters to symbol (likeenterinstead ofe,n,t,e,r), assuming there is only that one symbol.If you want to send letters, usesend("enter"). Andsend("$(enter)")will again sendenter.sendhas a lot of capabilities in$(), such as pressing key combinations, sleep, mouse move - see Reference section.Additionally, forenterandtabthere are one-char equivalents:\nand\t.Groups can contain Groups,rootis just a Group as well.There is a special Group which is not a child of root,control_group- it's always checked first, and exists for controllingtapperitself.By default, it has following hotkeys:fromtapperimporthelper,control_groupcontrol_group.add({"f3":helper.controls.restart,"alt+f3":helper.controls.terminate})These are only added if you didn't add any Taps tocontrol_group.Alternatively, call totapper.init()gives access to the commands butTapswon't be listened to:fromtapperimportinit,send,windowinit()send("Immediately on launch type this text.")print(f"Currently active window:{window.active()}")You canstarttapperafter this. It allows fortapper-related actions on script startup.Beforeinitorstartcomponents are not initialized.Usage philosophyA single script is all you need.GrouptheTaps anddicts for convenience and performance, add them to therootGroup.Add conditions (keyword args) to Groups and Taps to make them only trigger in a specific context.Use either functions or text as actions - text will be parsed and corresponding keys pressed.Customize yourcontrol_groupor use existing controls to always have a way to control the flow oftapper. It has the highest priority and will be triggered before root.Withinroot, priority is last-to-first, so set more general Taps/Groups first, and more specific last.Configure which actions can be executed concurrently, and which cannot - by default if an action is running, others cannot trigger.How it worksEach aspect, such as keyboard, window etc has anOS-specificadapter.For receiving signals it is blocking, which is the only way to suppress signals that trigger actions,
so there is an input delay that depends on how long processing takes.ReferenceWhat you have access to:Controllers: allow you to check status and give commands.from tapper import kb, mouse, windowKeyboard.
Get key state or issue commands; get/set language. For commandssendis more convenient though.Mouse.
Same thing.Window.
Get state, check if window is open/active, set window as active, close, max/min/restore.Tap,Group,root,control_group.Conditions forTapandGroup: only if all conditions work, will triggering actions be possible.send- a versatile text-to-command tool.Multi-language support.Concurrency control for actions.Events pub/sub: subscribe to device to get all non-emulated signals.Convenience functions:repeat actions while key is pressed, until toggled or custom condition.record actions and get backsendable text; use it to make permanent macros or playback at will.picture assist*and more to come! Leave your requests ingithub issues.Config settings for more flexibility.ConditionsThese are used as keyword args:Group(some_condition=some_eval_function).add(Tap("a","b",toggled_on="num_lock"))Existing Conditions wrap your supplied argument into a function(Callable),
and this is called every timeTap/Groupmay fire.If bool(callable result) is True for every Condition for thatTap/Group, it can trigger.Here are existing Conditions:NameValue type expectedMeaningExampletrigger_ifCallableYour custom function.bool(result) gets called directly.trigger_if=lambda: datetime.datetime.now().month > 5trigger_if=my_eval_fn_with_no_paramstrigger_if=partial(my_fn, "input param")langstr or int or LangLanguage is the specified one. See Multi-language support section below.lang="ua"lang=1046lang_notstr or int or LangOpposite of previous.lang_not="en"toggled_onstrKeyboard key is toggled on. Applicable to all keys, though usually only "lock" keys matter.toggled_on="num_lock"toggled_offstrOpposite of previous.toggled_off="caps"kb_key_pressedstrKeyboard key is currently pressed.kb_key_pressed=" "kb_key_not_pressedstrOpposite of previous.kb_key_not_pressed="esc"mouse_key_pressedstrSame as kb_key_pressed for mouse.mouse_key_pressed="right_mouse_button"mouse_key_not_pressedstrSame as kb_key_not_pressed for mouse.mouse_key_not_pressed="rmb"cursor_near(int, int) or ((int, int), int)Cursor is within circular area of target.Accepts (x, y) or ((x, y), radius).  Default radius is50.cursor_near=(500, 720)cursor_near=((860, 650), 400)cursor_in(int, int, int, int)Cursor is within a rectangle (min_x, min_y, max_x, max_y)cursor_in=((100, 100, 250, 300))winstrWindow with this name or exec is active.Comparison is not strict, so if supplied arg is contained with ignorecase, it's good enough.win="youtube"win="notepad"win_titlestrSame as "win", but only titles are considered.win_title="chrome"  # won't work unless page contains "chrome".win_title="youtube"  # will only work when page name is "youtube", or if "youtube" is in the name of a document of notepad, etc.win_execstrWindow with thisexactexec is active.win_exec="chrome.exe"open_winstrWindow with this name or exec is open on taskbar, but not necessarily the main window.Does not consider windows in tray.Not strict.open_win="zoom"open_win_titlestrSame as "open_win", but only titles are considered.open_win_title="favourite song name in a player or youtube"open_win_execstrSame as "open_win", but only execs are considered, andstrict.open_win_exec="notepad++.exe"You can easilywrite your own,
thenadd them.sendcommand allows you to:click character keys:Hello world!. This will respect the shift position and bring it back to where it was.click other keyboard/mouse keys:$(ctrl;lmb)- same as$(ctrl)$(lmb)- will click left control, then left mouse button.press combinations:$(alt+shift+c,v,v)will press down alt, shift, then click c, v, v, and release shift and alt.sleep:$(50ms)will sleep for 50 milliseconds(0.05 sec),$(3s)- for 3 sec.keys up/down/on/off:$(a up;caps off)$(b down;num_lock on)will release "a", click caps if it is on(otherwise nothing),
press down "b"(no repeats), and click num lock if it is off.
WARNING: "b" will stay pressed after this, which when unintended can cause bad experience.
Avoid using "down" without "up" afterwards.
If a key is stuck pressed, click it manually to release.press for a time:$(lmb 0.5s)will press left mouse button for half a sec, then release.repeat key multiple times:$(lmb 2x)will double-click left mouse button.move cursor:$(x100y100)will move cursor to x=100, y=100;$(x100y100r)will move cursor relative to current position.combine the above:bye, gotta work.$(ctrl 0.5s+c down;x400y650;lmb 2x;1s;caps off)hello colleagues!set interval between key presses:send("$(q 5x;esc)\t", interval=0.1)will insert a sleep(0.1 sec) between every "q", escape, and tab.(Mostly useful for playback of recorded actions, see below) regulate speed of sleep:send("hi $(30s)there", interval=1s, speed=5)will reduce 30s sleep to 6s, intervals of 1s are unaffected.Symbols and aliasesBothsendand hotkeys allow for aliases.Alias is a symbol that refers to one or more other symbols, such asshiftfor [left_shift,right_shift];lmbforleft_mouse_button.Here is a list of all keys and aliases forkeyboardandmouse.You can callget_possible_signal_symbols()forkb/mouseto get them as well.Multi-language supportGet current window language with:current_lang=tapper.kb.lang()Make a check for a specific language:iftapper.kb.lang("en"):Set language:tapper.kb.set_lang("ua",system_wide=True)# for all appstapper.kb.set_lang("es")# for current appTransliterate and send your string in a different language:fromtapper.helperimportlangdefsay_hi_in_ukrainian():tapper.kb.set_lang("ua")send(lang.to_en("ua","Привіт!"))Or if you have many strings:ua=lambdatext:lang.to_en("ua",text)send(ua("Їжак"))send(ua("І Жак"))You can use language identifiersen-US, aliases where they existen, locale codes1033,
or objects you got fromtapper.kb.lang().See currently supported languageshere.If you need another language - it's very easy to add, usethisand make a pull request!Concurrency of actionsBy default, a running action will block triggering of other actions.You can configure it differently:tapper.config.action_runner_executors_threads=[1,20]root.add({"a":"$(1s)1","b":"$(1s)2"},Tap("c","$(1s)3",executor=1),Tap("d","$(1s)4",executor=1))Clickingaandbwill result in1but not2being typed. Butcanddhave a separate queue of 20 threads,
so you can trigger them many times concurrently.Suppression of trigger keyBy default, trigger key is suppressed:Tap("a+b","hoy")This will result inahoy, asbis suppressed. You can modify this per-Tap/Group, or default in config:default_trigger_suppression=ListenerResult.PROPAGATE# orTap("h+e","llo",suppress_trigger=ListenerResult.PROPAGATE)This will result inhellonothllo.Pub/subThis is not main use oftapper, but you will receiveSignalswhen subscribing to devices:fromtapper.utilimporteventfromtapper.model.types_importSignalkeylog_list=[]defmy_function(signal:Signal):keylog_list.append(signal)event.subscribe("keyboard",my_function)event.subscribe("mouse",my_function)# and when necessaryevent.unsubscribe("keyboard",my_function)event.unsubscribe("mouse",my_function)This is not blocking, events are received in separate listener threads.Convenience functionsfromtapper.helperimportactions,controlsActions repeathelpers.action.repeat_whilerepeats while the condition function returns something that gets bool(result) == True.Tap("t",repeat_while(lambda:datetime.datetime.now().hour>=22,send("Go to sleep!"),period_s=1200,max_repeats=4))This will upon pressingtstart looping, and if the hour is 22 or later,
will type the phrase immediately and then every 20 min (1200 sec).It will stop looping at midnight (as hour is < 22 now), or after 4 iterations.helpers.action.repeat_while_pressedwill repeat the action as long as the specified key is pressed. It doesn't have to be the same key as in hotkey:Tap("a",repeat_while_pressed("ctrl",retry_my_stuff_function,1,15))Tap("b",repeat_while_pressed("b",retry_my_stuff_function))Pressingawill do at most 15 iterations, 1 second apart, as long asctrlis pressed.Pressingbwill run repeats as long asbis held down, and will use defaults - infinite retries 0.1 sec apart.Note: retries will be executed in a separate thread, not the one that called the function.helpers.action.toggle_repeatwill start repeating on first click, end on the second.Tap("ctrl+l",toggle_repeat(send("\nrpal 6.2 LF ICC 25HM with prof\n"),27))Onectrl+lclick will start sending the phrase immediately and every 27 sec, another will stop it.Actions recording and playback/savingYou can record your actions, then get a string you cansend.recording:str=""defset_recording(new_recording:str)->None:globalrecordingrecording=new_recordingroot.add(Group("recording").add({"f7":actions.record_toggle(set_recording),"ctrl+f7":actions.record_start(),"alt+f7":actions.record_stop(set_recording),"f8":lambda:send(recording,interval=0.1,speed=2),"ctrl+f8":lambda:pyperclip.copy(recording),}))On pressingf7orctrl+f7, it'll start recording, then onf7oralt+f7recording will stop, transform to string,
andset_recordingfunction will be called with that string.In this example we're saving the recording, and then onf8it will play back on double speed and with set interval.ctrl+f8will copy it to your clipboard (using external libpyperclip) so you can save it for a macro.You can supply aRecordConfigtorecord_toggleorrecord_stopto modify details about the string you get.Image and pixel search, snipfromtapper.helperimportimgallows you to:search for an image on a screen (or in image), fuzzy search is an optionwait for image (or one of the images) to appearsnip the screen easily and save in a format convenient fortapperusageall of this for a single pixel, which works much fasterTap("f2",img.snip())Move cursor to a spot, pressf2, move to another spot, pressf2- and now you have
a saved.pngfile - partial screenshot of a rectangle between first and second mouse
positions, with name likesnip-(BBOX_953_531_997_686).png, where in brackets are
coordinates of top-left and bottom-right corners of the image on the screen.This now can be used to search the picture:ifimg.find("snip-(BBOX_953_531_997_686).png"):do_my_stuff()You can also specify the bounding box separately, or not at all:img.find(("my_pic.png",(953,531,997,686)))# bbox separate from nameimg.find("my_pic.png")# search the whole screenNote that using the same bbox the image was snipped with means searching that exact
position on the screen. To search a more broad position but not the whole screen, set
coordinates appropriately.Fuzzy search allows you to search for an image like what you supplied:img.find("my_pic.png",precision=0.8)To wait for an image:img.wait_for("my_pic.png")This will regularly take screenshots and check if the picture is found on screen.You can control the search interval and timeout:img.wait_for("my_pic.png",timeout=15,interval=0.5)If you expect one of the images to appear, usewait_for_one_of:yes_btn="yes.png",(-100,213,-56,412)no_btn="no(BBOX_-100_213_-56_412).png"close_btn="close.png"btn=img.wait_for_one_of([yes_btn,no_btn,close_btn])ifbtn==yes_btn:continue_flow()elifbtn==no_btn:warn()elifbtn==close_btn:close_app()else:raiseValueErrorEach image may have a different bounding box.Similar functions for pixel:"f2":img.pixel_str(pyperclip.copy)Pressf2once, and you'll get a string of RGB color and xy-position of the pixel to your clipboard, like:"(255, 255, 255), (1919, 1079)"Or get the same thing as tuples:defmy_pixel_callback(color:tuple[int,int,int],coords:tuple[int,int]):..."f2":img.pixel_info(my_pixel_callback)There is an immediate, non-callback option:my_pixel=pixel_get_color(1920-1,1080-1)assertmy_pixel==(64,45,182)Use the string fromimg.pixel_strto find or wait for a pixel:my_pixel_precise_coords=(255,255,255),(1919,1079)ifimg.pixel_find(my_pixel_precise_coords):...ifimg.pixel_wait_for((255,255,255),(1000,1020,1919,1079)):# search in wider area...yes_btn_pixel=(67,240,13),(560,780)# will only be searched for in one spotno_btn_pixel=(255,13,13),None# will be searched for on the whole screen, as bbox=Noneifimg.pixel_wait_for_one_of([yes_btn_pixel,no_btn_pixel]):...Functions for image and pixel allow searching and snipping an image instead of screen.This may be a pathname, or numpy-array.This exists for efficiency, as taking screenshot every time in a loop or a function is slow.sct=img.get_snip()foriinrange(100):bbox=(i,i,i+100,i+1)ifimg.find((my_img_of_horizontal_line_100_by_1_px,bbox),sct):returniConfigSeeconfig filefor information on what you can configure.Changelog