Skip to content

feat: add OLED display support with DisplayRenderer trait#782

Merged
HaoboGu merged 42 commits intoHaoboGu:mainfrom
gbPagano:feat/oled-displays
Apr 11, 2026
Merged

feat: add OLED display support with DisplayRenderer trait#782
HaoboGu merged 42 commits intoHaoboGu:mainfrom
gbPagano:feat/oled-displays

Conversation

@gbPagano
Copy link
Copy Markdown
Contributor

This PR adds initial OLED display support to RMK via a new display feature flag. The goal is to get early feedback on the architecture and direction before investing in the full feature set.

What's included

  • DisplayRenderer trait — the core abstraction that allows users to fully customize what is drawn on the display:
    pub trait DisplayRenderer {
        fn render<D: DrawTarget<Color = BinaryColor>>(
            &mut self, ctx: &RenderContext, display: &mut D,
        );
    }
  • RenderContext — a snapshot of keyboard state (layer, WPM, caps/num lock, battery) passed to renderers on every redraw, decoupled from the display driver.
  • DefaultRenderer — a built-in renderer that automatically adapts between landscape and portrait layouts based on the logical display dimensions.
  • OledDisplayProcessor<DI, SIZE, R> — a processor (using the existing #[processor] macro) generic over the renderer. R defaults to DefaultRenderer, so ::new(display) works out of the box and ::with_renderer(display, my_renderer) gives full control.
  • examples/use_rust/rp2040_oled updated with a working I2C OLED setup.

Architecture

                    ┌─────────────────────────────────────┐
  use_config ─────▶│  keyboard.toml [display]            │
  (future)          │  rmk-macro generates a configured   │
                    │  renderer at compile-time           │
                    └──────────────┬──────────────────────┘
                                   │ impl DisplayRenderer
                    ┌──────────────▼──────────────────────┐
  use_rust ───────▶│  OledDisplayProcessor<DI, SIZE, R>  │
                    │  R: DisplayRenderer                 │
                    │  R = DefaultRenderer (if omitted)   │
                    └─────────────────────────────────────┘

The design mirrors the two user profiles RMK already supports:

  • use_rust users implement DisplayRenderer for full control.
  • use_config users (future) would declare widgets in keyboard.toml and the macro would generate the renderer.

Not yet included: support for displays other than SSD1306 (SH1106, SSD1309, etc.), an SPI example, keyboard.toml configuration for widget selection and orientation, and public helper functions for custom renderers to reuse.

Questions for review

  1. Does this overall architecture (trait-based renderer + processor generic over it) align with where you'd like RMK's display support to go?
  2. For the keyboard.toml integration: would a widget-slot model or a simpler theme-based approach be preferred?

Open to any thoughts on the direction, would love to hear feedback before moving forward.

@github-actions

This comment was marked as outdated.

@HaoboGu
Copy link
Copy Markdown
Owner

HaoboGu commented Mar 30, 2026

Thanks for proposing it!

The overall architecture looks really good to me. Decoupling renderer and the processor is great.

What I'm thinking about is, can we make both processor and renderer be abled to be implemented in a third-party crate? I'm imaging:

# Cargo.toml
rmk = { version = "*", feature = ["display", ] }

rmk-ssd1306-processor = { .. } # Display driver support
rmk-custom-renderer = { .. } # Display renderer support

Then in keyboard.toml, we can declare something like

# keyboard.toml
[display]
processor = "Ssd1306Processor"
renderer = "CustomRenderer"

where Ssd1306Processor and CustomRenderer are exported in rmk-ssd1306-processor and rmk-custom-renderer.

I prefer not to configure "widgets" in keyboard.toml because it's hard to define all widgets in the RMK's display interface -- I think it might limit the design of the display UI. It should be somehow defined in the renderer crate, not in RMK

@gbPagano
Copy link
Copy Markdown
Contributor Author

Thanks for the feedback! I really like the direction of decoupling the display driver from the RMK core. Here's what I'm thinking for the architecture:

1. rmk core - traits and default renderer only

The core would export only the generic abstractions, with no driver dependencies:

  • DisplayRenderer<C: PixelColor> - generic over the pixel color type so it works for both monochrome OLEDs and color LCDs
  • RenderContext - the keyboard state snapshot (unchanged)
  • DefaultRenderer - built-in renderer implementing DisplayRenderer<BinaryColor>

2. rmk-displays - a single crate with feature-gated drivers

Rather than one crate per display chip, I think a single rmk-displays crate with features makes more sense, since embedded-graphics already unifies the rendering API — the processors share the same event-handling logic and only differ in the driver init/flush layer:

# User's Cargo.toml
rmk-displays = { version = "*", features = ["ssd1306"] }

Each feature would expose a processor (Ssd1306DisplayProcessor<R>, Sh1106DisplayProcessor<R>, etc.), all generic over the renderer. This could live in the same repo as a workspace member.

3. Custom renderers via keyboard.toml

For use_config users who want a custom renderer, I'm thinking the TOML would accept an optional full path:

[display]
driver = "ssd1306"
interface = "i2c"
size = "128x64"
i2c_scl = "PIN_5"
i2c_sda = "PIN_4"
# Optional - omit for DefaultRenderer
renderer = "my_crate::MyRenderer"

The macro would generate:

  • If renderer is omitted: instantiate with DefaultRenderer
  • If renderer is set: parse it as a Rust path and call Default::default() to construct it (the custom renderer must implement Default)

The constraint that custom renderers need Default is reasonable since renderers are typically stateless. For anything more complex, use_rust gives full control.

4. use_rust - unchanged

Users who want full control keep using the processor directly, optionally with a custom renderer, exactly as it works today. They could also use #[register_processor] to wire things up manually.


I agree with your point about not defining widgets in keyboard.toml, the renderer crate is the right place for UI design decisions, not the config file.

What do you think about this approach?

@HaoboGu
Copy link
Copy Markdown
Owner

HaoboGu commented Mar 30, 2026

In RMK core, the built-in renderer(DefaultRenderer) and processor(such as Ssd1306DisplayProcessor) can be provided, that's fine. I just want to ensure that the Renderer/Processor can be implemented as a third crate.

I agree with your for other points. The only comment from me is the keyboard.toml configuration, I prefer to group the configuration for common interfaces. For example, for a display driver using i2c:

driver = "ssd1306"
interface = "i2c"
i2c = { scl = "", sda = "",  }
size = "128x64"

@gbPagano
Copy link
Copy Markdown
Contributor Author

Ok! I'll move forward with the implementation and come back with updates soon

@gbPagano gbPagano force-pushed the feat/oled-displays branch 3 times, most recently from 41ef80e to cd8d8fa Compare April 1, 2026 15:48
@gbPagano
Copy link
Copy Markdown
Contributor Author

gbPagano commented Apr 1, 2026

Hey @HaoboGu , here's an update on the progress since our last discussion.

keyboard.toml configuration

The [display] section uses the grouped interface config you suggested, reusing the existing CommunicationProtocol enum (I2c/Spi):

[display]
driver = "ssd1306"
size = "128x32"
rotation = 270
renderer = "custom_renderer::BigLayerRenderer"  # optional

[display.protocol.i2c]
instance = "I2C1"
scl = "PIN_3"
sda = "PIN_2"
address = 60

It also works in split configs via [split.central.display] and [split.peripheral.display].

Custom renderer support

As we discussed, custom renderers are referenced by path in keyboard.toml. I added an example library at examples/use_rust/custom_renderer/ that the use_config/rp2040_oled example uses as a dependency.

Driver re-exports

Driver crates (ssd1306, oled_async, display-interface-i2c) are re-exported from rmk::display::* so use_config users don't need to add them manually.

Other changes since last push

  • Render rate-limiting — configurable minimum interval (default 30ms) to prevent high-frequency events from overwhelming displays
  • More event subscriptionsKeyboardEvent, SleepStateEvent, BleStatusChangeEvent, PeripheralConnectedEvent, CentralConnectedEvent, PeripheralBatteryEvent.
  • oled_async driver support — SH1106, SH1107, SH1108, SSD1309 via feature flags

SPI interface

The config and codegen are prepared for SPI (via CommunicationProtocol::Spi), but the actual SPI initialization is not implemented yet — I don't have SPI display hardware to test against, so I preferred to leave it as a clear panic rather than ship untested code.

Next steps

If the current architecture looks good to you, my planned next steps are:

  1. Improve the custom_renderer example to better showcase the API
  2. Improve the default renderers (BLE status, split peripheral info, better layouts)
  3. Write user-facing documentation for the RMK docs site

@HaoboGu
Copy link
Copy Markdown
Owner

HaoboGu commented Apr 2, 2026

Thanks for your effort!

I just went through the code roughly, overall it's really great. Some initial comments from me:

  1. In render the DisplayProcessor checks the min interval, is it possible to use the polling based processor here? If so, all self.render().await; in on_xx_event can also be removed.
  2. There are width and height in RenderContext, I'm not sure this is the correct way, maybe associated const is better in this case? I understand that introducing many generics is verbose, but it's just kind of "strange" that display's width and height are in RenderContext. It should be a parameter/field of the display imo
  3. oled_async is a git dependency. It's fine right now but if we want to release the next version, the crates.io version should be there.

And I also found a bug that might block the DisplayProcessor, I've pushed a fix: #787

@gbPagano
Copy link
Copy Markdown
Contributor Author

gbPagano commented Apr 2, 2026

Thanks for the review!

1. Polling-based rendering

I'm actually working on this right now, the DisplayProcessor will support a configurable render_interval that controls the poll frequency, with Duration::MAX (effectively disabled) as the default. I'll push a commit soon.

That said, I don't think we should remove self.render() from the event handlers entirely, for two reasons:

  • Some state changes need to be reflected on the display practically instantly (e.g. a key press triggering a visual response).
  • Users may want to disable polling altogether to save battery and allow the keyboard to enter sleep mode. In that case, event-driven renders are the only way the display updates.

  1. You're right, these don't belong in RenderContext. The renderer already receives &mut D which implements DrawTarget, so it can call display.bounding_box().size directly. I'll remove them.

  2. The reason for the git dependency is that the SH1106 driver isn't included in the latest crates.io release yet. I'll reach out to the crate author about publishing a new version.

@HaoboGu
Copy link
Copy Markdown
Owner

HaoboGu commented Apr 2, 2026

yeah, making DisplayProcessor support both polling and event driven mode is fine. Let's keep self.render() then

@HaoboGu
Copy link
Copy Markdown
Owner

HaoboGu commented Apr 3, 2026

#788 was merged

gbPagano added 16 commits April 3, 2026 20:30
  Introduce a `display` feature flag with:
  - `DisplayRenderer` trait for user-defined rendering
  - `RenderContext` to decouple state from the display driver
  - `DefaultRenderer` with auto landscape/portrait layout
  - `OledDisplayProcessor` generic over the renderer (defaults to DefaultRenderer)
  - `examples/use_rust/rp2040_oled` with I2C SSD1306 setup
Introduce DisplayDriver trait extending DrawTarget with async init/flush,
replacing the SSD1306-specific OledDisplayProcessor. The processor is now
DisplayProcessor<D, R> generic over any DisplayDriver implementation.

Driver support is feature-gated: "display" provides base traits and
processor, "ssd1306" adds the SSD1306 impl. Clear responsibility moved
to the renderer. DefaultRenderer renamed to DefaultOledRenderer.
…s internal state

- Add KeyboardEvent, SleepStateEvent, BleStatusChangeEvent, PeripheralConnectedEvent,
CentralConnectedEvent, and PeripheralBatteryEvent subscriptions to DisplayProcessor.
- Extend RenderContext with the corresponding fields and use it directly as the
processor's internal state to avoid field duplication.
- Bump keyboard event default subs from 2 to 3 to accommodate the new subscriber.
Throttle display refreshes to prevent high-frequency events (e.g. KeyboardEvent)
from overwhelming slow I2C displays. Renders are skipped when the interval since
the last refresh is below a configurable minimum (default 30 ms); the latest
state is drawn on the next event that passes the time check.
Enable display setup via [display] section in keyboard.toml.
Supports SSD1306 and oled_async drivers (SH1106, SH1107, SH1108, SSD1309)
over I2C, with optional custom renderer via `renderer` field.

- Add DisplayConfig, DisplayDriver enum, and protocol-based I2C config
to rmk-config
- Add display codegen for all chip families (RP2040, NRF52, STM32, ESP32)
- Wire display processor and I2C interrupts into orchestrator and split
peripheral codegen
- Re-export driver crates from rmk::display for use_config compatibility
- Add use_config/rp2040_oled example
Add a standalone no_std library demonstrating how to implement a custom
DisplayRenderer, and wire it into the use_config/rp2040_oled example
via the `renderer` field in keyboard.toml.
…tom_renderer example

- Add `manual_polling` option to #[processor] macro, allowing manual PollingProcessor
implementations with dynamic interval control
- Add `render_interval` and `min_render_interval` to DisplayConfig, configurable
via keyboard.toml
- DisplayProcessor uses Duration::MAX by default (no polling), with builder methods
to enable periodic redraws for animations
- Fix overflow in PollingProcessor::polling_loop by using Timer::at with checked_add
instead of Timer::after
- Improve custom_renderer example using BongoCat Animation
- Skip redundant render on WpmUpdateEvent
Renderers can obtain display dimensions directly from the DrawTarget
via display.bounding_box().size, so there's no need to duplicate them
in RenderContext.
When the `display` feature is enabled, the central now forwards WPM,
modifier state, and sleep state to each peripheral via new SplitMessage
variants (Wpm, Modifier, SleepState), all gated behind `#[cfg(feature =
"display")]`. Peripherals republish these as local events, allowing a
peripheral-side display to render the same keyboard state as the central.
gbPagano added 4 commits April 5, 2026 21:47
The first key press still happens with WPM at 0, so the renderer stayed in the
idle state and Bongo Cat only hit the paw after that key press had already been
counted. Remove the idle/tap split below the fury threshold so the animation responds on
the first key press.
…ent-driven modes

Replace PollingProcessor impl with a manual Runnable that explicitly branches
between event-driven mode (sleeping or render_interval is None) and polling mode.
Also changes render_interval from Duration to Option<Duration> and reverts the
checked_add workaround in PollingProcessor::polling_loop.
Copy link
Copy Markdown
Owner

@HaoboGu HaoboGu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some more minor comments from me. Overall it is ok and we can move forward

gbPagano added 12 commits April 9, 2026 22:52
Keep the latest display state pending when redraws are throttled so event-driven displays
do not stay stale after bursts of events.
… display example

Drop the Bongo Cat-based custom renderer example because its QMK-derived frame data is
GPL-2.0 and not suitable for this repository.

Keep a minimal text-based renderer as the in-repo example so users still have a simple
reference for building custom displays.
keyboard.subs is 3 in event_default.toml; the two failing tests were
asserting the old value of 2.
@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 10, 2026

Size Report

Example main PR Diff .text .data .bss
use_config/nrf52832_ble 354.8 KiB 355.1 KiB +0.07% ⬆️ +252 0 +8
use_config/nrf52840_ble 405.5 KiB 406.0 KiB +0.12% ⬆️ +508 0 +8
use_config/nrf52840_ble_split (central) 482.0 KiB 483.2 KiB +0.25% ⬆️ +1164 0 +80
use_config/nrf52840_ble_split (peripheral) 305.2 KiB 305.3 KiB +0.06% ⬆️ +192 0 +8
use_config/pi_pico_w_ble 645.2 KiB 645.5 KiB +0.03% ⬆️ +244 0 +8
use_config/rp2040 146.2 KiB 146.5 KiB +0.17% ⬆️ +248 0 +8
use_config/rp2040_split (central) 157.3 KiB 157.5 KiB +0.09% ⬆️ +116 0 +32
use_config/rp2040_split (peripheral) 25.9 KiB 26.1 KiB +0.51% ⬆️ +128 0 +8
use_config/stm32f1 63.4 KiB 63.5 KiB +0.11% ⬆️ +64 0 +8
use_config/stm32h7 100.5 KiB 100.6 KiB +0.16% ⬆️ +160 0 +8
use_rust/nrf52832_ble 344.0 KiB 344.2 KiB +0.07% ⬆️ +248 0 +8
use_rust/nrf52840_ble 402.8 KiB 403.1 KiB +0.08% ⬆️ +328 0 +8
use_rust/nrf52840_ble_split (central) 492.7 KiB 492.6 KiB +0.00% ⬇️ -104 0 +80
use_rust/nrf52840_ble_split (peripheral) 302.1 KiB 302.1 KiB +0.02% ⬆️ +80 0 +8
use_rust/pi_pico_w_ble 645.5 KiB 645.7 KiB +0.03% ⬆️ +244 0 +8
use_rust/rp2040 146.5 KiB 146.7 KiB +0.16% ⬆️ +244 0 +8
use_rust/rp2040_split (central) 156.8 KiB 156.8 KiB +0.01% ⬆️ 0 0 +32
use_rust/rp2040_split (peripheral) 26.5 KiB 26.6 KiB +0.53% ⬆️ +136 0 +8
use_rust/stm32f1 63.1 KiB 63.2 KiB +0.16% ⬆️ +96 0 +8
use_rust/stm32h7 120.3 KiB 119.5 KiB -0.62% ⬇️ -780 0 +8
use_config/nrf52832_ble — 354.8 KiB → 355.1 KiB (+0.07% ⬆️)
use_config/nrf52840_ble — 405.5 KiB → 406.0 KiB (+0.12% ⬆️)
use_config/nrf52840_ble_split (central) — 482.0 KiB → 483.2 KiB (+0.25% ⬆️)
use_config/nrf52840_ble_split (peripheral) — 305.2 KiB → 305.3 KiB (+0.06% ⬆️)
use_config/pi_pico_w_ble — 645.2 KiB → 645.5 KiB (+0.03% ⬆️)
use_config/rp2040 — 146.2 KiB → 146.5 KiB (+0.17% ⬆️)
use_config/rp2040_split (central) — 157.3 KiB → 157.5 KiB (+0.09% ⬆️)
use_config/rp2040_split (peripheral) — 25.9 KiB → 26.1 KiB (+0.51% ⬆️)
use_config/stm32f1 — 63.4 KiB → 63.5 KiB (+0.11% ⬆️)
use_config/stm32h7 — 100.5 KiB → 100.6 KiB (+0.16% ⬆️)
use_rust/nrf52832_ble — 344.0 KiB → 344.2 KiB (+0.07% ⬆️)
use_rust/nrf52840_ble — 402.8 KiB → 403.1 KiB (+0.08% ⬆️)
use_rust/nrf52840_ble_split (central) — 492.7 KiB → 492.6 KiB (+0.00% ⬇️)
use_rust/nrf52840_ble_split (peripheral) — 302.1 KiB → 302.1 KiB (+0.02% ⬆️)
use_rust/pi_pico_w_ble — 645.5 KiB → 645.7 KiB (+0.03% ⬆️)
use_rust/rp2040 — 146.5 KiB → 146.7 KiB (+0.16% ⬆️)
use_rust/rp2040_split (central) — 156.8 KiB → 156.8 KiB (+0.01% ⬆️)
use_rust/rp2040_split (peripheral) — 26.5 KiB → 26.6 KiB (+0.53% ⬆️)
use_rust/stm32f1 — 63.1 KiB → 63.2 KiB (+0.16% ⬆️)
use_rust/stm32h7 — 120.3 KiB → 119.5 KiB (-0.62% ⬇️)

@gbPagano
Copy link
Copy Markdown
Contributor Author

Hey @HaoboGu, applied all your latest feedback! Also, this PR closes #450.

@HaoboGu
Copy link
Copy Markdown
Owner

HaoboGu commented Apr 10, 2026

Great work! No more comments from me about the code. Let's move to documentation now.

@gbPagano
Copy link
Copy Markdown
Contributor Author

Added the display docs! Split into two pages following the existing pattern:

  • features/display.md : overview, supported drivers, renderers, Rust API, custom renderers/drivers
  • configuration/display.md : keyboard.toml reference for [display] fields and split keyboard setup

Copy link
Copy Markdown
Owner

@HaoboGu HaoboGu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for your effort!

@HaoboGu HaoboGu merged commit 9b3ded4 into HaoboGu:main Apr 11, 2026
45 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants