<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Saad</title>
    <description>The latest articles on DEV Community by Saad (@saad_b9c2d26b377f).</description>
    <link>https://dev.to/saad_b9c2d26b377f</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4224698%2F0203d00f-9385-45ac-b72e-0cf043a77cd2.png</url>
      <title>DEV Community: Saad</title>
      <link>https://dev.to/saad_b9c2d26b377f</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/saad_b9c2d26b377f"/>
    <language>en</language>
    <item>
      <title>I built PowerToys for Linux in Python. Here's how key remapping works on Wayland</title>
      <dc:creator>Saad</dc:creator>
      <pubDate>Tue, 03 Nov 2026 16:59:07 +0000</pubDate>
      <link>https://dev.to/saad_b9c2d26b377f/i-built-powertoys-for-linux-in-python-heres-how-key-remapping-works-on-wayland-7fc</link>
      <guid>https://dev.to/saad_b9c2d26b377f/i-built-powertoys-for-linux-in-python-heres-how-key-remapping-works-on-wayland-7fc</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn6i66rj9fd8l3j643y2r.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn6i66rj9fd8l3j643y2r.gif" alt="Knobu demo" width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A year ago I switched to Linux, and it turned out to be the perfect fit for me. Faster, quieter, and mine to shape. But one thing kept nagging me: I missed the PowerToys-style tools I'd used to supercharge my workflow. I lean on shortcuts for everything, and above all I missed touchpad gestures. I wanted to slide three fingers to change the volume without hunting for a slider.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;&lt;a href="https://github.com/Saad-1719/knobu" rel="noopener noreferrer"&gt;Knobu&lt;/a&gt;&lt;/strong&gt;: five small power tools for&lt;br&gt;
Ubuntu and Kubuntu, each one switchable from a GTK4 settings app.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Touchpad Gestures&lt;/strong&gt;: slide three fingers up or down and the volume follows. Tap to mute or play/pause.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keyboard Manager&lt;/strong&gt;: remap keys (CapsLock → Ctrl) and shortcuts (Super+E → terminal) in every app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Awake&lt;/strong&gt;: keep the computer awake for a set time without touching power settings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Color Picker&lt;/strong&gt;: Super+Shift+C, click a pixel, and its HEX/RGB/HSL is copied.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text Extractor&lt;/strong&gt;: Super+Shift+T, drag over text on screen, and it's copied (Tesseract OCR).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The part I found most interesting to build was the keyboard remapper, so that's what this post is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: Wayland doesn't let apps grab your keys
&lt;/h2&gt;

&lt;p&gt;On X11, remapping tools could ask the X server to intercept keys. On Wayland, that's deliberately impossible: an app only sees keys while its own window is focused. Each compositor (KWin, Mutter, ...) has its own way of configuring keys, if it has one.&lt;/p&gt;

&lt;p&gt;I wanted one remapper that works the same on KDE, GNOME, Wayland and X11. The trick is to work &lt;strong&gt;below&lt;/strong&gt; the compositor, at the kernel's input layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grab the real keyboard, re-type from a virtual one
&lt;/h2&gt;

&lt;p&gt;Linux exposes every input device as &lt;code&gt;/dev/input/event*&lt;/code&gt; (evdev). A process can &lt;strong&gt;grab&lt;/strong&gt; a device, which means nobody else (including the compositor) receives its events. And through &lt;code&gt;/dev/uinput&lt;/code&gt;, a process can create a &lt;strong&gt;virtual keyboard&lt;/strong&gt; that the compositor treats like any other keyboard.&lt;/p&gt;

&lt;p&gt;So Knobu's remapper:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Creates a virtual keyboard with python-evdev's &lt;code&gt;UInput&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Grabs each physical keyboard.&lt;/li&gt;
&lt;li&gt;Reads every key event, runs it through the remap rules, and writes the result to the virtual keyboard.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;ui&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;evdev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UInput&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EV_KEY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;codes&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Knobu virtual keyboard&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;dev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;grab&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ev&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;dev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EV_KEY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;remapper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EV_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;syn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The compositor never knows the difference, which is why it behaves the same everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Never grab while a key is down.&lt;/strong&gt; If you start the daemon from a terminal, Enter is still held when the grab happens. The compositor saw Enter go down but never sees it come up, so Enter is stuck. Knobu waits until &lt;code&gt;dev.active_keys()&lt;/code&gt; is empty before grabbing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key remaps and shortcut remaps are different things.&lt;/strong&gt; CapsLock → Ctrl should change the key &lt;em&gt;everywhere&lt;/em&gt;, so CapsLock+C is a real copy. But Super+E → Ctrl+Alt+T should only fire on that exact combination. So plain key→key remaps are a lookup table applied to every event, and shortcuts are matched separately on key press, against the modifiers currently held.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shortcuts have to lift modifiers you're holding.&lt;/strong&gt; If you press Super+E and the target is Ctrl+Alt+T, the desktop must not see Super+Ctrl+Alt+T. The remapper briefly releases the held modifiers the target doesn't use, taps the target, then presses them again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Super opens the app launcher.&lt;/strong&gt; Desktops open the launcher when Super is pressed and released &lt;em&gt;on its own&lt;/em&gt;. After a Super+E remap fires, releasing Super would open it. The fix is a little odd: tap an unused key (F24) before Super goes up, so the desktop thinks&lt;br&gt;
Super was part of a shortcut.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't grab anything you don't need.&lt;/strong&gt; With no remaps configured, Knobu grabs nothing. And if anything goes wrong, the grab is released, so you can always still type.&lt;/p&gt;

&lt;p&gt;The remap logic is a plain class that takes &lt;code&gt;(code, value)&lt;/code&gt; and returns the events to send, with no devices involved. That made all of the above easy to unit test.&lt;/p&gt;

&lt;h2&gt;
  
  
  The touchpad volume knob
&lt;/h2&gt;

&lt;p&gt;Gestures use the same layer, but &lt;em&gt;read-only&lt;/em&gt;: the touchpad is never grabbed, so normal clicking and scrolling are untouched. Knobu follows the multitouch slots (&lt;code&gt;ABS_MT_SLOT&lt;/code&gt;, &lt;code&gt;ABS_MT_POSITION_Y&lt;/code&gt;) and averages the movement of the fingers present in consecutive reports. That way fingers landing a few milliseconds apart don't make the volume jump. A small deadzone separates a slide from a tap. A drag only counts if the finger count never went above three, so a 4-finger tap never turns into a volume change.&lt;/p&gt;

&lt;p&gt;Volume goes through PipeWire's &lt;code&gt;wpctl&lt;/code&gt; (falling back to &lt;code&gt;pactl&lt;/code&gt;). On KDE, Knobu calls Plasma's own OSD over D-Bus, so you get the native volume pop-up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost: permissions
&lt;/h2&gt;

&lt;p&gt;Reading &lt;code&gt;/dev/input&lt;/code&gt; needs your user in the &lt;code&gt;input&lt;/code&gt; group, and creating a virtual keyboard needs a udev rule for &lt;code&gt;/dev/uinput&lt;/code&gt;. The &lt;code&gt;.deb&lt;/code&gt; installs the rule. It's a real trade-off, since any process running as you can then read your keyboard, and I'd rather say so up front.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Knobu is GPL-3.0. It's fully supported on Kubuntu (Plasma 6) and in beta on Ubuntu (GNOME), and Xfce, LXQt and MATE are next.&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://github.com/Saad-1719/knobu" rel="noopener noreferrer"&gt;https://github.com/Saad-1719/knobu&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I'd love to hear which PowerToys tool you miss most on Linux. That's how I'll pick the next module.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>linux</category>
      <category>python</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
