A gamepad/trackpad for linux phones/tablets implemented using wayland’s layer shell protocol. As no-one has yet to. While running it consumes ~2-5% cpu. While disabled it consumes 0% cpu, so you can always leave it running.
Controls:
- Activate edit: mode by taping top left screen.
- Enable/disable: hold volume down for 250ms
- Switch orientation: volume up for 250ms
- Control volume: press volume up/down FASTER than 250ms
Only PHOSH and PLASMA MOBILE are tested and supported. i.e, see: https://wayland.app/protocols/wlr-layer-shell-unstable-v 8000 1#compositor-support
ironic… considering most layer shell projects use GTK :/
Due to "wayland?" limitations, the UI WILL NOT BE RENDERED ON THE LOCK SCREEN. so if you lock while in edit mode, you may be confused why no touches are registered, and nothing is visible.
Manually disable using volume down, unlock screen, the re-enable with volume down. There is no solution yet.
Debian/Mobian:
apt install build-essential libwayland-dev libwayland-protocols-dev libegl-dev libgles-dev libgl-dev
Alpine
apk add build-base pkgconf wayland-dev wayland-protocols mesa-dev
Build:
make
Root
(the -E is important, otherwise it will not connect to wayland)
sudo -E ./wlr-gamepad
"Rootless"
sudo chmod 666 /dev/input/* && sudo chmod 777 /dev/uinput
./wlr-gamepad
Currently hardcoded:
static const float kTrackpadSensitivity = 1.0f;
static float gMasterOpacity = 0.5f;
- OpenGL immediate mode rendering
- Minimal dependencies
- Simplicity, nothing unnecessary
- Old school C UI look
graph TD
A0["main()"] --> A1{"Initialize Systems"}
A1 --> A2["Wayland/EGL Setup"]
A1 --> A3["OpenGL Setup"]
A1 --> A4["uinput_init()"]
A1 --> A5["init_touch_device()"]
A1 --> A6["find_input_device() for Volume Keys"]
A1 --> A7["Widget System Init (UpdateAllWidgetCoords)"]
A1 --> A8["Main Loop (while running)"]
A8 --> A9{"Event Polling (poll: Wayland, Touch, Volume Keys)"}
A9 -- "Wayland Event" --> A10["wl_display_read_events()"]
A10 --> A11["Wayland Callbacks (e.g., layer_surface_handle_configure)"]
A11 --> A12["Update width, height, gViewportChanged, RecalculateGridLayout"]
A12 --> A7
A9 -- "Volume Key Event (Down/Up)" --> A13["Handle Volume Key Press"]
A13 -- "Long Press VolDown" --> A14["toggle_overlay()"]
A13 -- "Long Press VolUp" --> A14b["Toggle gLandscapeMode"]
A13 -- "Short Press VolDown/Up" --> A4b["uinput_key() for Volume"]
A14 --> A15["Update gOverlayActive"]
A15 -- "Overlay Inactive" --> A8
A15 -- "Overlay Active" --> A16
A9 -- "Touch Event" --> A16["read gTouchDevFd"]
A16 --> A17["handle_evdev_event()"]
A17 --> A18["Update MTSlot state (x, y, active)"]
A17 -- "EV_SYN_REPORT" --> A19{"Process Touch Slots"}
A19 --> A20["UpdateAllWidgetCoords(width, height)"]
A19 --> A21["ProcessAllWidgetsInput() (if APP_STATE_RUNNING)"]
A21 --> WProcTbl["widget_proc_tbl (calls joystick/dpad/button_process)"]
WProcTbl --> WOut["Update Widget.outputValue / Widget.data.button.isPressed"]
A19 --> A22["InputState_Update()"]
A22 -- "Based on Widget.outputValue" --> A23["enqueue_event()"]
A23 --> A24["gInputEvents Queue"]
A19 --> A25["InputState_Flush()"]
A25 -- "Reads gInputEvents" --> A4c["uinput_key() for widget actions"]
A19 --> A26["RenderFrame(width, height)"]
A26 --> A8
%% --- Application State Machine & Touch Interaction Logic (within handle_evdev_event) ---
subgraph AppStateLogic ["Application State & Touch Interaction"]
AS_Main["gAppState"]
AS_Run["APP_STATE_RUNNING"]
AS_Edit["APP_STATE_EDIT_MODE"]
AS_MenuAdd["APP_STATE_MENU_ADD_WIDGET"]
AS_MenuProps["APP_STATE_MENU_WIDGET_PROPERTIES"]
AS_MenuRemapAction["APP_STATE_MENU_REMAP_ACTION"]
AS_MenuRemapKey["APP_STATE_MENU_REMAP_KEY"]
A17 --> AS_Main
AS_Main --"Guides"--> TouchDispatch{"Touch Down/Move/Up Logic in handle_evdev_event"}
TouchDispatch --"Touch Down"--> TD_CheckUI["HandleUITouchDown() for Edit/Add/Props Buttons"]
TD_CheckUI --"Button Hit"--> ChangeState1["Change gAppState (e.g., RUNNING <-> EDIT_MODE, EDIT_MODE -> MENU_ADD_WIDGET)"]
ChangeState1 --> AS_Main
TouchDispatch --"Touch Down, AppState: RUNNING"--> TD_Run
TD_Run --"Widget Hit"--> Run_WidgetControl["Assign Widget.controllingFinger, slot_mode=SLOT_WIDGET"]
TD_Run --"No Widget Hit"--> Run_Trackpad["slot_mode=SLOT_TRACKPAD, init track_last_x/y"]
TouchDispatch --"Touch Down, AppState: EDIT_MODE"--> TD_Edit
TD_Edit --"Widget Hit"--> Edit_SelectOrAction["Select Widget (gSelectedWidgetId) / Init gEditState (MOVE/RESIZE)"]
TD_Edit --"Background Hit"--> Edit_Deselect["gSelectedWidgetId = 0"]
TouchDispatch --"Touch Down, AppState: MENUS"--> TD_Menus
TD_Menus --"MENU_ADD_WIDGET"--> MenuAdd_Action["CreateWidget(), Change gAppState to EDIT_MODE"]
TD_Menus --"MENU_WIDGET_PROPERTIES"--> MenuProps_Action
MenuProps_Action --"Delete"--> MenuProps_Delete["RemoveWidgetByIndex(), Cha
7941
nge gAppState to EDIT_MODE"]
MenuProps_Action --"Remap"--> MenuProps_Remap["Set gRemappingWidgetId, Change gAppState to REMAP_ACTION or REMAP_KEY"]
TD_Menus --"MENU_REMAP_ACTION"--> MenuRemapAction_Action["Set gRemapAction, Change gAppState to REMAP_KEY"]
TD_Menus --"MENU_REMAP_KEY"--> MenuRemapKey_Action["Update Widget KeyMapping, Change gAppState to EDIT_MODE"]
MenuAdd_Action --> AS_Main
MenuProps_Delete --> AS_Main
MenuProps_Remap --> AS_Main
MenuRemapAction_Action --> AS_Main
MenuRemapKey_Action --> AS_Main
TouchDispatch --"Touch Move, slot_mode: SLOT_WIDGET, AppState: EDIT_MODE"--> Move_EditAction["HandleWidgetEditAction() -> Update Widget normCenter/normHalfSize"]
TouchDispatch --"Touch Move, slot_mode: SLOT_TRACKPAD, AppState: RUNNING"--> Move_Trackpad["uinput_move()"]
TouchDispatch --"Touch Up, slot_mode: SLOT_WIDGET"--> Up_Widget
Up_Widget --"AppState: RUNNING"--> Run_ReleaseWidget["Widget.controllingFinger = INVALID_FINGER_ID"]
Up_Widget --"AppState: EDIT_MODE"--> Edit_FinalizeAction["Reset gEditState"]
TouchDispatch --"Touch Up, slot_mode: SLOT_TRACKPAD"--> Up_Trackpad
Up_Trackpad --"No Move"--> Trackpad_Click["uinput_key(BTN_LEFT, press/release)"]
Up_Trackpad --> ResetSlotMode["slot_mode = SLOT_IDLE"]
Up_Widget --> ResetSlotMode
end
%% --- Widget System ---
subgraph WidgetSystem ["Widget System"]
WS_Data["Widget Struct (id, type, pos, size, state, union data)"]
WS_Array["gWidgets[MAX_WIDGETS], gNumWidgets"]
WS_Mgr["Widget Management Functions (CreateWidget, RemoveWidgetByIndex, FindWidgetIndexById, Widget_UpdateAbsCoords)"]
WS_DispatchDraw["widget_draw_tbl (Joystick/DPad/Button_draw)"]
WS_DispatchProcess["widget_proc_tbl (Joystick/DPad/Button_process)"]
WS_Mgr --> WS_Array
AS_Main --"Triggers Widget Create/Delete"--> WS_Mgr
A21 -.-> WS_DispatchProcess
end
WOut --> WS_Data
%% --- Rendering System (within RenderFrame) ---
subgraph RenderingSystem ["Rendering System (OpenGL)"]
R0["RenderFrame()"]
R1["glClear()"]
R2["DrawAllWidgets(editMode)"]
R2 --> WS_DispatchDraw
WS_DispatchDraw --> R_Primitives["Drawing Primitives (DrawRect, DrawCircle, etc.)"]
WS_DispatchDraw --> R_Text["RenderText()"]
R_Primitives --> R_GL["OpenGL Commands (glVertex, glColor, etc.)"]
R_Text --> R_GL
R0 --"Based on gAppState"--> R3{"Draw Menus?"}
R3 --"Yes"--> R4["DrawWidgetSelectionMenu() / DrawWidgetPropertiesMenu() / etc."]
R4 --> R_GenericMenu["DrawGenericMenu()"]
R_GenericMenu --> R_GenericButton["DrawGenericButton()"]
R_GenericButton --> R_Primitives
R_GenericButton --> R_Text
R0 --> R5["DrawUserInterface(editMode)"]
R5 --> R6["DrawMainButton(), DrawAddButton(), DrawPropertiesButton()"]
R6 --> R_GenericButton
R0 --> R7["eglSwapBuffers()"]
end
A26 --> R0
%% --- UInput System ---
subgraph UInputSystem ["UInput Virtual Device"]
UI_Init["uinput_init()"]
UI_Key["uinput_key(keycode, pressed)"]
UI_Move["uinput_move(dx, dy)"]
UI_Destroy["uinput_destroy()"]
UI_Device["/dev/uinput Kernel Interface"]
end
A4 --> UI_Init
A4b --> UI_Key
A4c --> UI_Key
Move_Trackpad --> UI_Move
Trackpad_Click --> UI_Key
A8 --"On Exit"--> UI_Destroy
UI_Init --> UI_Device
UI_Key --> UI_Device
UI_Move --> UI_Device
UI_Destroy --> UI_Device
%% Styling
classDef main fill:#f9f,stroke:#333,stroke-width:2px;
classDef state fill:#ccf,stroke:#333,stroke-width:2px;
classDef io fill:#9cf,stroke:#333,stroke-width:2px;
classDef widget fill:#cf9,stroke:#333,stroke-width:2px;
classDef render fill:#fca,stroke:#333,stroke-width:2px;
classDef system fill:#eee,stroke:#333,stroke-width:2px;
class A0,A8 main;
class AS_Main,AS_Run,AS_Edit,AS_MenuAdd,AS_MenuProps,AS_MenuRemapAction,AS_MenuRemapKey,A15 state;
class A4,A5,A6,A9,A10,A13,A14,A14b,A16,A17,A18,A19,A23,A24,A25,A4b,A4c io;
class A2,A3,A26,R0,R1,R2,R3,R4,R5,R6,R7,R_Primitives,R_Text,R_GenericMenu,R_GenericButton render;
class WS_Data,WS_Array,WS_Mgr,WS_DispatchDraw,WS_DispatchProcess,WProcTbl,WOut,A20,A21,A22 widget;
class A1,A7,A11,A12,TouchDispatch,TD_CheckUI,ChangeState1,TD_Run,Run_WidgetControl,Run_Trackpad,TD_Edit,Edit_SelectOrAction,Edit_Deselect,TD_Menus,MenuAdd_Action,MenuProps_Action,MenuProps_Delete,MenuProps_Remap,MenuRemapAction_Action,MenuRemapKey_Action,Move_EditAction,Move_Trackpad,Up_Widget,Run_ReleaseWidget,Edit_FinalizeAction,Up_Trackpad,Trackpad_Click,ResetSlotMode system;
class UI_Init,UI_Key,UI_Move,UI_Destroy,UI_Device io;
You could feed this mermaid graph to an LLM for easier re-implementation in other coding languages
Due to the fact that layer shell is not well supported yet, many basic elements are implemented from scratch, code is quite difficult to follow, and I won’t even try to pretend to understand what “handle_evdev_event” does. if you know some minimal dependency abstractions which could help the complexity, I would love to know.
Things considered:
- Raylib: Good library, a replacement for drawbacks of immediate mode OpenGL while using immediate mode api, but because of simple shapes, there was actually an increase in cpu utilization by default even with little elements.
- Imgui: Impossible, does not support multi touch natively
- Nukklear: overly complicated for what I was trying to accomplish, would need to implement ui gamepad elements except buttons myself anyway, so better to implement the entire ui elements myself.
- gtk-layer-shell: Good project, but not a good fit for such simple ui elements
- SDL3: Can't draw to layer shell egl. Wayland support is not included in the base ubuntu build from what I understand and would require additional building steps.
- GLFW: Can't draw to layer shell egl at all
I’ve even considered rust, but adding just a single egui dependency resulted in a 1.6gb project….
- Test larger screen sizes like tablets, but I’ve tried, and it should work.
- Implement full mouse buttons and scrolling support just like TouchpadEmulator, but with improvements
- Fix font, but I find it charming. xD
- Saving/loading
- Preference menu for opacity, (currently hardcoded) etc.
- Squash bugs