Skip to content

Commit dc2fcbd

Browse files
Patrick Carrollclaude
andcommitted
Add --shield flag, .conf generation, and encoder sensor-bindings
Level 1 — .conf generation: - Add `--shield <name>` CLI flag to qmk2zmk; when set, emits a `<shield>.conf` alongside the keymap with CONFIG_ZMK_RGB_UNDERGLOW, CONFIG_ZMK_POINTING, and CONFIG_EC11 flags detected from the IR. - Add `zmk::render_conf(keyboard)` public function. Level 2 — Encoder sensor-bindings: - Add `sensor_bindings: Vec<(KeyExpr, KeyExpr)>` to `ir::Layer`. - Parse `encoder_update_user` in `qmk/parse_c.rs`: handles both a per-layer `switch (get_highest_layer(...))` pattern and a global `if (clockwise)` pattern; extracts keys from `tap_code()` / `tap_code16()` / `register_code()` calls. - Parse `sensor-bindings = <&inc_dec_kp CW CCW>;` in `zmk/parse.rs`. - Render `sensor-bindings = <&inc_dec_kp CW CCW>;` per layer in `zmk/mod.rs` when sensor_bindings is non-empty. - Add unit tests for all new paths and a ZMK render round-trip test. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 240a5d1 commit dc2fcbd

7 files changed

Lines changed: 525 additions & 3 deletions

File tree

src/bin/qmk2zmk.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ struct Cli {
6868
/// Parse the keymap and print a layout table, then exit without converting
6969
#[arg(short = 'p', long)]
7070
print_layout: bool,
71+
72+
/// Shield name used to emit a companion `<shield>.conf` file.
73+
///
74+
/// When set, a `<shield>.conf` file with detected `CONFIG_*` flags (RGB,
75+
/// pointing, encoder) is written alongside the `.keymap` output.
76+
#[arg(long)]
77+
shield: Option<String>,
7178
}
7279

7380
/// Process exit boundary for the binary.
@@ -119,7 +126,22 @@ fn run() -> Result<(), Error> {
119126
}
120127

121128
let output = zmk::render(&keyboard, cols);
122-
io::write_output(&output, cli.output.as_deref())
129+
io::write_output(&output, cli.output.as_deref())?;
130+
131+
if let Some(shield) = &cli.shield {
132+
let conf = zmk::render_conf(&keyboard);
133+
let conf_path = cli
134+
.output
135+
.as_deref()
136+
.and_then(|p| p.parent())
137+
.map_or_else(
138+
|| PathBuf::from(format!("{shield}.conf")),
139+
|dir| dir.join(format!("{shield}.conf")),
140+
);
141+
io::write_output(&conf, Some(&conf_path))?;
142+
}
143+
144+
Ok(())
123145
}
124146

125147
/// Print the built-in keyboard column heuristics used by `--keyboard`.

src/ir.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ pub struct Layer {
4545
pub index: usize,
4646
/// Keys in physical layout order, already flattened out of source syntax.
4747
pub keys: Vec<Key>,
48+
/// Per-encoder clockwise/counter-clockwise key pairs for this layer.
49+
///
50+
/// Each element maps to one physical encoder. QMK's `encoder_update_user`
51+
/// callback is the source for this data; ZMK renders each pair as
52+
/// `sensor-bindings = <&inc_dec_kp CW CCW>`.
53+
pub sensor_bindings: Vec<(KeyExpr, KeyExpr)>,
4854
}
4955

5056
/// A single binding in a layer, macro, or tap-dance definition.

src/qmk/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ mod tests {
234234
name: "base_layer".into(),
235235
index: 0,
236236
keys,
237+
sensor_bindings: vec![],
237238
}],
238239
macros: vec![],
239240
tap_dances: vec![],

src/qmk/parse_c.rs

Lines changed: 288 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ pub fn parse(source: &str) -> Result<Keyboard, ParseCError> {
4343
.map(|(i, td)| (td.name.clone(), i))
4444
.collect();
4545

46+
let encoder_bindings =
47+
extract_encoder_bindings(&cleaned, &layer_map, &defines, &custom_keycodes);
48+
4649
let raw_layers = extract_raw_layers(&cleaned)?;
4750

4851
let mut layers: Vec<Layer> = raw_layers
@@ -61,7 +64,15 @@ pub fn parse(source: &str) -> Result<Keyboard, ParseCError> {
6164
)
6265
})
6366
.collect();
64-
Layer { name, index, keys }
67+
let sensor_bindings = encoder_bindings
68+
.get(&index)
69+
.map_or_else(Vec::new, |pair| vec![pair.clone()]);
70+
Layer {
71+
name,
72+
index,
73+
keys,
74+
sensor_bindings,
75+
}
6576
})
6677
.collect();
6778

@@ -711,6 +722,200 @@ fn parse_tap_dance_action(
711722
vec![]
712723
}
713724

725+
// ── Encoder extraction ────────────────────────────────────────────────────────
726+
727+
/// Parse `encoder_update_user` and return a map from layer index to
728+
/// (clockwise key, counter-clockwise key).
729+
///
730+
/// Handles two common QMK patterns:
731+
///
732+
/// 1. **Per-layer switch**: `switch (get_highest_layer(layer_state)) { case _BASE: … }`
733+
/// 2. **Global**: a bare `if (clockwise) { tap_code(CW); } else { tap_code(CCW); }`
734+
/// at the function's top level, applied to every layer in `layer_map`.
735+
fn extract_encoder_bindings(
736+
s: &str,
737+
layer_map: &HashMap<String, usize>,
738+
defines: &HashMap<String, String>,
739+
custom_keycodes: &HashSet<String>,
740+
) -> HashMap<usize, (KeyExpr, KeyExpr)> {
741+
let mut result = HashMap::new();
742+
743+
let Some(body) = find_function_body(s, "encoder_update_user") else {
744+
return result;
745+
};
746+
747+
if body.contains("get_highest_layer")
748+
&& let Some(switch_body) = extract_switch_on_highest_layer(&body)
749+
{
750+
parse_switch_cases(&switch_body, layer_map, defines, custom_keycodes, &mut result);
751+
}
752+
753+
if result.is_empty()
754+
&& let Some(pair) = extract_clockwise_pair(&body, defines, custom_keycodes)
755+
{
756+
for &idx in layer_map.values() {
757+
result.insert(idx, pair.clone());
758+
}
759+
}
760+
761+
result
762+
}
763+
764+
/// Extract the body (between the outer braces) of a named C function.
765+
fn find_function_body(s: &str, fn_name: &str) -> Option<String> {
766+
let pos = s.find(fn_name)?;
767+
let after = &s[pos + fn_name.len()..];
768+
let paren = after.find('(')?;
769+
let close_paren = find_matching(&after[paren..], '(', ')')?;
770+
let after_params = after[paren + close_paren + 1..].trim_start();
771+
let brace = after_params.find('{')?;
772+
let body_rest = &after_params[brace..];
773+
let close_brace = find_matching(body_rest, '{', '}')?;
774+
Some(body_rest[1..close_brace].to_string())
775+
}
776+
777+
/// Extract the body of `switch (get_highest_layer(...)) { … }`.
778+
fn extract_switch_on_highest_layer(body: &str) -> Option<String> {
779+
let switch_pos = body.find("switch")?;
780+
let after = &body[switch_pos..];
781+
let paren = after.find('(')?;
782+
let close_paren = find_matching(&after[paren..], '(', ')')?;
783+
if !after[paren + 1..paren + close_paren].contains("get_highest_layer") {
784+
return None;
785+
}
786+
let after_cond = after[paren + close_paren + 1..].trim_start();
787+
let brace = after_cond.find('{')?;
788+
let close = find_matching(&after_cond[brace..], '{', '}')?;
789+
Some(after_cond[brace + 1..brace + close].to_string())
790+
}
791+
792+
/// Walk the cases of a switch body and populate `result` with per-layer bindings.
793+
fn parse_switch_cases(
794+
switch_body: &str,
795+
layer_map: &HashMap<String, usize>,
796+
defines: &HashMap<String, String>,
797+
custom_keycodes: &HashSet<String>,
798+
result: &mut HashMap<usize, (KeyExpr, KeyExpr)>,
799+
) {
800+
let mut remaining = switch_body;
801+
while let Some(case_pos) = remaining.find("case ") {
802+
let after_case = &remaining[case_pos + 5..];
803+
let Some(colon) = after_case.find(':') else {
804+
break;
805+
};
806+
let layer_name = after_case[..colon].trim();
807+
let case_start = case_pos + 5 + colon + 1;
808+
let case_body = &remaining[case_start..];
809+
let end = find_case_body_end(case_body);
810+
if let Some(idx) = resolve_layer(layer_name, layer_map)
811+
&& let Some(pair) =
812+
extract_clockwise_pair(&case_body[..end], defines, custom_keycodes)
813+
{
814+
result.insert(idx, pair);
815+
}
816+
remaining = &remaining[case_start + end..];
817+
}
818+
}
819+
820+
/// Return the length of the current case's body — stopping at the next `case`,
821+
/// `default:`, or the end of the enclosing switch block.
822+
fn find_case_body_end(s: &str) -> usize {
823+
let mut depth = 0usize;
824+
let mut i = 0;
825+
while i < s.len() {
826+
match s.as_bytes()[i] {
827+
b'{' => {
828+
depth += 1;
829+
i += 1;
830+
}
831+
b'}' => {
832+
if depth == 0 {
833+
return i;
834+
}
835+
depth -= 1;
836+
i += 1;
837+
}
838+
_ => {
839+
if depth == 0
840+
&& (s[i..].starts_with("case ") || s[i..].starts_with("default:"))
841+
{
842+
return i;
843+
}
844+
i += 1;
845+
}
846+
}
847+
}
848+
s.len()
849+
}
850+
851+
/// Extract a (clockwise, counter-clockwise) key pair from an `if (clockwise)`
852+
/// block. Returns `None` if the pattern is not found or not recognized.
853+
fn extract_clockwise_pair(
854+
body: &str,
855+
defines: &HashMap<String, String>,
856+
custom_keycodes: &HashSet<String>,
857+
) -> Option<(KeyExpr, KeyExpr)> {
858+
// Allow both "if (clockwise)" and "if(clockwise)" spellings.
859+
let if_pos = body.find("if")?;
860+
let after_if = body[if_pos + 2..].trim_start();
861+
if !after_if.starts_with('(') {
862+
return None;
863+
}
864+
let close_paren = find_matching(after_if, '(', ')')?;
865+
if !after_if[1..close_paren].contains("clockwise") {
866+
return None;
867+
}
868+
let after_cond = after_if[close_paren + 1..].trim_start();
869+
let (clockwise_body, rest) = extract_block_or_stmt(after_cond)?;
870+
let after_else_kw = rest.trim_start();
871+
let after_else = after_else_kw.strip_prefix("else")?.trim_start();
872+
let (counter_body, _) = extract_block_or_stmt(after_else)?;
873+
let cw = extract_tap_code_key(&clockwise_body, defines, custom_keycodes)?;
874+
let ccw = extract_tap_code_key(&counter_body, defines, custom_keycodes)?;
875+
Some((cw, ccw))
876+
}
877+
878+
/// Return the body of a `{ … }` block or single statement, and the remaining
879+
/// text after the closing delimiter.
880+
fn extract_block_or_stmt(s: &str) -> Option<(String, &str)> {
881+
if s.starts_with('{') {
882+
let close = find_matching(s, '{', '}')?;
883+
Some((s[1..close].to_string(), &s[close + 1..]))
884+
} else {
885+
let end = s.find(';')? + 1;
886+
Some((s[..end - 1].to_string(), &s[end..]))
887+
}
888+
}
889+
890+
/// Extract the ZMK key expression from a `tap_code(KC_X)` or
891+
/// `tap_code16(KC_X)` or `register_code(KC_X)` call.
892+
fn extract_tap_code_key(
893+
body: &str,
894+
defines: &HashMap<String, String>,
895+
custom_keycodes: &HashSet<String>,
896+
) -> Option<KeyExpr> {
897+
for fn_name in &["tap_code16", "tap_code", "register_code"] {
898+
if let Some(pos) = body.find(fn_name) {
899+
let after = body[pos + fn_name.len()..].trim_start();
900+
if !after.starts_with('(') {
901+
continue;
902+
}
903+
let close = find_matching(after, '(', ')')?;
904+
let arg = after[1..close].trim();
905+
let empty_lm: HashMap<String, usize> = HashMap::new();
906+
let empty_td: HashMap<String, usize> = HashMap::new();
907+
let key =
908+
parse_key_expr_str(arg, &empty_lm, defines, custom_keycodes, &empty_td);
909+
if let Key::Kp(expr) = key {
910+
return Some(expr);
911+
}
912+
// Fallback: try direct QMK→ZMK lookup without the full key pipeline
913+
return KeyExpr::from_qmk_key(arg);
914+
}
915+
}
916+
None
917+
}
918+
714919
// ── Utilities ─────────────────────────────────────────────────────────────────
715920

716921
/// Find the index of the closing delimiter matching the opening at position 0.
@@ -1193,4 +1398,86 @@ uint8_t layer_state_set_user(uint8_t state) {
11931398
assert_eq!(tri.upper, 2);
11941399
assert_eq!(tri.tri, 3);
11951400
}
1401+
1402+
// ── encoder_update_user parsing ───────────────────────────────────────────
1403+
1404+
#[test]
1405+
fn encoder_global_pattern() {
1406+
let src = r"
1407+
enum layers { _BASE, _FN };
1408+
const uint16_t PROGMEM keymaps[][1][2] = {
1409+
[_BASE] = LAYOUT(KC_A, KC_B),
1410+
[_FN] = LAYOUT(KC_TRANSPARENT, KC_TRANSPARENT),
1411+
};
1412+
bool encoder_update_user(uint8_t index, bool clockwise) {
1413+
if (clockwise) {
1414+
tap_code(KC_VOLU);
1415+
} else {
1416+
tap_code(KC_VOLD);
1417+
}
1418+
return false;
1419+
}
1420+
";
1421+
let km = super::parse(src).unwrap();
1422+
assert_eq!(km.layers.len(), 2);
1423+
for layer in &km.layers {
1424+
assert_eq!(layer.sensor_bindings.len(), 1, "layer {} should have one encoder pair", layer.name);
1425+
let (cw, ccw) = &layer.sensor_bindings[0];
1426+
assert_eq!(cw.to_string(), "C_VOL_UP");
1427+
assert_eq!(ccw.to_string(), "C_VOL_DN");
1428+
}
1429+
}
1430+
1431+
#[test]
1432+
fn encoder_per_layer_switch() {
1433+
let src = r"
1434+
enum layers { _BASE, _LOWER };
1435+
const uint16_t PROGMEM keymaps[][1][2] = {
1436+
[_BASE] = LAYOUT(KC_A, KC_B),
1437+
[_LOWER] = LAYOUT(KC_TRANSPARENT, KC_TRANSPARENT),
1438+
};
1439+
bool encoder_update_user(uint8_t index, bool clockwise) {
1440+
switch (get_highest_layer(layer_state)) {
1441+
case _BASE:
1442+
if (clockwise) {
1443+
tap_code(KC_VOLU);
1444+
} else {
1445+
tap_code(KC_VOLD);
1446+
}
1447+
break;
1448+
case _LOWER:
1449+
if (clockwise) {
1450+
tap_code(KC_PGDN);
1451+
} else {
1452+
tap_code(KC_PGUP);
1453+
}
1454+
break;
1455+
}
1456+
return false;
1457+
}
1458+
";
1459+
let km = super::parse(src).unwrap();
1460+
let base = km.layers.iter().find(|l| l.name == "_BASE").unwrap();
1461+
assert_eq!(base.sensor_bindings.len(), 1);
1462+
let (cw, ccw) = &base.sensor_bindings[0];
1463+
assert_eq!(cw.to_string(), "C_VOL_UP");
1464+
assert_eq!(ccw.to_string(), "C_VOL_DN");
1465+
1466+
let lower = km.layers.iter().find(|l| l.name == "_LOWER").unwrap();
1467+
assert_eq!(lower.sensor_bindings.len(), 1);
1468+
assert_eq!(lower.sensor_bindings[0].0.to_string(), "PG_DN");
1469+
assert_eq!(lower.sensor_bindings[0].1.to_string(), "PG_UP");
1470+
}
1471+
1472+
#[test]
1473+
fn no_encoder_means_empty_sensor_bindings() {
1474+
let src = r"
1475+
enum layers { _BASE };
1476+
const uint16_t PROGMEM keymaps[][1][1] = {
1477+
[_BASE] = LAYOUT(KC_A),
1478+
};
1479+
";
1480+
let km = super::parse(src).unwrap();
1481+
assert!(km.layers[0].sensor_bindings.is_empty());
1482+
}
11961483
}

src/qmk/parse_json.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ impl From<QmkJsonLayer> for Layer {
8383
)
8484
})
8585
.collect(),
86+
sensor_bindings: vec![],
8687
}
8788
}
8889
}

0 commit comments

Comments
 (0)