use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::qt_scjson;
pub const QT_IR_VERSION: u32 = 2;
pub const QT_IR_SCHEMA_ID: &str = "https://rlvgl.dev/schemas/qt-ir.schema.json";
pub const QT_IR_SCHEMA_COMMENT: &str = "schemas/qt-ir.schema.json - rlvgl-creator qt-ir IR. \
Regenerate with `rlvgl-creator qt schema --out schemas/qt-ir.schema.json`. \
See docs/qt-support/02-ir-schema.md for the bumping policy.";
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct UiModule {
pub version: u32,
pub source: String,
pub imports: Vec<UiImport>,
pub pragmas: Vec<String>,
pub root: UiItem,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state_machine: Option<UiStateMachine>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct UiImport {
pub module: String,
pub version: Option<String>,
pub alias: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
pub struct UiItem {
pub type_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub properties: Vec<UiProperty>,
pub assignments: Vec<UiAssignment>,
pub signals: Vec<UiSignal>,
pub handlers: Vec<UiHandler>,
pub children: Vec<UiItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct UiProperty {
pub name: String,
pub ty: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_value: Option<String>,
pub readonly: bool,
pub default_kw: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct UiAssignment {
pub target: String,
pub value: UiAssignmentValue,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum UiAssignmentValue {
Expression { text: String },
Object { item: Box<UiItem> },
List { items: Vec<UiAssignmentValue> },
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct UiSignal {
pub name: String,
pub params: Vec<UiSignalParam>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct UiSignalParam {
pub name: String,
pub ty: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct UiHandler {
pub signal: String,
pub body: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct UiStateMachine {
pub id: String,
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub initial: Option<String>,
pub states: Vec<UiState>,
pub transitions: Vec<UiTransition>,
pub datamodel: Vec<UiDmField>,
pub scripts: Vec<UiScript>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct UiState {
pub id: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub on_entry: Vec<UiAction>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub on_exit: Vec<UiAction>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct UiTransition {
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub event: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cond: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub actions: Vec<UiAction>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct UiDmField {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub initial: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct UiScript {
pub name: String,
pub origin: UiScriptOrigin,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum UiScriptOrigin {
Transition {
index: u32,
from: String,
to: Option<String>,
},
OnEntry {
state: String,
},
OnExit {
state: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum UiAction {
Assign {
location: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
expr: Option<String>,
},
Raise { event: String },
Script { name: String },
}
fn walk_qml_state_machine(item: &UiItem, source: &str) -> Result<Option<qt_scjson::Scxml>> {
use serde_json::Value;
let states_assignment = item.assignments.iter().find(|a| a.target == "states");
let transitions_assignment = item.assignments.iter().find(|a| a.target == "transitions");
if states_assignment.is_none() && transitions_assignment.is_none() {
return Ok(None);
}
let mut states: Vec<qt_scjson::State> = Vec::new();
let mut initial: Option<String> = None;
if let Some(asn) = states_assignment {
for inner in iter_object_items(&asn.value, "State") {
let name = lookup_assignment(inner, "name")
.and_then(parse_string_literal)
.ok_or_else(|| {
anyhow::anyhow!(
"QT-05d §6: `State` block in {source} has no literal-string `name:` \
attribute (per QT-05d §5)"
)
})?;
let is_initial = lookup_assignment(inner, "initial")
.map(|s| s.trim() == "true")
.unwrap_or(false);
if is_initial {
if initial.is_some() {
bail!(
"QT-05d §6 / §5: at most one `State {{ … initial: true }}` permitted; \
{source} has multiple"
);
}
initial = Some(name.clone());
}
states.push(qt_scjson::State {
id: Some(name),
..qt_scjson::State::default()
});
}
}
if let Some(asn) = transitions_assignment {
for inner in iter_object_items(&asn.value, "Transition") {
let from = lookup_assignment(inner, "from")
.and_then(parse_string_literal)
.ok_or_else(|| {
anyhow::anyhow!(
"QT-05d §6: `Transition` block in {source} has no literal-string \
`from:` (per QT-05d §5)"
)
})?;
let to = lookup_assignment(inner, "to")
.and_then(parse_string_literal)
.ok_or_else(|| {
anyhow::anyhow!(
"QT-05d §6: `Transition` block in {source} has no literal-string \
`to:` (per QT-05d §5)"
)
})?;
let event = lookup_assignment(inner, "event").and_then(parse_string_literal);
let host = states.iter_mut().find(|s| s.id.as_deref() == Some(from.as_str()))
.ok_or_else(|| anyhow::anyhow!(
"QT-05d §6: `Transition` in {source} references unknown source state `{from}` (no matching `State {{ name: \"{from}\" }}`)"
))?;
host.transition.push(qt_scjson::Transition {
event,
target: vec![to],
..qt_scjson::Transition::default()
});
}
}
let mut other_attributes = serde_json::Map::new();
other_attributes.insert(
"_comment".to_string(),
Value::String(format!("QT-05d emit-scjson: {source}")),
);
let scxml = qt_scjson::Scxml {
state: states,
initial: initial.into_iter().collect(),
other_attributes,
..qt_scjson::Scxml::default()
};
Ok(Some(scxml))
}
fn iter_object_items<'a>(value: &'a UiAssignmentValue, expected: &str) -> Vec<&'a UiItem> {
match value {
UiAssignmentValue::Object { item } if item.type_name == expected => vec![item.as_ref()],
UiAssignmentValue::List { items } => items
.iter()
.filter_map(|v| match v {
UiAssignmentValue::Object { item } if item.type_name == expected => {
Some(item.as_ref())
}
_ => None,
})
.collect(),
_ => Vec::new(),
}
}
pub(crate) fn emit_scjson(input: &Path, out: Option<&Path>) -> Result<()> {
if input.is_dir() {
let out_dir = out.unwrap_or(input);
fs::create_dir_all(out_dir)
.with_context(|| format!("creating output dir {}", out_dir.display()))?;
for qml in qt08_collect_qml_files(input)? {
emit_scjson_one(&qml, &resolve_scjson_out_for(&qml, out_dir))?;
}
Ok(())
} else {
let out_path = match out {
Some(p) if p.is_dir() => resolve_scjson_out_for(input, p),
Some(p) => p.to_path_buf(),
None => resolve_scjson_out_for(input, input.parent().unwrap_or_else(|| Path::new("."))),
};
emit_scjson_one(input, &out_path)
}
}
fn resolve_scjson_out_for(qml: &Path, out_dir: &Path) -> PathBuf {
let stem = qml
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("untitled");
out_dir.join(format!("{stem}.scjson"))
}
fn emit_scjson_one(input: &Path, out_path: &Path) -> Result<()> {
let source =
fs::read_to_string(input).with_context(|| format!("reading {}", input.display()))?;
let module =
parse_module(&source, input).with_context(|| format!("parsing {}", input.display()))?;
let source_label = input.display().to_string();
let Some(scxml) = walk_qml_state_machine(&module.root, &source_label)? else {
return Ok(());
};
let json = serde_json::to_string_pretty(&scxml)
.with_context(|| format!("serialising scjson for {}", input.display()))?;
let mut json = json;
json.push('\n');
if let Some(parent) = out_path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)
.with_context(|| format!("creating output dir {}", parent.display()))?;
}
fs::write(out_path, json).with_context(|| format!("writing {}", out_path.display()))?;
Ok(())
}
pub const QT_EXTERNALS_VERSION: u32 = 1;
pub(crate) fn emit_externals(input: &Path, out: Option<&Path>) -> Result<()> {
if input.is_dir() {
let out_dir = out.unwrap_or(input);
fs::create_dir_all(out_dir)
.with_context(|| format!("creating output dir {}", out_dir.display()))?;
for qml in qt08_collect_qml_files(input)? {
emit_externals_one(&qml, &resolve_externals_out_for(&qml, out_dir))?;
}
Ok(())
} else {
let out_path = match out {
Some(p) if p.is_dir() => resolve_externals_out_for(input, p),
Some(p) => p.to_path_buf(),
None => {
resolve_externals_out_for(input, input.parent().unwrap_or_else(|| Path::new(".")))
}
};
emit_externals_one(input, &out_path)
}
}
fn resolve_externals_out_for(qml: &Path, out_dir: &Path) -> PathBuf {
let stem = qml
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("untitled");
out_dir.join(format!("{stem}_externals.rs"))
}
fn emit_externals_one(input: &Path, out_path: &Path) -> Result<()> {
let source =
fs::read_to_string(input).with_context(|| format!("reading {}", input.display()))?;
let mut module =
parse_module(&source, input).with_context(|| format!("parsing {}", input.display()))?;
attach_scjson_side_file(&mut module, input)?;
let Some(sm) = module.state_machine.as_ref() else {
return Ok(());
};
if sm.scripts.is_empty() {
return Ok(());
}
let stem = input
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("input has no usable file stem: {}", input.display()))?;
let rust = render_externals(&sm.id, sm, stem, &input.display().to_string());
if let Some(parent) = out_path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)
.with_context(|| format!("creating output dir {}", parent.display()))?;
}
fs::write(out_path, rust).with_context(|| format!("writing {}", out_path.display()))?;
Ok(())
}
pub fn render_externals(
sm_id: &str,
sm: &UiStateMachine,
qml_stem: &str,
qml_source: &str,
) -> String {
let mut out = String::new();
out.push_str("// SPDX-License-Identifier: MIT\n");
out.push_str("//\n");
out.push_str(&format!(
"// Generated by `rlvgl-creator qt emit-externals` from `{qml_source}`.\n"
));
out.push_str("// Hand-edit the method bodies below; regeneration\n");
out.push_str("// overwrites this file. See QT-05e §9.\n");
out.push_str("//\n");
out.push_str("// Emit-shape contract: docs/qt-support/05e-externals-stubs.md\n");
out.push_str("// Install path (QT-05e §7):\n");
out.push_str(&format!(
"// let (_, _, machine, _) = build_screen(bounds);\n// machine.borrow_mut().externals = Box::new({qml_stem}_externals::ScreenExternals::new());\n",
));
out.push('\n');
out.push_str("#![allow(dead_code)]\n");
out.push_str("#![allow(unused_variables)]\n");
out.push('\n');
out.push_str(&format!("use {sm_id}_gen::{{Externals, Machine}};\n\n"));
out.push_str(&format!(
"/// QT-05e per-file emit-shape version.\npub const QT_EXTERNALS_VERSION: u32 = {QT_EXTERNALS_VERSION};\n\n"
));
out.push_str("/// QT-05e §3 — `pub struct ScreenExternals`. v1 is\n");
out.push_str("/// stateless; the user adds fields by hand.\n");
out.push_str("pub struct ScreenExternals;\n\n");
out.push_str("impl ScreenExternals {\n pub fn new() -> Self {\n Self\n }\n}\n\n");
out.push_str("impl Default for ScreenExternals {\n fn default() -> Self {\n Self::new()\n }\n}\n\n");
out.push_str(&format!("impl Externals for ScreenExternals {{\n"));
let mut first = true;
for script in &sm.scripts {
if !first {
out.push('\n');
}
first = false;
let origin = render_script_origin(&script.origin);
out.push_str(&format!(
" fn {name}(&mut self, m: &mut Machine) {{\n \
// QT-05e externals-stub: {name} from {origin}\n \
// TODO — fill in side-effect code.\n \
let _ = m;\n \
}}\n",
name = script.name,
origin = origin,
));
}
out.push_str("}\n");
format!("{}\n", out.trim_end())
}
fn render_script_origin(origin: &UiScriptOrigin) -> String {
match origin {
UiScriptOrigin::Transition { index, from, to } => format!(
"Transition {{ index: {index}, from: \"{from}\", to: {} }}",
to.as_deref()
.map(|s| format!("Some(\"{s}\")"))
.unwrap_or_else(|| "None".to_string())
),
UiScriptOrigin::OnEntry { state } => format!("OnEntry {{ state: \"{state}\" }}"),
UiScriptOrigin::OnExit { state } => format!("OnExit {{ state: \"{state}\" }}"),
}
}
#[derive(Debug, Default)]
pub struct TokenSet {
pub colors: std::collections::BTreeMap<String, String>,
pub spacing: std::collections::BTreeMap<String, i64>,
pub radii: std::collections::BTreeMap<String, i64>,
pub fonts: std::collections::BTreeMap<String, String>,
pub dark_colors: std::collections::BTreeMap<String, String>,
}
impl TokenSet {
fn is_empty(&self) -> bool {
self.colors.is_empty()
&& self.spacing.is_empty()
&& self.radii.is_empty()
&& self.fonts.is_empty()
&& self.dark_colors.is_empty()
}
}
pub fn walk_theme_module(item: &UiItem) -> Option<TokenSet> {
let mut ts = TokenSet::default();
for prop in &item.properties {
let Some(default) = prop.default_value.as_deref() else {
continue;
};
match prop.ty.as_str() {
"color" => {
if let Some(hex) = parse_hex_color_lit(default) {
if let Some(stem) = prop.name.strip_suffix("_dark") {
ts.dark_colors.insert(stem.to_string(), hex);
} else {
ts.colors.insert(prop.name.clone(), hex);
}
}
}
"int" => {
if let Some(v) = parse_int_literal_i64(default) {
if let Some(key) = prop.name.strip_prefix("spacing_") {
ts.spacing.insert(key.to_string(), v);
} else if let Some(key) = prop.name.strip_prefix("radius_") {
ts.radii.insert(key.to_string(), v);
}
}
}
"string" => {
if let Some(s) = parse_string_literal(default) {
if let Some(key) = prop.name.strip_prefix("font_") {
ts.fonts.insert(key.to_string(), s);
}
}
}
_ => {}
}
}
if ts.is_empty() { None } else { Some(ts) }
}
fn parse_hex_color_lit(expr: &str) -> Option<String> {
let s = parse_string_literal(expr)?;
let bytes = s.as_bytes();
if bytes.is_empty() || bytes[0] != b'#' {
return None;
}
let hex = &bytes[1..];
if !matches!(hex.len(), 3 | 4 | 6 | 8) {
return None;
}
if !hex.iter().all(|b| b.is_ascii_hexdigit()) {
return None;
}
Some(s)
}
fn parse_int_literal_i64(expr: &str) -> Option<i64> {
expr.trim().parse::<i64>().ok()
}
pub fn render_tokens_yaml(theme: &TokenSet, qml_source: &str) -> String {
let mut out = String::new();
out.push_str("# Auto-generated from Qt theme by rlvgl-creator (QT-06)\n");
out.push_str(&format!("# QT-06 theme: {qml_source}\n"));
out.push_str("version: 1\n");
out.push_str("colors:\n");
for (k, v) in &theme.colors {
out.push_str(&format!(" {k}: \"{v}\"\n"));
}
out.push_str("spacing:\n");
for (k, v) in &theme.spacing {
out.push_str(&format!(" {k}: {v}\n"));
}
out.push_str("radii:\n");
for (k, v) in &theme.radii {
out.push_str(&format!(" {k}: {v}\n"));
}
out.push_str("fonts:\n");
for (k, v) in &theme.fonts {
out.push_str(&format!(" {k}: \"{v}\"\n"));
}
if !theme.dark_colors.is_empty() {
out.push_str("modes:\n dark:\n colors:\n");
for (k, v) in &theme.dark_colors {
out.push_str(&format!(" {k}: \"{v}\"\n"));
}
}
out
}
pub(crate) fn emit_tokens(input: &Path, out: Option<&Path>) -> Result<()> {
if input.is_dir() {
let out_dir = out.unwrap_or(input);
fs::create_dir_all(out_dir)
.with_context(|| format!("creating output dir {}", out_dir.display()))?;
for qml in qt08_collect_qml_files(input)? {
emit_tokens_one(&qml, &resolve_tokens_out_for(&qml, out_dir))?;
}
Ok(())
} else {
let out_path = match out {
Some(p) if p.is_dir() => resolve_tokens_out_for(input, p),
Some(p) => p.to_path_buf(),
None => resolve_tokens_out_for(input, input.parent().unwrap_or_else(|| Path::new("."))),
};
emit_tokens_one(input, &out_path)
}
}
fn resolve_tokens_out_for(qml: &Path, out_dir: &Path) -> PathBuf {
let stem = qml
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("untitled");
out_dir.join(format!("{stem}.tokens.yaml"))
}
fn emit_tokens_one(input: &Path, out_path: &Path) -> Result<()> {
let source =
fs::read_to_string(input).with_context(|| format!("reading {}", input.display()))?;
let module =
parse_module(&source, input).with_context(|| format!("parsing {}", input.display()))?;
let Some(theme) = walk_theme_module(&module.root) else {
return Ok(());
};
let yaml = render_tokens_yaml(&theme, &input.display().to_string());
if let Some(parent) = out_path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)
.with_context(|| format!("creating output dir {}", parent.display()))?;
}
fs::write(out_path, yaml).with_context(|| format!("writing {}", out_path.display()))?;
Ok(())
}
#[derive(Debug, Default)]
pub struct AssetInventory {
pub images: std::collections::BTreeSet<String>,
pub fonts: std::collections::BTreeSet<String>,
}
impl AssetInventory {
fn is_empty(&self) -> bool {
self.images.is_empty() && self.fonts.is_empty()
}
}
pub fn walk_asset_refs(item: &UiItem) -> AssetInventory {
let mut inv = AssetInventory::default();
visit_for_assets(item, &mut inv);
inv
}
fn visit_for_assets(item: &UiItem, inv: &mut AssetInventory) {
let stripped_type = item.type_name.rsplit('.').next().unwrap_or(&item.type_name);
if matches!(stripped_type, "Image" | "BorderImage" | "AnimatedImage")
&& let Some(raw_source) = lookup_assignment(item, "source")
{
if let Some(s) = parse_string_literal(raw_source) {
inv.images.insert(strip_qrc_prefix(&s).to_string());
} else {
for s in extract_asset_literals(raw_source) {
inv.images.insert(strip_qrc_prefix(&s).to_string());
}
}
}
if stripped_type == "Font"
&& let Some(raw) = lookup_assignment(item, "family")
&& let Some(s) = parse_string_literal(raw)
{
inv.fonts.insert(s);
}
if let Some(raw) = lookup_assignment(item, "font.family")
&& let Some(s) = parse_string_literal(raw)
{
inv.fonts.insert(s);
}
for asn in &item.assignments {
if asn.target == "font"
&& let UiAssignmentValue::Object { item: nested } = &asn.value
&& nested
.type_name
.rsplit('.')
.next()
.unwrap_or(&nested.type_name)
== "Font"
&& let Some(raw) = lookup_assignment(nested, "family")
&& let Some(s) = parse_string_literal(raw)
{
inv.fonts.insert(s);
}
}
for child in &item.children {
visit_for_assets(child, inv);
}
}
fn pick_image_source(item: &UiItem) -> Option<AssetRef> {
let raw = lookup_assignment(item, "source")?;
let path = parse_string_literal(raw)
.filter(|s| {
let l = s.to_ascii_lowercase();
l.starts_with("qrc:") || is_image_path(&l)
})
.or_else(|| extract_asset_literals(raw).into_iter().next())?;
let stripped = strip_qrc_prefix(&path).to_string();
Some(AssetRef {
symbol: asset_symbol(&stripped),
path: stripped,
})
}
fn is_image_path(lower: &str) -> bool {
const IMAGE_EXTS: [&str; 7] = [".png", ".jpg", ".jpeg", ".gif", ".svg", ".bmp", ".webp"];
IMAGE_EXTS.iter().any(|e| lower.ends_with(e))
}
fn asset_symbol(path: &str) -> String {
let stem = path
.rsplit('/')
.next()
.unwrap_or(path)
.rsplit_once('.')
.map(|(s, _)| s)
.unwrap_or(path);
let mut sym = String::from("IMG_");
for ch in stem.chars() {
if ch.is_ascii_alphanumeric() {
sym.extend(ch.to_uppercase());
} else {
sym.push('_');
}
}
sym
}
fn extract_asset_literals(expr: &str) -> Vec<String> {
let bytes = expr.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < bytes.len() {
let q = bytes[i];
if q == b'"' || q == b'\'' {
let start = i + 1;
let mut j = start;
while j < bytes.len() && bytes[j] != q {
if bytes[j] == b'\\' && j + 1 < bytes.len() {
j += 2;
continue;
}
j += 1;
}
if j <= bytes.len() {
let lit = &expr[start..j.min(expr.len())];
let lower = lit.to_ascii_lowercase();
if lower.starts_with("qrc:") || is_image_path(&lower) {
out.push(lit.to_string());
}
}
i = j + 1;
} else {
i += 1;
}
}
out
}
fn strip_qrc_prefix(path: &str) -> &str {
if let Some(rest) = path.strip_prefix("qrc:///") {
rest
} else if let Some(rest) = path.strip_prefix("qrc:/") {
rest
} else {
path
}
}
pub fn render_assets_yaml(inv: &AssetInventory, qml_source: &str) -> String {
let mut out = String::new();
out.push_str("# Auto-generated from Qt project by rlvgl-creator (QT-07)\n");
out.push_str(&format!("# QT-07 assets: {qml_source}\n"));
out.push_str("version: 1\n");
out.push_str("images:\n");
for path in &inv.images {
out.push_str(&format!(" - {}\n", quote_yaml_scalar(path)));
}
out.push_str("fonts:\n");
for family in &inv.fonts {
out.push_str(&format!(" - {}\n", quote_yaml_scalar(family)));
}
out
}
fn quote_yaml_scalar(s: &str) -> String {
let needs_quote = s.is_empty()
|| s.chars().any(|c| {
matches!(
c,
' ' | '\t' | ':' | '#' | '"' | '\'' | '[' | ']' | '{' | '}' | ','
)
});
if needs_quote {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
} else {
s.to_string()
}
}
pub(crate) fn list_assets(input: &Path, out: Option<&Path>) -> Result<()> {
if input.is_dir() {
let out_dir = out.unwrap_or(input);
fs::create_dir_all(out_dir)
.with_context(|| format!("creating output dir {}", out_dir.display()))?;
for qml in qt08_collect_qml_files(input)? {
list_assets_one(&qml, &resolve_assets_out_for(&qml, out_dir))?;
}
Ok(())
} else {
let out_path = match out {
Some(p) if p.is_dir() => resolve_assets_out_for(input, p),
Some(p) => p.to_path_buf(),
None => resolve_assets_out_for(input, input.parent().unwrap_or_else(|| Path::new("."))),
};
list_assets_one(input, &out_path)
}
}
fn resolve_assets_out_for(qml: &Path, out_dir: &Path) -> PathBuf {
let stem = qml
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("untitled");
out_dir.join(format!("{stem}.assets.yaml"))
}
fn list_assets_one(input: &Path, out_path: &Path) -> Result<()> {
let source =
fs::read_to_string(input).with_context(|| format!("reading {}", input.display()))?;
let module =
parse_module(&source, input).with_context(|| format!("parsing {}", input.display()))?;
let inv = walk_asset_refs(&module.root);
if inv.is_empty() {
return Ok(());
}
let yaml = render_assets_yaml(&inv, &input.display().to_string());
if let Some(parent) = out_path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)
.with_context(|| format!("creating output dir {}", parent.display()))?;
}
fs::write(out_path, yaml).with_context(|| format!("writing {}", out_path.display()))?;
Ok(())
}
#[derive(Debug, Clone, PartialEq)]
pub struct QmldirType {
pub name: String,
pub version: Option<String>,
pub file: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct QmldirImport {
pub module: String,
pub version: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct QmldirPlugin {
pub name: String,
pub path: Option<String>,
}
#[derive(Debug, Default, PartialEq)]
pub struct QmldirManifest {
pub module: Option<String>,
pub types: Vec<QmldirType>,
pub singletons: Vec<QmldirType>,
pub internals: Vec<QmldirType>,
pub imports: Vec<QmldirImport>,
pub depends: Vec<QmldirImport>,
pub plugins: Vec<QmldirPlugin>,
pub other: Vec<String>,
}
pub fn parse_qmldir(content: &str) -> QmldirManifest {
let mut m = QmldirManifest::default();
for raw_line in content.lines() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.is_empty() {
continue;
}
match tokens.as_slice() {
["module", name] => {
m.module = Some(name.to_string());
}
["singleton", name, version, file] => {
m.singletons.push(QmldirType {
name: name.to_string(),
version: Some(version.to_string()),
file: file.to_string(),
});
}
["internal", name, file] => {
m.internals.push(QmldirType {
name: name.to_string(),
version: None,
file: file.to_string(),
});
}
["import", module] => {
m.imports.push(QmldirImport {
module: module.to_string(),
version: None,
});
}
["import", module, version] => {
m.imports.push(QmldirImport {
module: module.to_string(),
version: Some(version.to_string()),
});
}
["depends", module] => {
m.depends.push(QmldirImport {
module: module.to_string(),
version: None,
});
}
["depends", module, version] => {
m.depends.push(QmldirImport {
module: module.to_string(),
version: Some(version.to_string()),
});
}
["plugin", name] => {
m.plugins.push(QmldirPlugin {
name: name.to_string(),
path: None,
});
}
["plugin", name, path] => {
m.plugins.push(QmldirPlugin {
name: name.to_string(),
path: Some(path.to_string()),
});
}
[name, version, file] if file.ends_with(".qml") => {
m.types.push(QmldirType {
name: name.to_string(),
version: Some(version.to_string()),
file: file.to_string(),
});
}
_ => {
m.other.push(line.to_string());
}
}
}
m
}
pub fn render_qmldir_yaml(manifest: &QmldirManifest, source: &str) -> String {
let mut out = String::new();
out.push_str(&format!("# QT-08b qmldir: {source}\n"));
out.push_str("version: 1\n");
match &manifest.module {
Some(name) => out.push_str(&format!("module: {name}\n")),
None => out.push_str("module: null\n"),
}
out.push_str("types:\n");
for t in &manifest.types {
out.push_str(&format!(" - {}\n", render_qmldir_type(t)));
}
out.push_str("singletons:\n");
for t in &manifest.singletons {
out.push_str(&format!(" - {}\n", render_qmldir_type(t)));
}
out.push_str("internals:\n");
for t in &manifest.internals {
out.push_str(&format!(" - {}\n", render_qmldir_type(t)));
}
out.push_str("imports:\n");
for i in &manifest.imports {
out.push_str(&format!(" - {}\n", render_qmldir_import(i)));
}
out.push_str("depends:\n");
for d in &manifest.depends {
out.push_str(&format!(" - {}\n", render_qmldir_import(d)));
}
out.push_str("plugins:\n");
for p in &manifest.plugins {
let path = p
.path
.as_deref()
.map(|s| format!("\"{s}\""))
.unwrap_or_else(|| "null".to_string());
out.push_str(&format!(" - {{ name: {}, path: {path} }}\n", p.name));
}
out.push_str("other:\n");
for s in &manifest.other {
out.push_str(&format!(" - {}\n", quote_yaml_scalar(s)));
}
out
}
fn render_qmldir_type(t: &QmldirType) -> String {
let version = t
.version
.as_deref()
.map(|s| format!("\"{s}\""))
.unwrap_or_else(|| "null".to_string());
format!(
"{{ name: {}, version: {version}, file: {} }}",
t.name, t.file
)
}
fn render_qmldir_import(i: &QmldirImport) -> String {
let version = i
.version
.as_deref()
.map(|s| format!("\"{s}\""))
.unwrap_or_else(|| "null".to_string());
format!("{{ module: {}, version: {version} }}", i.module)
}
pub(crate) fn list_qmldir(input: &Path, out: Option<&Path>) -> Result<()> {
let (qmldir_path, dirname) = if input.is_dir() {
let p = input.join("qmldir");
let dn = input
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("untitled")
.to_string();
(p, dn)
} else {
let dn = input
.parent()
.and_then(|p| p.file_name())
.and_then(|s| s.to_str())
.unwrap_or("untitled")
.to_string();
(input.to_path_buf(), dn)
};
if !qmldir_path.exists() {
bail!(
"QT-08b §7: expected qmldir file at {} (not found)",
qmldir_path.display()
);
}
let content = fs::read_to_string(&qmldir_path)
.with_context(|| format!("reading {}", qmldir_path.display()))?;
let manifest = parse_qmldir(&content);
let source_label = qmldir_path.display().to_string();
let yaml = render_qmldir_yaml(&manifest, &source_label);
let out_path = match out {
Some(p) if p.is_dir() => p.join(format!("{dirname}.qmldir.yaml")),
Some(p) => p.to_path_buf(),
None => qmldir_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(format!("{dirname}.qmldir.yaml")),
};
if let Some(parent) = out_path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)
.with_context(|| format!("creating output dir {}", parent.display()))?;
}
fs::write(&out_path, yaml).with_context(|| format!("writing {}", out_path.display()))?;
Ok(())
}
#[derive(Debug, Clone, PartialEq)]
pub struct QrcFile {
pub path: String,
pub alias: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct QrcResource {
pub prefix: Option<String>,
pub lang: Option<String>,
pub files: Vec<QrcFile>,
}
#[derive(Debug, Default, PartialEq)]
pub struct QrcManifest {
pub version: Option<String>,
pub resources: Vec<QrcResource>,
}
pub fn parse_qrc(content: &str) -> Result<QrcManifest> {
let mut parser = QrcParser::new(content);
parser.parse_document()
}
struct QrcParser<'a> {
src: &'a str,
pos: usize,
}
impl<'a> QrcParser<'a> {
fn new(src: &'a str) -> Self {
Self { src, pos: 0 }
}
fn parse_document(&mut self) -> Result<QrcManifest> {
loop {
self.skip_ws();
if self.starts_with("<!--") {
self.skip_comment()?;
continue;
}
if self.starts_with("<!DOCTYPE") {
self.skip_doctype()?;
continue;
}
if self.starts_with("<?") {
self.skip_pi()?;
continue;
}
break;
}
if !self.starts_with("<RCC") {
bail!(
"QT-08c §5: expected `<RCC>` root element at byte {}",
self.pos
);
}
self.expect("<RCC")?;
let attrs = self.parse_attrs()?;
self.expect(">")?;
let mut manifest = QrcManifest::default();
if let Some(v) = attrs.iter().find(|(k, _)| k == "version") {
manifest.version = Some(v.1.clone());
}
loop {
self.skip_ws_and_comments()?;
if self.starts_with("</RCC>") {
self.expect("</RCC>")?;
self.skip_ws_and_comments()?;
if self.pos < self.src.len() {
bail!(
"QT-08c §5: unexpected trailing content after </RCC> at byte {}",
self.pos
);
}
return Ok(manifest);
}
if self.starts_with("<qresource") {
let res = self.parse_qresource()?;
manifest.resources.push(res);
continue;
}
bail!(
"QT-08c §5: unrecognised element under <RCC> at byte {} — \
only <qresource> is allowed (per §5 strictness rule)",
self.pos
);
}
}
fn parse_qresource(&mut self) -> Result<QrcResource> {
self.expect("<qresource")?;
let attrs = self.parse_attrs()?;
self.expect(">")?;
let mut res = QrcResource {
prefix: attrs
.iter()
.find(|(k, _)| k == "prefix")
.map(|(_, v)| v.clone()),
lang: attrs
.iter()
.find(|(k, _)| k == "lang")
.map(|(_, v)| v.clone()),
files: Vec::new(),
};
loop {
self.skip_ws_and_comments()?;
if self.starts_with("</qresource>") {
self.expect("</qresource>")?;
return Ok(res);
}
if self.starts_with("<file") {
let file = self.parse_file()?;
res.files.push(file);
continue;
}
bail!(
"QT-08c §5: unrecognised element under <qresource> at byte {} — \
only <file> is allowed",
self.pos
);
}
}
fn parse_file(&mut self) -> Result<QrcFile> {
self.expect("<file")?;
let attrs = self.parse_attrs()?;
self.expect(">")?;
let start = self.pos;
let close_idx = self.src[start..]
.find("</file>")
.ok_or_else(|| anyhow::anyhow!("QT-08c §5: unterminated <file> at byte {start}"))?;
let path = self.src[start..start + close_idx].trim().to_string();
self.pos = start + close_idx;
self.expect("</file>")?;
let alias = attrs
.iter()
.find(|(k, _)| k == "alias")
.map(|(_, v)| v.clone());
Ok(QrcFile { path, alias })
}
fn skip_ws(&mut self) {
while let Some(b) = self.src.as_bytes().get(self.pos) {
if matches!(b, b' ' | b'\t' | b'\n' | b'\r') {
self.pos += 1;
} else {
break;
}
}
}
fn skip_ws_and_comments(&mut self) -> Result<()> {
loop {
self.skip_ws();
if self.starts_with("<!--") {
self.skip_comment()?;
continue;
}
return Ok(());
}
}
fn skip_comment(&mut self) -> Result<()> {
self.expect("<!--")?;
let start = self.pos;
let end = self.src[start..]
.find("-->")
.ok_or_else(|| anyhow::anyhow!("QT-08c: unterminated XML comment at byte {start}"))?;
self.pos = start + end + "-->".len();
Ok(())
}
fn skip_doctype(&mut self) -> Result<()> {
let start = self.pos;
let end = self.src[start..]
.find('>')
.ok_or_else(|| anyhow::anyhow!("QT-08c: unterminated DOCTYPE at byte {start}"))?;
self.pos = start + end + 1;
Ok(())
}
fn skip_pi(&mut self) -> Result<()> {
let start = self.pos;
let end = self.src[start..]
.find("?>")
.ok_or_else(|| anyhow::anyhow!("QT-08c: unterminated XML PI at byte {start}"))?;
self.pos = start + end + "?>".len();
Ok(())
}
fn starts_with(&self, prefix: &str) -> bool {
self.src[self.pos..].starts_with(prefix)
}
fn expect(&mut self, lit: &str) -> Result<()> {
if self.starts_with(lit) {
self.pos += lit.len();
Ok(())
} else {
bail!(
"QT-08c §5: expected `{lit}` at byte {} (saw `{}`)",
self.pos,
&self.src[self.pos..self.pos.saturating_add(8.min(self.src.len() - self.pos))]
)
}
}
fn parse_attrs(&mut self) -> Result<Vec<(String, String)>> {
let mut out = Vec::new();
loop {
self.skip_ws();
let bytes = self.src.as_bytes();
let Some(&b) = bytes.get(self.pos) else {
bail!("QT-08c §5: unexpected end of input in attribute list");
};
if b == b'>' || (b == b'/' && bytes.get(self.pos + 1) == Some(&b'>')) {
return Ok(out);
}
let name_start = self.pos;
while let Some(&c) = self.src.as_bytes().get(self.pos) {
if matches!(c, b' ' | b'\t' | b'\n' | b'\r' | b'=' | b'/' | b'>') {
break;
}
self.pos += 1;
}
if self.pos == name_start {
bail!("QT-08c §5: expected attribute name at byte {}", self.pos);
}
let name = self.src[name_start..self.pos].to_string();
self.skip_ws();
self.expect("=")?;
self.skip_ws();
let quote = self
.src
.as_bytes()
.get(self.pos)
.copied()
.ok_or_else(|| anyhow::anyhow!("QT-08c: expected attribute quote"))?;
if quote != b'"' && quote != b'\'' {
bail!(
"QT-08c §5: attribute value must be quoted at byte {}",
self.pos
);
}
self.pos += 1;
let val_start = self.pos;
let close_byte = quote;
while let Some(&c) = self.src.as_bytes().get(self.pos) {
if c == close_byte {
break;
}
self.pos += 1;
}
let value = self.src[val_start..self.pos].to_string();
self.expect(if quote == b'"' { "\"" } else { "'" })?;
out.push((name, value));
}
}
}
pub fn render_qrc_yaml(manifest: &QrcManifest, source: &str) -> String {
let mut out = String::new();
out.push_str(&format!("# QT-08c qrc: {source}\n"));
out.push_str("version: 1\n");
match &manifest.version {
Some(v) => out.push_str(&format!("rcc_version: \"{v}\"\n")),
None => out.push_str("rcc_version: null\n"),
}
out.push_str("resources:\n");
for r in &manifest.resources {
let prefix = r
.prefix
.as_deref()
.map(|s| format!("\"{s}\""))
.unwrap_or_else(|| "null".to_string());
let lang = r
.lang
.as_deref()
.map(|s| format!("\"{s}\""))
.unwrap_or_else(|| "null".to_string());
out.push_str(&format!(" - prefix: {prefix}\n"));
out.push_str(&format!(" lang: {lang}\n"));
out.push_str(" files:\n");
for f in &r.files {
let alias = f
.alias
.as_deref()
.map(|s| s.to_string())
.unwrap_or_else(|| "null".to_string());
out.push_str(&format!(" - {{ path: {}, alias: {alias} }}\n", f.path));
}
}
out
}
pub(crate) fn list_qrc(input: &Path, out: Option<&Path>) -> Result<()> {
if input.is_dir() {
let out_dir = out.unwrap_or(input);
fs::create_dir_all(out_dir)
.with_context(|| format!("creating output dir {}", out_dir.display()))?;
let read =
fs::read_dir(input).with_context(|| format!("reading dir {}", input.display()))?;
let mut qrc_files: Vec<PathBuf> = Vec::new();
for entry in read {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("qrc") {
qrc_files.push(path);
}
}
qrc_files.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
for qrc in qrc_files {
list_qrc_one(&qrc, &resolve_qrc_out_for(&qrc, out_dir))?;
}
Ok(())
} else {
if !input.exists() {
bail!(
"QT-08c §7: expected .qrc file at {} (not found)",
input.display()
);
}
let out_path = match out {
Some(p) if p.is_dir() => resolve_qrc_out_for(input, p),
Some(p) => p.to_path_buf(),
None => resolve_qrc_out_for(input, input.parent().unwrap_or_else(|| Path::new("."))),
};
list_qrc_one(input, &out_path)
}
}
fn resolve_qrc_out_for(qrc: &Path, out_dir: &Path) -> PathBuf {
let stem = qrc
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("untitled");
out_dir.join(format!("{stem}.qrc.yaml"))
}
fn list_qrc_one(input: &Path, out_path: &Path) -> Result<()> {
let content =
fs::read_to_string(input).with_context(|| format!("reading {}", input.display()))?;
let manifest = parse_qrc(&content)
.with_context(|| format!("parsing {} per QT-08c §5", input.display()))?;
let yaml = render_qrc_yaml(&manifest, &input.display().to_string());
if let Some(parent) = out_path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)
.with_context(|| format!("creating output dir {}", parent.display()))?;
}
fs::write(out_path, yaml).with_context(|| format!("writing {}", out_path.display()))?;
Ok(())
}
fn find_scjson_side_file(qml_path: &Path) -> Option<PathBuf> {
let stem = qml_path.file_stem()?.to_str()?;
let parent = qml_path.parent().unwrap_or_else(|| Path::new("."));
let candidate = parent.join(format!("{stem}.scjson"));
if fs::metadata(&candidate).is_ok() {
Some(candidate)
} else {
None
}
}
fn attach_scjson_side_file(module: &mut UiModule, qml_path: &Path) -> Result<()> {
let Some(scjson_path) = find_scjson_side_file(qml_path) else {
return Ok(());
};
let raw = fs::read_to_string(&scjson_path)
.with_context(|| format!("reading scjson side-file {}", scjson_path.display()))?;
if raw.trim().is_empty() {
bail!(
"scjson side-file is empty: {} (per QT-05a §7)",
scjson_path.display()
);
}
let scxml: qt_scjson::Scxml = serde_json::from_str(&raw).with_context(|| {
format!(
"parsing scjson side-file {} (per QT-05a §7)",
scjson_path.display()
)
})?;
if scxml.state.is_empty() && scxml.datamodel.is_empty() && scxml.initial.is_empty() {
bail!(
"scjson side-file {} has neither <state> nor <datamodel> nor an initial state — \
it does not look like a state machine (per QT-05a §7 / QT-05 §5)",
scjson_path.display()
);
}
let id = derive_state_machine_id(&scxml, qml_path)?;
let source = scjson_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
module.state_machine = Some(walk_scxml_into_ui_state_machine(&scxml, id, source)?);
Ok(())
}
fn derive_state_machine_id(scxml: &qt_scjson::Scxml, qml_path: &Path) -> Result<String> {
if let Some(name) = scxml.name.as_deref()
&& !name.trim().is_empty()
{
return Ok(snake_case_for_sm(name));
}
let stem = qml_path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| {
anyhow::anyhow!("QML path has no usable file stem: {}", qml_path.display())
})?;
Ok(snake_case_for_sm(stem))
}
fn snake_case_for_sm(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut prev_lower = false;
for ch in input.chars() {
if ch.is_ascii_alphanumeric() {
if ch.is_ascii_uppercase() {
if prev_lower {
out.push('_');
}
for low in ch.to_lowercase() {
out.push(low);
}
prev_lower = false;
} else {
out.push(ch);
prev_lower = ch.is_ascii_lowercase() || ch.is_ascii_digit();
}
} else if !out.is_empty() && !out.ends_with('_') {
out.push('_');
prev_lower = false;
}
}
while out.ends_with('_') {
out.pop();
}
out
}
fn walk_scxml_into_ui_state_machine(
scxml: &qt_scjson::Scxml,
id: String,
source: String,
) -> Result<UiStateMachine> {
let initial = scxml.initial.iter().find(|s| !s.is_empty()).cloned();
let mut states: Vec<UiState> = Vec::new();
let mut transitions: Vec<UiTransition> = Vec::new();
let mut scripts: Vec<UiScript> = Vec::new();
let mut anon_state_counter: u32 = 0;
let mut transition_index: u32 = 0;
walk_states(
&scxml.state,
&mut states,
&mut transitions,
&mut scripts,
&mut anon_state_counter,
&mut transition_index,
)?;
let mut datamodel: Vec<UiDmField> = Vec::new();
for dm in &scxml.datamodel {
for d in &dm.data {
let initial_value = d.expr.as_deref().and_then(|expr| expr.trim().parse().ok());
datamodel.push(UiDmField {
id: d.id.clone(),
initial: initial_value,
});
}
}
for sc in &scxml.script {
scripts.push(UiScript {
name: extract_script_name(sc, &mut 0_u32, "root_script"),
origin: UiScriptOrigin::OnEntry {
state: "_root".to_string(),
},
});
}
Ok(UiStateMachine {
id,
source,
initial,
states,
transitions,
datamodel,
scripts,
})
}
fn walk_states(
src: &[qt_scjson::State],
states: &mut Vec<UiState>,
transitions: &mut Vec<UiTransition>,
scripts: &mut Vec<UiScript>,
anon_state_counter: &mut u32,
transition_index: &mut u32,
) -> Result<()> {
for s in src {
let state_id = s.id.clone().unwrap_or_else(|| {
let n = *anon_state_counter;
*anon_state_counter += 1;
format!("_anon_{n}")
});
let on_entry = lower_action_block(
&s.onentry,
scripts,
UiScriptOrigin::OnEntry {
state: state_id.clone(),
},
);
let on_exit = lower_action_block_exit(
&s.onexit,
scripts,
UiScriptOrigin::OnExit {
state: state_id.clone(),
},
);
states.push(UiState {
id: state_id.clone(),
on_entry,
on_exit,
});
for t in &s.transition {
let idx = *transition_index;
*transition_index += 1;
let target = t.target.first().cloned();
let actions = lower_transition_actions(
t,
scripts,
UiScriptOrigin::Transition {
index: idx,
from: state_id.clone(),
to: target.clone(),
},
);
transitions.push(UiTransition {
source: state_id.clone(),
event: t.event.clone(),
target,
cond: t.cond.clone(),
actions,
});
}
if !s.state.is_empty() {
walk_states(
&s.state,
states,
transitions,
scripts,
anon_state_counter,
transition_index,
)?;
}
}
Ok(())
}
fn lower_action_block(
onentry: &[qt_scjson::Onentry],
scripts: &mut Vec<UiScript>,
origin: UiScriptOrigin,
) -> Vec<UiAction> {
let mut out = Vec::new();
let mut anon = 0_u32;
for blk in onentry {
for a in &blk.assign {
out.push(UiAction::Assign {
location: a.location.clone(),
expr: a.expr.clone(),
});
}
for r in &blk.raise_value {
out.push(UiAction::Raise {
event: r.event.clone(),
});
}
for sc in &blk.script {
let name = extract_script_name(sc, &mut anon, "onentry");
scripts.push(UiScript {
name: name.clone(),
origin: origin.clone(),
});
out.push(UiAction::Script { name });
}
}
out
}
fn lower_action_block_exit(
onexit: &[qt_scjson::Onexit],
scripts: &mut Vec<UiScript>,
origin: UiScriptOrigin,
) -> Vec<UiAction> {
let mut out = Vec::new();
let mut anon = 0_u32;
for blk in onexit {
for a in &blk.assign {
out.push(UiAction::Assign {
location: a.location.clone(),
expr: a.expr.clone(),
});
}
for r in &blk.raise_value {
out.push(UiAction::Raise {
event: r.event.clone(),
});
}
for sc in &blk.script {
let name = extract_script_name(sc, &mut anon, "onexit");
scripts.push(UiScript {
name: name.clone(),
origin: origin.clone(),
});
out.push(UiAction::Script { name });
}
}
out
}
fn lower_transition_actions(
t: &qt_scjson::Transition,
scripts: &mut Vec<UiScript>,
origin: UiScriptOrigin,
) -> Vec<UiAction> {
let mut out = Vec::new();
let mut anon = 0_u32;
for a in &t.assign {
out.push(UiAction::Assign {
location: a.location.clone(),
expr: a.expr.clone(),
});
}
for r in &t.raise_value {
out.push(UiAction::Raise {
event: r.event.clone(),
});
}
for sc in &t.script {
let name = extract_script_name(sc, &mut anon, "trans");
scripts.push(UiScript {
name: name.clone(),
origin: origin.clone(),
});
out.push(UiAction::Script { name });
}
out
}
fn extract_script_name(sc: &qt_scjson::Script, anon: &mut u32, tag: &str) -> String {
if let Some(v) = sc.other_attributes.get("name")
&& let Some(s) = v.as_str()
&& !s.is_empty()
{
return s.to_string();
}
let n = *anon;
*anon += 1;
format!("script_{tag}_{n}")
}
pub(crate) fn check(input: &Path) -> Result<()> {
let source =
fs::read_to_string(input).with_context(|| format!("reading {}", input.display()))?;
let mut module =
parse_module(&source, input).with_context(|| format!("parsing {}", input.display()))?;
attach_scjson_side_file(&mut module, input)?;
Ok(())
}
pub(crate) fn schema(out: Option<&Path>) -> Result<()> {
let json = render_schema()?;
if let Some(path) = out {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)
.with_context(|| format!("creating output dir {}", parent.display()))?;
}
fs::write(path, json).with_context(|| format!("writing {}", path.display()))?;
} else {
println!("{json}");
}
Ok(())
}
pub fn render_schema() -> Result<String> {
let raw = schemars::schema_for!(UiModule);
let raw_value = serde_json::to_value(raw)?;
let decorated = decorate_schema(raw_value);
let json = serde_json::to_string_pretty(&decorated)?;
Ok(json)
}
fn decorate_schema(raw: serde_json::Value) -> serde_json::Value {
let raw_obj = match raw {
serde_json::Value::Object(obj) => obj,
other => return other,
};
let mut head = serde_json::Map::new();
if let Some(s) = raw_obj.get("$schema") {
head.insert("$schema".to_string(), s.clone());
}
head.insert(
"$id".to_string(),
serde_json::Value::String(QT_IR_SCHEMA_ID.to_string()),
);
head.insert(
"$comment".to_string(),
serde_json::Value::String(QT_IR_SCHEMA_COMMENT.to_string()),
);
for (k, v) in raw_obj.into_iter() {
if k == "$schema" {
continue;
}
head.insert(k, v);
}
serde_json::Value::Object(head)
}
pub const QT_EMIT_VERSION_DATA: u32 = 1;
pub const QT_EMIT_VERSION_RLVGL: u32 = 23;
pub const QT_FAMILY_STRICT_VERSION: u32 = 2;
#[deprecated(note = "use QT_EMIT_VERSION_DATA (the rlvgl target uses QT_EMIT_VERSION_RLVGL)")]
pub const QT_EMIT_VERSION: u32 = QT_EMIT_VERSION_DATA;
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum EmitTarget {
Data,
Rlvgl,
}
pub(crate) fn emit(
input: &Path,
out: &Path,
target: EmitTarget,
scxml_context: Option<String>,
) -> Result<()> {
fs::create_dir_all(out).with_context(|| format!("creating output dir {}", out.display()))?;
let ctx = scxml_context
.as_deref()
.map(parse_scxml_context)
.transpose()?;
if input.is_dir() {
for qml in qt08_collect_qml_files(input)? {
emit_one_file(&qml, out, target, ctx.as_ref())?;
}
return Ok(());
}
emit_one_file(input, out, target, ctx.as_ref())
}
#[derive(Debug, Clone)]
pub(crate) struct ScxmlContext {
pub context: String,
pub krate: String,
}
fn parse_scxml_context(s: &str) -> Result<ScxmlContext> {
let (ctx, krate) = s
.split_once('=')
.ok_or_else(|| anyhow::anyhow!("--scxml-context expects `<ctx>=<crate>`, got {s:?}"))?;
let (ctx, krate) = (ctx.trim(), krate.trim());
let ident_ok = |t: &str| {
!t.is_empty()
&& t.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& !t.chars().next().unwrap().is_ascii_digit()
};
if !ident_ok(ctx) || !ident_ok(krate) {
anyhow::bail!("--scxml-context `<ctx>=<crate>` parts must be identifiers, got {s:?}");
}
Ok(ScxmlContext {
context: ctx.to_string(),
krate: krate.to_string(),
})
}
fn emit_one_file(
input: &Path,
out: &Path,
target: EmitTarget,
scxml_context: Option<&ScxmlContext>,
) -> Result<()> {
let source =
fs::read_to_string(input).with_context(|| format!("reading {}", input.display()))?;
let mut module =
parse_module(&source, input).with_context(|| format!("parsing {}", input.display()))?;
attach_scjson_side_file(&mut module, input)?;
if matches!(target, EmitTarget::Rlvgl) {
let root_dir = component_search_root(input);
let mut stack = Vec::new();
let mut cache = std::collections::HashMap::new();
expand_components_in(&mut module.root, &root_dir, &mut stack, &mut cache);
expand_repeaters_in(&mut module.root, 0, 0, scxml_context.is_some());
if scxml_context.is_some() {
synthesize_button_tap_tags(&mut module.root);
}
}
let stem = input
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("input has no usable file stem: {}", input.display()))?;
let (out_path, rust) = match target {
EmitTarget::Data => (out.join(format!("{stem}.rs")), render_rs(&module)),
EmitTarget::Rlvgl => {
let resolver = DimResolver::from_qml(input, &module.root);
(
out.join(format!("{stem}.rlvgl.rs")),
render_rlvgl_with_resolver(&module, &resolver, scxml_context),
)
}
};
fs::write(&out_path, rust).with_context(|| format!("writing {}", out_path.display()))?;
Ok(())
}
fn component_search_root(input: &Path) -> PathBuf {
let mut cur = input.parent();
let fallback = cur
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
while let Some(d) = cur {
if d.file_name().and_then(|s| s.to_str()) == Some("Qml") {
return d.to_path_buf();
}
cur = d.parent();
}
fallback
}
fn find_component_file(root: &Path, type_name: &str) -> Option<PathBuf> {
fn walk(dir: &Path, target: &str, depth: u32) -> Option<PathBuf> {
if depth > 6 {
return None;
}
let entries = fs::read_dir(dir).ok()?;
let mut subdirs = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
subdirs.push(path);
} else if path.file_name().and_then(|s| s.to_str()) == Some(target) {
return Some(path);
}
}
for sub in subdirs {
if let Some(hit) = walk(&sub, target, depth + 1) {
return Some(hit);
}
}
None
}
let target = format!("{type_name}.qml");
walk(root, &target, 0)
}
fn load_component(
type_name: &str,
root_dir: &Path,
cache: &mut std::collections::HashMap<String, Option<UiItem>>,
) -> Option<UiItem> {
if let Some(hit) = cache.get(type_name) {
return hit.clone();
}
let resolved = find_component_file(root_dir, type_name).and_then(|path| {
let src = fs::read_to_string(&path).ok()?;
parse_module(&src, &path).ok().map(|m| m.root)
});
cache.insert(type_name.to_string(), resolved.clone());
resolved
}
fn merge_component_into_instance(instance: &mut UiItem, def: UiItem) {
instance.type_name = def.type_name;
let mut children = def.children;
children.append(&mut instance.children);
instance.children = children;
let mut merged = def.assignments;
for asn in std::mem::take(&mut instance.assignments) {
if let Some(slot) = merged.iter_mut().find(|a| a.target == asn.target) {
*slot = asn;
} else {
merged.push(asn);
}
}
instance.assignments = merged;
let mut handlers = def.handlers;
handlers.append(&mut instance.handlers);
instance.handlers = handlers;
let mut props = def.properties;
props.append(&mut instance.properties);
instance.properties = props;
let mut signals = def.signals;
signals.append(&mut instance.signals);
instance.signals = signals;
}
fn expand_components_in(
item: &mut UiItem,
root_dir: &Path,
stack: &mut Vec<String>,
cache: &mut std::collections::HashMap<String, Option<UiItem>>,
) {
let mut pushed = 0usize;
loop {
let stripped = item
.type_name
.rsplit('.')
.next()
.unwrap_or(&item.type_name)
.to_string();
if !(starts_uppercase(&stripped)
&& matches!(map_qml_type(&stripped), WidgetKind::Fallback)
&& !stack.contains(&stripped))
{
break;
}
let Some(def) = load_component(&stripped, root_dir, cache) else {
break;
};
stack.push(stripped);
pushed += 1;
merge_component_into_instance(item, def);
}
for child in &mut item.children {
expand_components_in(child, root_dir, stack, cache);
}
for _ in 0..pushed {
stack.pop();
}
}
fn expand_repeaters_in(item: &mut UiItem, layout_w: i32, spacing: i32, preserve_ternary: bool) {
let my_w = lookup_assignment(item, "width")
.and_then(parse_int_literal)
.unwrap_or(layout_w);
let my_spacing = lookup_assignment(item, "spacing")
.and_then(parse_int_literal)
.unwrap_or(spacing);
for child in &mut item.children {
if child.type_name == "Repeater" && my_w > 0 {
expand_one_repeater(child, my_w, my_spacing, preserve_ternary);
}
expand_repeaters_in(child, my_w, my_spacing, preserve_ternary);
}
}
fn expand_one_repeater(rep: &mut UiItem, layout_w: i32, spacing: i32, preserve_ternary: bool) {
let items: Vec<String> = match rep.assignments.iter().find(|a| a.target == "model") {
Some(UiAssignment {
value: UiAssignmentValue::List { items },
..
}) => items
.iter()
.filter_map(|v| match v {
UiAssignmentValue::Expression { text } => Some(text.clone()),
_ => None,
})
.collect(),
_ => return,
};
let entries: Vec<(String, Option<String>)> = items
.iter()
.filter_map(|t| {
extract_image_key_source(t, preserve_ternary).map(|s| (s, extract_model_event_name(t)))
})
.collect();
let n = entries.len() as i32;
if n == 0 {
return;
}
let icon = 48;
let group_w = n * icon + (n - 1) * spacing.max(0);
let start = ((layout_w - group_w) / 2).max(0);
let mut kids = Vec::new();
for (i, (src, event)) in entries.iter().enumerate() {
let id = format!("__rep_btn_{i}");
let mut a: Vec<UiAssignment> = vec![
ui_assign("source", src.clone()),
ui_assign("width", format!("{icon}")),
ui_assign("height", format!("{icon}")),
ui_assign(
"anchors.verticalCenter",
"parent.verticalCenter".to_string(),
),
];
if i == 0 {
a.push(ui_assign("anchors.left", "parent.left".to_string()));
a.push(ui_assign("anchors.leftMargin", format!("{start}")));
} else {
a.push(ui_assign(
"anchors.left",
format!("__rep_btn_{}.right", i - 1),
));
a.push(ui_assign(
"anchors.leftMargin",
format!("{}", spacing.max(0)),
));
}
let handlers = match event {
Some(ev) => vec![UiHandler {
signal: "onReleased".to_string(),
body: format!("submitBtnSetupEvent(\"{ev}\")"),
}],
None => Vec::new(),
};
kids.push(UiItem {
type_name: "Image".to_string(),
id: Some(id),
properties: Vec::new(),
assignments: a,
signals: Vec::new(),
handlers,
children: Vec::new(),
});
}
rep.children = kids;
}
fn ui_assign(target: &str, text: String) -> UiAssignment {
UiAssignment {
target: target.to_string(),
value: UiAssignmentValue::Expression { text },
}
}
fn parse_submit_btn_event(body: &str) -> Option<String> {
let idx = body.find("submitBtnSetupEvent")?;
let args = &body[idx + "submitBtnSetupEvent".len()..];
let args = args.trim_start().strip_prefix('(')?;
let q1 = args.find('"')?;
if args[..q1].contains(',') {
return None;
}
let rest = &args[q1 + 1..];
let q2 = rest.find('"')?;
Some(rest[..q2].to_string())
}
fn extract_submit_btn_event(item: &UiItem) -> Option<String> {
item.handlers
.iter()
.find_map(|h| parse_submit_btn_event(&h.body))
}
fn extract_model_event_name(obj_text: &str) -> Option<String> {
let idx = obj_text.find("eventName")?;
let after = obj_text[idx + "eventName".len()..]
.trim_start()
.strip_prefix(':')?;
let q1 = after.find('"')?;
let rest = &after[q1 + 1..];
let q2 = rest.find('"')?;
Some(rest[..q2].to_string())
}
fn synthesize_button_tap_tags(item: &mut UiItem) {
if item.id.is_none()
&& let Some(ev) = extract_submit_btn_event(item)
{
item.id = Some(format!(
"__btn_{}",
rust_snake_ident(&ev.to_ascii_lowercase())
));
}
for child in &mut item.children {
synthesize_button_tap_tags(child);
}
}
fn extract_image_key_source(obj_text: &str, preserve_ternary: bool) -> Option<String> {
let idx = obj_text.find("imageKeySource")?;
let after = obj_text[idx + "imageKeySource".len()..].trim_start();
let after = after.strip_prefix(':')?;
let expr = after
.rsplit_once('}')
.map(|(e, _)| e)
.unwrap_or(after)
.trim();
if preserve_ternary && split_ternary(expr).is_some() {
return Some(expr.to_string());
}
let lit = extract_asset_literals(expr).into_iter().last()?;
Some(format!("\"{lit}\""))
}
fn split_ternary(expr: &str) -> Option<(&str, &str, &str)> {
let bytes = expr.as_bytes();
let mut in_q = 0u8;
let mut qpos = None;
let mut cpos = None;
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if in_q != 0 {
if b == b'\\' {
i += 2;
continue;
}
if b == in_q {
in_q = 0;
}
} else if b == b'"' || b == b'\'' {
in_q = b;
} else if b == b'?' && qpos.is_none() {
qpos = Some(i);
} else if b == b':' && qpos.is_some() && cpos.is_none() {
cpos = Some(i);
break;
}
i += 1;
}
let (q, c) = (qpos?, cpos?);
Some((
expr[..q].trim(),
expr[q + 1..c].trim(),
expr[c + 1..].trim(),
))
}
fn parse_predicate_source(expr: &str, ctx: &str) -> Option<(String, String, String)> {
let (cond, then_b, else_b) = split_ternary(expr)?;
if then_b.contains('?') || else_b.contains('?') {
return None; }
let state = cond.strip_prefix(ctx)?.strip_prefix('.')?.trim();
if state.is_empty() || !state.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return None;
}
let on = extract_asset_literals(then_b).into_iter().next()?;
let off = extract_asset_literals(else_b).into_iter().next()?;
Some((
state.to_string(),
strip_qrc_prefix(&on).to_string(),
strip_qrc_prefix(&off).to_string(),
))
}
fn parse_chained_predicate_source(
expr: &str,
ctx: &str,
) -> Option<(Vec<(String, String)>, String)> {
let mut arms: Vec<(String, String)> = Vec::new();
let mut cur = expr.trim();
loop {
let (cond, then_b, else_b) = split_ternary(cur)?;
if then_b.contains('?') {
return None; }
let state = cond.strip_prefix(ctx)?.strip_prefix('.')?.trim();
if state.is_empty() || !state.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return None;
}
let on = extract_asset_literals(then_b).into_iter().next()?;
arms.push((state.to_string(), strip_qrc_prefix(&on).to_string()));
let else_b = else_b.trim();
if split_ternary(else_b).is_some() {
cur = else_b; continue;
}
if arms.len() < 2 {
return None;
}
let default = extract_asset_literals(else_b).into_iter().next()?;
return Some((arms, strip_qrc_prefix(&default).to_string()));
}
}
fn parse_visible_predicate(expr: &str, ctx: &str) -> Option<String> {
let state = expr.trim().strip_prefix(ctx)?.strip_prefix('.')?.trim();
if state.is_empty() || !state.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return None;
}
Some(state.to_string())
}
pub fn render_rs(module: &UiModule) -> String {
let mut out = String::new();
out.push_str("// SPDX-License-Identifier: MIT\n");
out.push_str("//\n");
out.push_str(&format!(
"// Generated by `rlvgl-creator qt emit` from `{}`.\n",
module.source
));
out.push_str("// Do not edit by hand — regenerate with:\n");
out.push_str("// cargo run --features creator --bin rlvgl-creator -- \\\n");
out.push_str("// qt emit <input.qml> <out_dir>\n");
out.push_str("//\n");
out.push_str("// Emit-shape contract: docs/qt-support/03-rlvgl-emitter-widgets.md\n");
out.push_str("// QT-03 phase notes:\n");
out.push_str("// * widget API mapping is intentionally deferred to QT-03b.\n");
out.push_str("// * properties / signals / handlers are not yet lowered;\n");
out.push_str("// they remain in the JSON IR (qt-ir.json) and are not\n");
out.push_str("// materialised in this static `Node` form.\n");
out.push_str("//\n");
out.push_str("// The emitted module uses no `String`/`Vec`/`std` paths and is\n");
out.push_str("// safe to consume from a `no_std` crate. We deliberately do NOT\n");
out.push_str("// emit `#![no_std]` because that is a crate-root attribute and\n");
out.push_str("// would be rejected when this file is pulled in via `#[path]`.\n");
out.push('\n');
out.push_str("#![allow(dead_code)]\n");
out.push('\n');
out.push_str(&format!(
"/// QT-03 emit-shape version. Bumping is Specification-Required.\n\
pub const QT_EMIT_VERSION: u32 = {QT_EMIT_VERSION_DATA};\n\n"
));
out.push_str(&format!(
"/// `qt-ir` schema version this module was generated from.\n\
pub const QT_IR_VERSION: u32 = {};\n\n",
module.version
));
out.push_str(&format!(
"/// Source `.qml` file path as recorded at emit time.\n\
pub const QT_SOURCE: &str = {};\n\n",
rust_str_lit(&module.source)
));
out.push_str(NODE_TYPES);
out.push('\n');
out.push_str("/// Top-level screen tree, lowered from the source `.qml`.\n");
out.push_str("///\n");
out.push_str("/// `#[rustfmt::skip]` keeps the deterministic emit-shape\n");
out.push_str("/// stable across `cargo fmt` runs. The shape is owned by\n");
out.push_str("/// QT-03 §6 / §7; rustfmt's collapsing heuristics are not.\n");
out.push_str("#[rustfmt::skip]\n");
out.push_str("pub static SCREEN: Node = ");
emit_node(&module.root, 0, &mut out);
out.push_str(";\n");
out
}
const NODE_TYPES: &str = "\
/// One QML type instance lowered to static data.
///
/// QT-03 carries only type name, optional id, expression-valued
/// assignments, and children. Object-valued assignments and list-valued
/// assignments are surfaced as preceding `// emitter-skipped:` comments
/// so reviewers can see what was elided. Property declarations,
/// signal declarations, and signal handlers remain in the upstream
/// `qt-ir.json` and are not yet lowered here — see
/// `docs/qt-support/03-rlvgl-emitter-widgets.md` §6.
#[derive(Debug, Clone, Copy)]
pub struct Node {
pub type_name: &'static str,
pub id: Option<&'static str>,
pub assignments: &'static [Assignment],
pub children: &'static [Node],
}
/// One `target: <expression>` assignment whose value is an opaque QML
/// expression. Object-valued and list-valued assignments are not
/// represented here at QT-03 — see the comments preceding each `Node`
/// literal for the elided entries.
#[derive(Debug, Clone, Copy)]
pub struct Assignment {
pub target: &'static str,
pub value: &'static str,
}
";
fn emit_node(item: &UiItem, depth: usize, out: &mut String) {
let pad = " ".repeat(depth);
let pad1 = " ".repeat(depth + 1);
out.push_str("Node {\n");
out.push_str(&format!(
"{pad1}type_name: {},\n",
rust_str_lit(&item.type_name)
));
match &item.id {
Some(id) => out.push_str(&format!("{pad1}id: Some({}),\n", rust_str_lit(id))),
None => out.push_str(&format!("{pad1}id: None,\n")),
}
if !item.properties.is_empty() {
out.push_str(&format!(
"{pad1}// emitter-skipped (QT-04+): {} property declaration(s)\n",
item.properties.len()
));
}
if !item.signals.is_empty() {
out.push_str(&format!(
"{pad1}// emitter-skipped (QT-04+): {} signal declaration(s)\n",
item.signals.len()
));
}
if !item.handlers.is_empty() {
out.push_str(&format!(
"{pad1}// emitter-skipped (QT-04+): {} signal handler(s)\n",
item.handlers.len()
));
}
out.push_str(&format!("{pad1}assignments: &["));
let expression_assignments: Vec<&UiAssignment> = item
.assignments
.iter()
.filter(|a| matches!(a.value, UiAssignmentValue::Expression { .. }))
.collect();
let nontrivial_skipped: Vec<&UiAssignment> = item
.assignments
.iter()
.filter(|a| !matches!(a.value, UiAssignmentValue::Expression { .. }))
.collect();
if expression_assignments.is_empty() && nontrivial_skipped.is_empty() {
out.push_str("],\n");
} else {
out.push('\n');
for skipped in &nontrivial_skipped {
let kind = match skipped.value {
UiAssignmentValue::Object { .. } => "object",
UiAssignmentValue::List { .. } => "list",
UiAssignmentValue::Expression { .. } => unreachable!(),
};
out.push_str(&format!(
"{} // emitter-skipped (QT-03b): {}: <{}>\n",
pad1, skipped.target, kind
));
}
for asn in &expression_assignments {
let value_text = match &asn.value {
UiAssignmentValue::Expression { text } => text.as_str(),
_ => unreachable!(),
};
out.push_str(&format!("{pad1} Assignment {{\n"));
out.push_str(&format!(
"{pad1} target: {},\n",
rust_str_lit(&asn.target)
));
out.push_str(&format!(
"{pad1} value: {},\n",
rust_str_lit(value_text)
));
out.push_str(&format!("{pad1} }},\n"));
}
out.push_str(&format!("{pad1}],\n"));
}
out.push_str(&format!("{pad1}children: &["));
if item.children.is_empty() {
out.push_str("],\n");
} else {
out.push('\n');
for child in &item.children {
out.push_str(&" ".repeat(depth + 2));
emit_node(child, depth + 2, out);
out.push_str(",\n");
}
out.push_str(&format!("{pad1}],\n"));
}
out.push_str(&format!("{pad}}}"));
}
fn rust_str_lit(s: &str) -> String {
format!("{s:?}")
}
pub fn render_rlvgl(module: &UiModule) -> String {
render_rlvgl_with_resolver(module, &DimResolver::default(), None)
}
fn render_rlvgl_with_resolver(
module: &UiModule,
resolver: &DimResolver,
scxml_context: Option<&ScxmlContext>,
) -> String {
let state_fields = collect_state_fields(&module.root);
let v1_sm_id = module.state_machine.as_ref().map(|sm| sm.id.clone());
let v2 = v1_sm_id.is_none() && scxml_context.is_some();
let sm_id = v1_sm_id
.clone()
.or_else(|| scxml_context.map(|c| c.krate.clone()));
let sm_context = scxml_context.map(|c| c.context.clone());
let dm_field_ids: Vec<String> = module
.state_machine
.as_ref()
.map(|sm| sm.datamodel.iter().map(|f| f.id.clone()).collect())
.unwrap_or_default();
let mut ctx = RlvglEmitCtx::new_with_fields(state_fields.clone())
.with_resolver(resolver.clone())
.with_sm(sm_id.clone())
.with_dm_fields(dm_field_ids)
.with_v2(v2, sm_context.clone());
let root_fn = ctx.alloc_fn_name(&module.root);
let root_body = ctx.emit_helper(&module.root, &root_fn, true);
let has_sm = sm_id.is_some();
let used_predicate = ctx.used_predicate;
let used_visibility = ctx.used_visibility;
let used_predicate_chain = ctx.used_predicate_chain;
let button_tap_events = ctx.button_tap_events.clone();
let used_external_text = ctx.used_external_text;
let external_text_bindings = ctx.external_text_bindings.clone();
let used_dm_fields = ctx.used_dm_fields.clone();
let used_assets = ctx.used_assets.clone();
let has_images = !used_assets.is_empty();
let mut out = String::new();
out.push_str("// SPDX-License-Identifier: MIT\n");
out.push_str("//\n");
out.push_str(&format!(
"// Generated by `rlvgl-creator qt emit --target rlvgl` from `{}`.\n",
module.source
));
out.push_str("// Do not edit by hand — regenerate with:\n");
out.push_str("// cargo run --features creator --bin rlvgl-creator -- \\\n");
out.push_str("// qt emit --target rlvgl <input.qml> <out_dir>\n");
out.push_str("//\n");
out.push_str("// Emit-shape contract: docs/qt-support/03b-rlvgl-widget-mapping.md\n");
out.push_str("// QT-03b notes:\n");
out.push_str("// * helper functions named per §8 (`build_<sanitized_id>` /\n");
out.push_str("// `build_node_<index>`) so reviewers have stable handles.\n");
out.push_str("// * bounds resolved per §7 trivial path; non-`fill`/`margins`\n");
out.push_str("// anchors are deferred to QT-03c.\n");
out.push_str("// * property lowering per §6; unsupported lower to TODOs.\n");
out.push_str("// QT-04b notes (docs/qt-support/04b-properties-bindings.md):\n");
out.push_str("// * `pub struct ScreenState` carries every QML root-level\n");
out.push_str("// property declaration (see §3 / §5).\n");
out.push_str("// * `build_screen` returns `(WidgetNode, Rc<RefCell<ScreenState>>)`\n");
out.push_str("// and threads `state` through every helper.\n");
out.push_str("// * Handler bodies that match the §7 grammar lower to\n");
out.push_str("// `state.borrow_mut()...` mutations under a `// QT-04b body:`\n");
out.push_str("// marker; non-matching bodies fall through to `// QT-04 body:`.\n");
out.push('\n');
out.push_str("#![allow(dead_code)]\n");
out.push_str("#![allow(unused_imports)]\n");
out.push_str("#![allow(unused_variables)]\n");
out.push('\n');
out.push_str("extern crate alloc;\n");
out.push('\n');
if has_images {
out.push_str("use crate::qt_assets;\n");
}
out.push_str("use alloc::rc::Rc;\n");
out.push_str("use alloc::string::String;\n");
out.push_str("use alloc::vec::Vec;\n");
out.push_str("use core::cell::RefCell;\n");
out.push('\n');
if v2 && let Some(id) = &sm_id {
out.push_str(&format!("use {id}::Machine;\n"));
}
out.push_str("use rlvgl_core::WidgetNode;\n");
if has_images {
out.push_str("use rlvgl_core::image::BlitOpts;\n");
}
out.push_str("use rlvgl_core::widget::{Color, Rect, Widget};\n");
out.push_str("use rlvgl_widgets::button::Button;\n");
out.push_str("use rlvgl_widgets::click_area::ClickArea;\n");
out.push_str("use rlvgl_widgets::container::Container;\n");
if has_images {
out.push_str("use rlvgl_widgets::image::Image;\n");
}
out.push_str("use rlvgl_widgets::label::Label;\n");
if !v2 && let Some(id) = &sm_id {
out.push_str(&format!("use {id}_gen::{{DataModel, Event, Machine}};\n"));
}
out.push('\n');
out.push_str(&format!(
"/// rlvgl-target emit-shape version. Bumping is Specification-Required\n\
/// (see `docs/qt-support/04b-properties-bindings.md` §11).\n\
pub const QT_EMIT_VERSION: u32 = {QT_EMIT_VERSION_RLVGL};\n\n"
));
out.push_str(&format!(
"/// `qt-ir` schema version this module was generated from.\n\
pub const QT_IR_VERSION: u32 = {};\n\n",
module.version
));
out.push_str(&format!(
"/// Source `.qml` file path as recorded at emit time.\n\
#[rustfmt::skip]\n\
pub const QT_SOURCE: &str = {};\n\n",
rust_str_lit(&module.source)
));
if let Some(id) = &sm_id {
if v2 {
out.push_str(
"/// QT-05 §6 linkage version. v2 is the istate M1P6 dynamic-string\n\
/// surface (`step`/`is_active`/`get_var`), linked via `--scxml-context`.\n\
pub const ISTATE_LINKAGE_VERSION: u32 = 2;\n\n",
);
} else {
out.push_str(
"/// QT-05 §6 linkage version. v1 pins the istate Rust\n\
/// template's std-profile shape (VecDeque + Box<dyn Externals>).\n\
pub const ISTATE_LINKAGE_VERSION: u32 = 1;\n\n",
);
}
out.push_str(&format!(
"/// QT-05a §8 derived state-machine ID; matches the\n\
/// `<sm>_gen` crate name stem (v1) or the `--scxml-context` crate (v2).\n\
pub const QT_SM_NAME: &str = {};\n\n",
rust_str_lit(id)
));
}
if !button_tap_events.is_empty() {
out.push_str(
"/// QT-05j — tap targets lowered from `submitBtnSetupEvent(\"…\")` button\n\
/// handlers: `(node tag, raw QML button-event)`. The consumer maps each\n\
/// QML event to a machine event via `machine.step(…)`.\n\
pub const BUTTON_TAP_EVENTS: &[(&str, &str)] = &[\n",
);
for (tag, ev) in &button_tap_events {
out.push_str(&format!(
" ({}, {}),\n",
rust_str_lit(tag),
rust_str_lit(ev)
));
}
out.push_str("];\n\n");
}
if !external_text_bindings.is_empty() {
out.push_str(
"/// QT-05k — external-text targets lowered from Label `text: <obj>.<prop>`\n\
/// sources: `(node tag, verbatim external key)`. The consumer resolves\n\
/// each key to a string via `apply_external_text`.\n\
#[rustfmt::skip]\n\
pub const EXTERNAL_TEXT_BINDINGS: &[(&str, &str)] = &[\n",
);
for (tag, key) in &external_text_bindings {
out.push_str(&format!(
" ({}, {}),\n",
rust_str_lit(tag),
rust_str_lit(key)
));
}
out.push_str("];\n\n");
}
emit_screen_state_struct(&state_fields, &mut out);
emit_label_binding_struct(&mut out);
if has_sm {
if v2 {
if used_predicate || used_visibility || used_predicate_chain {
emit_image_art_struct(&mut out);
}
if used_predicate {
emit_predicate_binding_struct(&mut out);
}
if used_visibility {
emit_visibility_binding_struct(&mut out);
}
if used_predicate_chain {
emit_predicate_chain_binding_struct(&mut out);
}
if used_external_text {
emit_external_text_binding_struct(&mut out);
}
emit_binding_enum_v2(
&mut out,
used_predicate,
used_visibility,
used_predicate_chain,
used_external_text,
);
} else {
emit_machine_binding_struct(&mut out);
emit_binding_enum(&mut out);
}
}
emit_binding_sink_struct(&mut out);
emit_built_screen_alias(&mut out, has_sm);
if has_sm {
if v2 {
out.push_str(
"/// Build the screen widget tree at `bounds` and return it\n\
/// alongside the `ScreenState` handle (QT-04b §3), the\n\
/// `Rc<RefCell<Machine>>` istate (linkage v2) handle, and the\n\
/// `Vec<Binding>` of reactive bindings (QT-05g §3). Callers\n\
/// drive the machine via `machine.borrow_mut().step(\"…\", …)`\n\
/// and call `refresh_bindings` to re-apply state-predicate\n\
/// artwork (e.g. Play↔Pause via `is_active`).\n\
#[rustfmt::skip]\n\
pub fn build_screen(\n \
bounds: Rect,\n) \
-> BuiltScreen {\n",
);
} else {
out.push_str(
"/// Build the screen widget tree at `bounds` and return it\n\
/// alongside the `ScreenState` handle (QT-04b §3), the\n\
/// `Rc<RefCell<Machine>>` istate-codegen handle (QT-05b §3),\n\
/// and the `Vec<Binding>` of reactive bindings (QT-04e §3,\n\
/// QT-05c §3). Callers may dispatch external events via\n\
/// `machine.borrow_mut().dispatch(Event::…)` — the QML-side\n\
/// `dispatch(\"…\")` handlers route through this same machine.\n\
#[rustfmt::skip]\n\
pub fn build_screen(\n \
bounds: Rect,\n) \
-> BuiltScreen {\n",
);
}
emit_screen_state_init(&state_fields, &mut out);
if v2 {
out.push_str(
" let machine = Rc::new(RefCell::new({ let mut m = Machine::new(); m.start(); m }));\n",
);
} else {
out.push_str(" let machine = Rc::new(RefCell::new(Machine::new()));\n");
}
out.push_str(" let mut bindings: Vec<Binding> = Vec::new();\n");
out.push_str(" let mut binding_sink = BindingSink::new(&mut bindings);\n");
out.push_str(&format!(
" let node = {root_fn}(bounds, Rc::clone(&state), Rc::clone(&machine), &mut binding_sink);\n \
(node, state, machine, bindings)\n}}\n\n"
));
} else {
out.push_str(
"/// Build the screen widget tree at `bounds` and return it\n\
/// alongside the `ScreenState` handle (QT-04b) and the\n\
/// `Vec<LabelBinding>` of reactive bindings (QT-04e §3).\n\
/// Callers ignore the third element with a `_` if reactivity\n\
/// is not needed.\n\
#[rustfmt::skip]\n\
pub fn build_screen(\n \
bounds: Rect,\n) \
-> BuiltScreen {\n",
);
emit_screen_state_init(&state_fields, &mut out);
out.push_str(" let mut label_bindings: Vec<LabelBinding> = Vec::new();\n");
out.push_str(" let mut binding_sink = BindingSink::new(&mut label_bindings);\n");
out.push_str(&format!(
" let node = {root_fn}(bounds, Rc::clone(&state), &mut binding_sink);\n \
(node, state, label_bindings)\n}}\n\n"
));
}
emit_refresh_bindings_fn(
&mut out,
has_sm,
v2,
used_predicate,
used_visibility,
used_predicate_chain,
used_external_text,
);
if used_external_text {
emit_apply_external_text_fn(&mut out);
}
for field in &used_dm_fields {
out.push_str(&format!(
"/// QT-05c §6: f64::to_string accessor for the bound DM field.\n\
#[inline]\n\
fn format_dm_{field}(dm: &DataModel) -> String {{\n \
use alloc::string::ToString;\n \
dm.{field}.to_string()\n}}\n\n"
));
}
if has_images {
emit_qt_image_helper(&used_assets, &mut out);
}
if used_predicate || used_visibility || used_predicate_chain {
emit_qt_image_art_helper(&mut out);
}
if used_predicate {
emit_qt_predicate_image_helper(&mut out);
}
if used_visibility {
emit_qt_visibility_image_helper(&mut out);
}
if used_predicate_chain {
emit_qt_predicate_chain_image_helper(&mut out);
}
if root_body.contains("qt_label(") {
out.push_str(
"/// Construct a `Label` with a transparent background (QML text has\n\
/// no fill), so it does not paint the opaque-white default `Style`.\n\
fn qt_label(text: impl Into<String>, bounds: Rect) -> Label {\n \
let mut l = Label::new(text, bounds);\n \
l.style.bg_color = Color(0x00, 0x00, 0x00, 0x00);\n \
l\n}\n\n",
);
}
out.push_str(&root_body);
format!("{}\n", out.trim_end())
}
fn emit_qt_image_helper(used_assets: &[AssetRef], out: &mut String) {
out.push_str("// Required `qt_assets` symbols (one RLE `&[u8]` blob each):\n");
for a in used_assets {
out.push_str(&format!("// qt_assets::{} ← {}\n", a.symbol, a.path));
}
out.push_str(
"/// Decode a vendored RLE asset into an owned, leaked pixel buffer and\n\
/// wrap it in an `Image` widget (see emit-time docs above).\n\
#[rustfmt::skip]\n\
fn qt_image(bounds: Rect, rle: &'static [u8]) -> Rc<RefCell<dyn Widget>> {\n \
let (w, h, palette_bytes, stream) =\n \
rlvgl_decomp::parse_rle_blob(rle).expect(\"qt_image: malformed RLE asset\");\n \
let palette_len = palette_bytes.len() / 2;\n \
let mut palette = alloc::vec![0u16; palette_len];\n \
for i in 0..palette_len {\n \
palette[i] = u16::from_le_bytes([palette_bytes[i * 2], palette_bytes[i * 2 + 1]]);\n \
}\n \
let rgba = rlvgl_decomp::decode_rgba(w as usize, h as usize, &palette, stream)\n \
.expect(\"qt_image: RLE decode failed\");\n \
let pixels: Vec<Color> = rgba\n \
.as_chunks::<4>().0.iter()\n \
.map(|c| if c[0] == 0xFF && c[1] == 0x00 && c[2] == 0xFF {\n \
Color(0x00, 0x00, 0x00, 0x00) // magenta sentinel → transparent (RGB565 has no alpha)\n \
} else {\n \
Color(c[0], c[1], c[2], c[3])\n \
})\n \
.collect();\n \
let pixels: &'static [Color] = Vec::leak(pixels);\n \
let mut img = Image::new(bounds, w as i32, h as i32, pixels);\n \
// An Image paints its own pixels; the widget background MUST be\n \
// transparent so the default opaque-white `Style` does not bury\n \
// the artwork (and content drawn behind it).\n \
img.style.bg_color = Color(0x00, 0x00, 0x00, 0x00);\n \
// QML's default `fillMode` is `Image.Stretch`: scale the source to\n \
// fill the destination bounds. `scale` is 8.8 fixed-point, so 256 =\n \
// 1:1; dest/src * 256 stretches source pixels across `bounds`.\n \
let scale_x = if w > 0 {\n \
((bounds.width.max(0) as i64 * 256 / w as i64).clamp(1, 0xffff)) as u16\n \
} else { 256 };\n \
let scale_y = if h > 0 {\n \
((bounds.height.max(0) as i64 * 256 / h as i64).clamp(1, 0xffff)) as u16\n \
} else { 256 };\n \
let img = img.with_blit_opts(BlitOpts { scale_x, scale_y, ..BlitOpts::default() });\n \
Rc::new(RefCell::new(img))\n}\n\n",
);
}
fn emit_qt_image_art_helper(out: &mut String) {
out.push_str(
"/// QT-05g: decode one vendored RLE asset into a leaked `ImageArt`\n\
/// (magenta-keyed → transparent, RGB565 has no alpha).\n\
#[rustfmt::skip]\n\
fn qt_image_art(rle: &'static [u8]) -> ImageArt {\n \
let (w, h, palette_bytes, stream) =\n \
rlvgl_decomp::parse_rle_blob(rle).expect(\"qt_image_art: malformed RLE asset\");\n \
let palette_len = palette_bytes.len() / 2;\n \
let mut palette = alloc::vec![0u16; palette_len];\n \
for i in 0..palette_len {\n \
palette[i] = u16::from_le_bytes([palette_bytes[i * 2], palette_bytes[i * 2 + 1]]);\n \
}\n \
let rgba = rlvgl_decomp::decode_rgba(w as usize, h as usize, &palette, stream)\n \
.expect(\"qt_image_art: RLE decode failed\");\n \
let pixels: Vec<Color> = rgba\n \
.as_chunks::<4>().0.iter()\n \
.map(|c| if c[0] == 0xFF && c[1] == 0x00 && c[2] == 0xFF {\n \
Color(0x00, 0x00, 0x00, 0x00)\n \
} else {\n \
Color(c[0], c[1], c[2], c[3])\n \
})\n \
.collect();\n \
let pixels: &'static [Color] = Vec::leak(pixels);\n \
ImageArt { width: w as i32, height: h as i32, pixels }\n}\n\n",
);
}
fn emit_qt_predicate_image_helper(out: &mut String) {
out.push_str(
"/// QT-05g: build a predicate-bound Image. Decodes both branches, builds\n\
/// the Image at the machine-driven branch, and returns it as a\n\
/// `dyn Widget` plus the `Binding::Predicate` that swaps it on refresh.\n\
#[rustfmt::skip]\n\
fn qt_predicate_image(\n \
bounds: Rect,\n on_rle: &'static [u8],\n off_rle: &'static [u8],\n \
state_id: &'static str,\n active: bool,\n) -> (Rc<RefCell<dyn Widget>>, Binding) {\n \
let on = qt_image_art(on_rle);\n \
let off = qt_image_art(off_rle);\n \
let cur = if active { on } else { off };\n \
let mut img = Image::new(bounds, cur.width, cur.height, cur.pixels);\n \
img.style.bg_color = Color(0x00, 0x00, 0x00, 0x00);\n \
// QML default `Image.Stretch`: scale source → bounds (8.8 fixed-point).\n \
let scale_x = if cur.width > 0 {\n \
((bounds.width.max(0) as i64 * 256 / cur.width as i64).clamp(1, 0xffff)) as u16\n \
} else { 256 };\n \
let scale_y = if cur.height > 0 {\n \
((bounds.height.max(0) as i64 * 256 / cur.height as i64).clamp(1, 0xffff)) as u16\n \
} else { 256 };\n \
let img = img.with_blit_opts(BlitOpts { scale_x, scale_y, ..BlitOpts::default() });\n \
let image = Rc::new(RefCell::new(img));\n \
let widget: Rc<RefCell<dyn Widget>> = image.clone();\n \
(widget, Binding::Predicate(PredicateBinding { image, state_id, on, off }))\n}\n\n",
);
}
fn emit_qt_visibility_image_helper(out: &mut String) {
out.push_str(
"/// QT-05h: build a visibility-bound Image. Decodes the source, builds\n\
/// the Image (initially hidden iff the bound state is inactive), and\n\
/// returns it as a `dyn Widget` plus its `Binding::Visibility`.\n\
#[rustfmt::skip]\n\
fn qt_visibility_image(\n \
bounds: Rect,\n rle: &'static [u8],\n \
state_id: &'static str,\n visible: bool,\n) -> (Rc<RefCell<dyn Widget>>, Binding) {\n \
let art = qt_image_art(rle);\n \
let mut img = Image::new(bounds, art.width, art.height, art.pixels);\n \
img.style.bg_color = Color(0x00, 0x00, 0x00, 0x00);\n \
let scale_x = if art.width > 0 {\n \
((bounds.width.max(0) as i64 * 256 / art.width as i64).clamp(1, 0xffff)) as u16\n \
} else { 256 };\n \
let scale_y = if art.height > 0 {\n \
((bounds.height.max(0) as i64 * 256 / art.height as i64).clamp(1, 0xffff)) as u16\n \
} else { 256 };\n \
let mut img = img.with_blit_opts(BlitOpts { scale_x, scale_y, ..BlitOpts::default() });\n \
img.set_hidden(!visible);\n \
let image = Rc::new(RefCell::new(img));\n \
let widget: Rc<RefCell<dyn Widget>> = image.clone();\n \
(widget, Binding::Visibility(VisibilityBinding { image, state_id }))\n}\n\n",
);
}
fn emit_qt_predicate_chain_image_helper(out: &mut String) {
out.push_str(
"/// QT-05i: build a chained-predicate Image. Decodes every arm + the\n\
/// default, builds the Image at the machine-driven arm (first active\n\
/// wins, else default), and returns it as a `dyn Widget` plus the\n\
/// `Binding::Chain` that swaps it on refresh.\n\
#[rustfmt::skip]\n\
fn qt_predicate_chain_image(\n \
bounds: Rect,\n arms: &[(&'static [u8], &'static str)],\n \
default_rle: &'static [u8],\n machine: &Machine,\n) -> (Rc<RefCell<dyn Widget>>, Binding) {\n \
let decoded: Vec<PredicateArm> = arms\n \
.iter()\n \
.map(|(rle, state_id)| PredicateArm { state_id, art: qt_image_art(rle) })\n \
.collect();\n \
let default = qt_image_art(default_rle);\n \
let cur = decoded\n \
.iter()\n \
.find(|a| machine.is_active(a.state_id))\n \
.map(|a| a.art)\n \
.unwrap_or(default);\n \
let mut img = Image::new(bounds, cur.width, cur.height, cur.pixels);\n \
img.style.bg_color = Color(0x00, 0x00, 0x00, 0x00);\n \
// QML default `Image.Stretch`: scale source → bounds (8.8 fixed-point).\n \
let scale_x = if cur.width > 0 {\n \
((bounds.width.max(0) as i64 * 256 / cur.width as i64).clamp(1, 0xffff)) as u16\n \
} else { 256 };\n \
let scale_y = if cur.height > 0 {\n \
((bounds.height.max(0) as i64 * 256 / cur.height as i64).clamp(1, 0xffff)) as u16\n \
} else { 256 };\n \
let img = img.with_blit_opts(BlitOpts { scale_x, scale_y, ..BlitOpts::default() });\n \
let image = Rc::new(RefCell::new(img));\n \
let widget: Rc<RefCell<dyn Widget>> = image.clone();\n \
(widget, Binding::Chain(PredicateChainBinding { image, arms: decoded, default }))\n}\n\n",
);
}
struct RlvglEmitCtx {
node_index: u32,
state_fields: Vec<StateField>,
sm_id: Option<String>,
dm_field_ids: Vec<String>,
used_dm_fields: Vec<String>,
used_fn_names: std::collections::HashSet<String>,
used_assets: Vec<AssetRef>,
resolver: DimResolver,
linkage_v2: bool,
sm_context: Option<String>,
used_predicate: bool,
used_visibility: bool,
used_predicate_chain: bool,
button_tap_events: Vec<(String, String)>,
used_external_text: bool,
external_text_bindings: Vec<(String, String)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct AssetRef {
symbol: String,
path: String,
}
impl RlvglEmitCtx {
fn new_with_fields(state_fields: Vec<StateField>) -> Self {
Self {
node_index: 0,
state_fields,
sm_id: None,
dm_field_ids: Vec::new(),
used_dm_fields: Vec::new(),
used_assets: Vec::new(),
used_fn_names: std::collections::HashSet::new(),
resolver: DimResolver::default(),
linkage_v2: false,
sm_context: None,
used_predicate: false,
used_visibility: false,
used_predicate_chain: false,
button_tap_events: Vec::new(),
used_external_text: false,
external_text_bindings: Vec::new(),
}
}
fn with_resolver(mut self, resolver: DimResolver) -> Self {
self.resolver = resolver;
self
}
fn with_sm(mut self, sm_id: Option<String>) -> Self {
self.sm_id = sm_id;
self
}
fn with_v2(mut self, v2: bool, sm_context: Option<String>) -> Self {
self.linkage_v2 = v2;
self.sm_context = sm_context;
self
}
fn with_dm_fields(mut self, dm_field_ids: Vec<String>) -> Self {
self.dm_field_ids = dm_field_ids;
self
}
fn has_sm(&self) -> bool {
self.sm_id.is_some()
}
fn alloc_fn_name(&mut self, item: &UiItem) -> String {
let idx = self.node_index;
self.node_index += 1;
let base = match &item.id {
Some(id) => format!("build_{}", rust_snake_ident(id)),
None => return format!("build_node_{idx}"),
};
if self.used_fn_names.insert(base.clone()) {
base
} else {
let unique = format!("{base}_{idx}");
self.used_fn_names.insert(unique.clone());
unique
}
}
fn emit_helper(&mut self, item: &UiItem, fn_name: &str, is_root: bool) -> String {
let kind = map_qml_type(&item.type_name);
let mut child_fns: Vec<(String, &UiItem)> = Vec::new();
for child in &item.children {
let name = self.alloc_fn_name(child);
child_fns.push((name, child));
}
let has_children = !child_fns.is_empty();
let mut out = String::new();
out.push_str(&format!(
"// QML type: `{}`{}\n",
item.type_name,
match &item.id {
Some(id) => format!(" (id: `{id}`)"),
None => String::new(),
}
));
out.push_str("#[rustfmt::skip]\n");
if self.has_sm() {
out.push_str(&format!(
"fn {fn_name}(\n bounds: Rect,\n state: Rc<RefCell<ScreenState>>,\n \
machine: Rc<RefCell<Machine>>,\n \
bindings: &mut BindingSink<'_, Binding>,\n) -> WidgetNode {{\n"
));
} else {
out.push_str(&format!(
"fn {fn_name}(\n bounds: Rect,\n state: Rc<RefCell<ScreenState>>,\n \
label_bindings: &mut BindingSink<'_, LabelBinding>,\n) -> WidgetNode {{\n"
));
}
emit_widget_construction(
&kind,
item,
&self.state_fields,
self.sm_id.as_deref(),
&self.dm_field_ids,
&mut self.used_dm_fields,
&mut self.used_assets,
self.sm_context.as_deref(),
self.linkage_v2,
&mut self.used_predicate,
&mut self.used_visibility,
&mut self.used_predicate_chain,
&mut self.used_external_text,
&mut self.external_text_bindings,
&mut out,
);
emit_skipped_summary(item, is_root, &self.state_fields, &mut out);
let tag_lit = match &item.id {
Some(id) => format!("Some({})", rust_str_lit(id)),
None => "None".to_string(),
};
if self.sm_context.is_some()
&& let Some(id) = &item.id
&& let Some(ev) = extract_submit_btn_event(item)
{
self.button_tap_events.push((id.clone(), ev));
}
if has_children {
out.push_str(&format!(
" let mut node = WidgetNode {{\n \
widget,\n children: Vec::new(),\n tag: {tag_lit},\n }};\n"
));
} else {
out.push_str(&format!(
" WidgetNode {{\n \
widget,\n children: Vec::new(),\n tag: {tag_lit},\n }}\n}}\n\n"
));
return out;
}
let needs_solver = item.children.iter().any(child_has_sibling_anchor);
if needs_solver {
emit_solved_child_bounds(&child_fns, &self.resolver, &mut out);
for (i, (child_name, _child)) in child_fns.iter().enumerate() {
if self.has_sm() {
out.push_str(&format!(
" node.children.push({child_name}(cb_{i}, Rc::clone(&state), Rc::clone(&machine), bindings));\n"
));
} else {
out.push_str(&format!(
" node.children.push({child_name}(cb_{i}, Rc::clone(&state), label_bindings));\n"
));
}
}
} else {
for (child_name, child) in &child_fns {
emit_child_bounds(child, &mut out);
if self.has_sm() {
out.push_str(&format!(
" node.children.push({child_name}(child_bounds, Rc::clone(&state), Rc::clone(&machine), bindings));\n"
));
} else {
out.push_str(&format!(
" node.children.push({child_name}(child_bounds, Rc::clone(&state), label_bindings));\n"
));
}
}
}
out.push_str(" node\n}\n\n");
for (name, child) in child_fns {
out.push_str(&self.emit_helper(child, &name, false));
}
out
}
}
#[derive(Debug)]
enum WidgetKind {
Container,
Label,
Button,
ClickArea,
Image,
Fallback,
}
fn map_qml_type(name: &str) -> WidgetKind {
match name {
"Item" | "Rectangle" => WidgetKind::Container,
"Text" | "Label" | "QC.Label" => WidgetKind::Label,
"Button" | "QC.Button" => WidgetKind::Button,
"MouseArea" => WidgetKind::ClickArea,
"Image" | "AnimatedImage" | "BorderImage" => WidgetKind::Image,
"Column" | "Row" => WidgetKind::Container,
_ => WidgetKind::Fallback,
}
}
#[allow(clippy::too_many_arguments)]
fn emit_widget_construction(
kind: &WidgetKind,
item: &UiItem,
state_fields: &[StateField],
sm_id: Option<&str>,
dm_field_ids: &[String],
used_dm_fields: &mut Vec<String>,
used_assets: &mut Vec<AssetRef>,
sm_context: Option<&str>,
v2: bool,
used_predicate: &mut bool,
used_visibility: &mut bool,
used_predicate_chain: &mut bool,
used_external_text: &mut bool,
external_text: &mut Vec<(String, String)>,
out: &mut String,
) {
match kind {
WidgetKind::Container => {
let color = lookup_assignment(item, "color").and_then(parse_qml_color_lit);
if color.is_some() || lookup_assignment(item, "color").is_some() {
out.push_str(" let mut w = Container::new(bounds);\n");
if let Some((r, g, b, a)) = color {
out.push_str(&format!(
" w.style.bg_color = Color({r:#04x}, {g:#04x}, {b:#04x}, {a:#04x});\n"
));
} else {
out.push_str(" // TODO QT-04e: bind color (non-literal QML expression)\n");
out.push_str(" w.style.bg_color = Color(0x00, 0x00, 0x00, 0x00);\n");
}
out.push_str(
" let widget: Rc<RefCell<dyn Widget>> = Rc::new(RefCell::new(w));\n",
);
} else {
out.push_str(
" let mut w = Container::new(bounds);\n \
w.style.bg_color = Color(0x00, 0x00, 0x00, 0x00);\n \
let widget: Rc<RefCell<dyn Widget>> = Rc::new(RefCell::new(w));\n",
);
}
}
WidgetKind::Label => {
let raw_text = lookup_assignment(item, "text");
let text_lit = raw_text.and_then(parse_string_literal);
if let Some(text) = text_lit {
out.push_str(&format!(
" let widget: Rc<RefCell<dyn Widget>> =\n \
Rc::new(RefCell::new(qt_label({}, bounds)));\n",
rust_str_lit(&text)
));
} else if sm_id.is_some()
&& let Some(field) = raw_text.and_then(parse_dm_text_ref)
{
if !dm_field_ids.iter().any(|f| f == &field) {
panic!(
"QT-05c §5: Label `text: sm.dm.{field}` references an unknown DataModel \
field. Known fields: {dm_field_ids:?}. Either add the field to the \
scjson <datamodel> or fix the QML reference."
);
}
if !used_dm_fields.iter().any(|f| f == &field) {
used_dm_fields.push(field.clone());
}
out.push_str(&format!(
" // QT-05c machine-bound: text → sm.dm.{field}\n"
));
out.push_str(&format!(
" let label_handle: Rc<RefCell<Label>> = Rc::new(RefCell::new(\n \
qt_label(\n \
{{ let m = machine.borrow(); format_dm_{field}(&m.dm) }},\n \
bounds,\n ),\n ));\n"
));
out.push_str(" let widget: Rc<RefCell<dyn Widget>> = label_handle.clone();\n");
out.push_str(&format!(
" bindings.push(Binding::Machine(MachineBinding {{\n \
label: Rc::clone(&label_handle),\n \
accessor: format_dm_{field},\n }}));\n"
));
} else if let Some(field) =
raw_text.and_then(|expr| resolve_string_state_ref(expr, state_fields))
{
out.push_str(&format!(" // QT-04c bound: text → state.{field}\n"));
out.push_str(&format!(
" let label_handle: Rc<RefCell<Label>> = Rc::new(RefCell::new(\n \
qt_label(state.borrow().{field}.clone(), bounds),\n ));\n"
));
out.push_str(" let widget: Rc<RefCell<dyn Widget>> = label_handle.clone();\n");
out.push_str(&format!(
" // QT-04e bound: refresh state.{field} → label.set_text\n"
));
if sm_id.is_some() {
out.push_str(&format!(
" bindings.push(Binding::Label(LabelBinding {{\n \
label: Rc::clone(&label_handle),\n \
accessor: |s| s.{field}.clone(),\n }}));\n"
));
} else {
out.push_str(&format!(
" label_bindings.push(LabelBinding {{\n \
label: Rc::clone(&label_handle),\n \
accessor: |s| s.{field}.clone(),\n }});\n"
));
}
} else if v2
&& let Some(id) = &item.id
&& let Some(key) = raw_text.and_then(|e| parse_external_text_ref(e, sm_context))
{
out.push_str(&format!(
" // QT-05k external-text: text → {key} (consumer-resolved, tag `{id}`)\n"
));
out.push_str(
" let label_handle: Rc<RefCell<Label>> = Rc::new(RefCell::new(\n \
qt_label(\"\", bounds),\n ));\n",
);
out.push_str(" let widget: Rc<RefCell<dyn Widget>> = label_handle.clone();\n");
out.push_str(&format!(
" bindings.push(Binding::ExternalText(ExternalTextBinding {{\n \
label: Rc::clone(&label_handle),\n \
key: {},\n }}));\n",
rust_str_lit(&key)
));
*used_external_text = true;
external_text.push((id.clone(), key));
} else {
out.push_str(
" // TODO QT-04e: reactive bind text (non-literal QML expression)\n",
);
out.push_str(
" let widget: Rc<RefCell<dyn Widget>> =\n \
Rc::new(RefCell::new(qt_label(\"\", bounds)));\n",
);
}
}
WidgetKind::Button => {
let raw_text = lookup_assignment(item, "text");
let (ctor_arg, bound_marker) =
if let Some(text) = raw_text.and_then(parse_string_literal) {
(rust_str_lit(&text), None)
} else if let Some(field) =
raw_text.and_then(|expr| resolve_string_state_ref(expr, state_fields))
{
(
format!("state.borrow().{field}.clone()"),
Some(format!(" // QT-04c bound: text → state.{field}\n")),
)
} else {
out.push_str(
" // TODO QT-04e: reactive bind text (non-literal QML expression)\n",
);
("\"\"".to_string(), None)
};
if let Some(marker) = bound_marker {
out.push_str(&marker);
}
out.push_str(&format!(
" let mut button = Button::new({ctor_arg}, bounds);\n"
));
out.push_str(" button.style_mut().bg_color = Color(0x00, 0x00, 0x00, 0x00);\n");
for handler in item.handlers.iter().filter(|h| h.signal == "onClicked") {
emit_qt04b_or_qt04_handler(&handler.body, state_fields, sm_id, out);
}
out.push_str(
" let widget: Rc<RefCell<dyn Widget>> = \
Rc::new(RefCell::new(button));\n",
);
}
WidgetKind::ClickArea => {
let click_handlers: Vec<_> = item
.handlers
.iter()
.filter(|handler| handler.signal == "onClicked")
.collect();
if click_handlers.is_empty() {
out.push_str(" let click_area = ClickArea::new(bounds);\n");
} else {
out.push_str(" let mut click_area = ClickArea::new(bounds);\n");
}
for handler in click_handlers {
emit_qt04b_or_qt04_handler_for(
"click_area",
&handler.body,
state_fields,
sm_id,
out,
);
}
out.push_str(
" let widget: Rc<RefCell<dyn Widget>> = \
Rc::new(RefCell::new(click_area));\n",
);
}
WidgetKind::Image => {
let predicate = if v2 {
sm_context
.zip(lookup_assignment(item, "source"))
.and_then(|(ctx, raw)| parse_predicate_source(raw, ctx))
} else {
None
};
if let Some((state, on_path, off_path)) = predicate {
let on = AssetRef {
symbol: asset_symbol(&on_path),
path: on_path,
};
let off = AssetRef {
symbol: asset_symbol(&off_path),
path: off_path,
};
for a in [&on, &off] {
if !used_assets.iter().any(|x| x.symbol == a.symbol) {
used_assets.push(a.clone());
}
}
*used_predicate = true;
out.push_str(&format!(
" // QT-05g predicate-bound: source → {ctx}.{state} ? \
on={on_sym} : off={off_sym}\n",
ctx = sm_context.unwrap_or(""),
on_sym = on.symbol,
off_sym = off.symbol,
));
out.push_str(&format!(
" let active = machine.borrow().is_active({state_lit});\n \
let (widget, __pb): (Rc<RefCell<dyn Widget>>, Binding) =\n \
qt_predicate_image(bounds, qt_assets::{on_sym}, qt_assets::{off_sym}, {state_lit}, active);\n \
bindings.push(__pb);\n",
state_lit = rust_str_lit(&state),
on_sym = on.symbol,
off_sym = off.symbol,
));
return;
}
let chain = if v2 {
sm_context
.zip(lookup_assignment(item, "source"))
.and_then(|(ctx, raw)| parse_chained_predicate_source(raw, ctx))
} else {
None
};
if let Some((arms, default_path)) = chain {
let arm_refs: Vec<(AssetRef, String)> = arms
.into_iter()
.map(|(state, path)| {
(
AssetRef {
symbol: asset_symbol(&path),
path,
},
state,
)
})
.collect();
let default_ref = AssetRef {
symbol: asset_symbol(&default_path),
path: default_path,
};
for a in arm_refs
.iter()
.map(|(r, _)| r)
.chain(core::iter::once(&default_ref))
{
if !used_assets.iter().any(|x| x.symbol == a.symbol) {
used_assets.push(a.clone());
}
}
*used_predicate_chain = true;
out.push_str(&format!(
" // QT-05i predicate-chain-bound: source → {ctx} chain \
[{arms}] default={def_sym}\n",
ctx = sm_context.unwrap_or(""),
arms = arm_refs
.iter()
.map(|(r, s)| format!("{s}→{}", r.symbol))
.collect::<Vec<_>>()
.join(", "),
def_sym = default_ref.symbol,
));
out.push_str(" let __arms: &[(&'static [u8], &'static str)] = &[\n");
for (r, state) in &arm_refs {
out.push_str(&format!(
" (qt_assets::{}, {}),\n",
r.symbol,
rust_str_lit(state),
));
}
out.push_str(" ];\n");
out.push_str(&format!(
" let (widget, __pcb): (Rc<RefCell<dyn Widget>>, Binding) =\n \
qt_predicate_chain_image(bounds, __arms, qt_assets::{def_sym}, &machine.borrow());\n \
bindings.push(__pcb);\n",
def_sym = default_ref.symbol,
));
return;
}
let visible_state = if v2 {
sm_context
.zip(lookup_assignment(item, "visible"))
.and_then(|(ctx, raw)| parse_visible_predicate(raw, ctx))
} else {
None
};
if let Some(state) = visible_state
&& let Some(asset) = pick_image_source(item)
{
if !used_assets.iter().any(|a| a.symbol == asset.symbol) {
used_assets.push(asset.clone());
}
*used_visibility = true;
out.push_str(&format!(
" // QT-05h visibility-bound: visible → {ctx}.{state} (source {sym})\n",
ctx = sm_context.unwrap_or(""),
sym = asset.symbol,
));
out.push_str(&format!(
" let visible = machine.borrow().is_active({state_lit});\n \
let (widget, __vb): (Rc<RefCell<dyn Widget>>, Binding) =\n \
qt_visibility_image(bounds, qt_assets::{sym}, {state_lit}, visible);\n \
bindings.push(__vb);\n",
state_lit = rust_str_lit(&state),
sym = asset.symbol,
));
return;
}
match pick_image_source(item) {
Some(asset) => {
if !used_assets.iter().any(|a| a.symbol == asset.symbol) {
used_assets.push(asset.clone());
}
out.push_str(&format!(
" // QT-IMG: Image source → qt_assets::{} ({})\n",
asset.symbol, asset.path
));
out.push_str(&format!(
" let widget: Rc<RefCell<dyn Widget>> = \
qt_image(bounds, qt_assets::{});\n",
asset.symbol
));
}
None => {
out.push_str(
" // QT-IMG: Image with no resolvable source literal; \
emitting an empty transparent container\n",
);
out.push_str(
" let mut w = Container::new(bounds);\n \
w.style.bg_color = Color(0x00, 0x00, 0x00, 0x00);\n \
let widget: Rc<RefCell<dyn Widget>> = Rc::new(RefCell::new(w));\n",
);
}
}
}
WidgetKind::Fallback => {
out.push_str(&format!(
" // emitter-fallback (QT-03b): unmapped QML type `{}`\n",
item.type_name
));
out.push_str(
" let mut w = Container::new(bounds);\n \
w.style.bg_color = Color(0x00, 0x00, 0x00, 0x00);\n \
let widget: Rc<RefCell<dyn Widget>> = Rc::new(RefCell::new(w));\n",
);
}
}
}
fn emit_qt04_handler_body(body: &str, out: &mut String) {
let trimmed = body.trim();
if trimmed.is_empty() {
return;
}
for line in trimmed.lines() {
out.push_str(&format!(" // QT-04 body: {line}\n"));
}
}
fn lowered_handler_count(item: &UiItem) -> usize {
if matches!(
map_qml_type(&item.type_name),
WidgetKind::Button | WidgetKind::ClickArea
) {
item.handlers
.iter()
.filter(|h| h.signal == "onClicked")
.count()
} else {
0
}
}
fn emit_skipped_summary(
item: &UiItem,
is_root: bool,
state_fields: &[StateField],
out: &mut String,
) {
let lowered_props = item
.properties
.iter()
.filter(|p| {
state_fields.iter().any(|f| {
f.qml_prop == p.name
&& match (is_root, &f.owner_id) {
(true, None) => true,
(false, Some(id)) => Some(id) == item.id.as_ref(),
_ => false,
}
})
})
.count();
let skipped_props = item.properties.len().saturating_sub(lowered_props);
if skipped_props > 0 {
out.push_str(&format!(
" // emitter-skipped (QT-04c+): {skipped_props} property declaration(s)\n",
));
}
if !item.signals.is_empty() {
out.push_str(&format!(
" // emitter-skipped (QT-04+): {} signal declaration(s)\n",
item.signals.len()
));
}
let skipped_handlers = item
.handlers
.len()
.saturating_sub(lowered_handler_count(item));
if skipped_handlers > 0 {
out.push_str(&format!(
" // emitter-skipped (QT-04+): {skipped_handlers} signal handler(s)\n",
));
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
enum RustExpr {
Int(i64),
Atom(String),
Binary {
op: RustBinOp,
left: Box<RustExpr>,
right: Box<RustExpr>,
},
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum RustBinOp {
Add,
Sub,
Mul,
Div,
}
impl RustBinOp {
fn symbol(self) -> &'static str {
match self {
Self::Add => "+",
Self::Sub => "-",
Self::Mul => "*",
Self::Div => "/",
}
}
fn precedence(self) -> u8 {
match self {
Self::Add | Self::Sub => 1,
Self::Mul | Self::Div => 2,
}
}
}
impl RustExpr {
fn atom(value: impl Into<String>) -> Self {
Self::Atom(value.into())
}
fn binary(op: RustBinOp, left: Self, right: Self) -> Self {
match (&op, &left, &right) {
(RustBinOp::Add, _, Self::Int(0)) | (RustBinOp::Sub, _, Self::Int(0)) => left,
(RustBinOp::Add, Self::Int(0), _) => right,
(RustBinOp::Mul, _, Self::Int(1)) | (RustBinOp::Div, _, Self::Int(1)) => left,
(RustBinOp::Mul, Self::Int(1), _) => right,
_ => Self::Binary {
op,
left: Box::new(left),
right: Box::new(right),
},
}
}
fn add(left: Self, right: Self) -> Self {
Self::binary(RustBinOp::Add, left, right)
}
fn sub(left: Self, right: Self) -> Self {
Self::binary(RustBinOp::Sub, left, right)
}
fn mul(left: Self, right: Self) -> Self {
Self::binary(RustBinOp::Mul, left, right)
}
fn div(left: Self, right: Self) -> Self {
Self::binary(RustBinOp::Div, left, right)
}
fn precedence(&self) -> u8 {
match self {
Self::Int(_) | Self::Atom(_) => 3,
Self::Binary { op, .. } => op.precedence(),
}
}
fn render(&self) -> String {
self.render_child(0, false)
}
fn render_child(&self, parent_precedence: u8, right_child: bool) -> String {
let rendered = match self {
Self::Int(value) => value.to_string(),
Self::Atom(value) => value.clone(),
Self::Binary { op, left, right } => format!(
"{} {} {}",
left.render_child(op.precedence(), false),
op.symbol(),
right.render_child(op.precedence(), true)
),
};
let precedence = self.precedence();
if precedence < parent_precedence || (right_child && precedence == parent_precedence) {
format!("({rendered})")
} else {
rendered
}
}
}
struct ResolvedBounds {
x: RustExpr,
y: RustExpr,
w: RustExpr,
h: RustExpr,
}
fn anchor_sibling_target(value: &str) -> Option<&str> {
let v = value.trim();
let (obj, edge) = v.split_once('.')?;
let edge_ok = matches!(
edge,
"left" | "right" | "top" | "bottom" | "horizontalCenter" | "verticalCenter"
);
if edge_ok && obj != "parent" && is_ident_str(obj) {
Some(obj)
} else {
None
}
}
fn is_ident_str(s: &str) -> bool {
!s.is_empty()
&& s.bytes().next().map(is_ident_start).unwrap_or(false)
&& s.bytes().all(is_ident_cont)
}
fn parse_external_text_ref(expr: &str, ctx: Option<&str>) -> Option<String> {
let e = expr.trim();
let mut parts = e.split('.');
let head = parts.next()?;
if !is_ident_str(head) {
return None;
}
if Some(head) == ctx {
return None;
}
let mut count = 1usize;
for p in parts {
if !is_ident_str(p) {
return None;
}
count += 1;
}
if count < 2 {
return None;
}
Some(e.to_string())
}
fn child_has_sibling_anchor(child: &UiItem) -> bool {
child.assignments.iter().any(|a| {
a.target.starts_with("anchors.")
&& matches!(&a.value, UiAssignmentValue::Expression { text }
if anchor_sibling_target(text).is_some())
})
}
fn child_anchor_deps(child: &UiItem) -> Vec<String> {
let mut deps = Vec::new();
for a in &child.assignments {
if !a.target.starts_with("anchors.") {
continue;
}
if let UiAssignmentValue::Expression { text } = &a.value
&& let Some(obj) = anchor_sibling_target(text)
&& !deps.iter().any(|d| d == obj)
{
deps.push(obj.to_string());
}
}
deps
}
fn anchor_edge_expr(value: &str, base_of: &dyn Fn(&str) -> Option<String>) -> Option<RustExpr> {
let v = value.trim();
let (obj, edge) = v.split_once('.')?;
let base = if obj == "parent" {
"bounds".to_string()
} else {
base_of(obj)?
};
let expr = match edge {
"left" => RustExpr::atom(format!("{base}.x")),
"right" => RustExpr::add(
RustExpr::atom(format!("{base}.x")),
RustExpr::atom(format!("{base}.width")),
),
"top" => RustExpr::atom(format!("{base}.y")),
"bottom" => RustExpr::add(
RustExpr::atom(format!("{base}.y")),
RustExpr::atom(format!("{base}.height")),
),
"horizontalCenter" => RustExpr::add(
RustExpr::atom(format!("{base}.x")),
RustExpr::div(RustExpr::atom(format!("{base}.width")), RustExpr::Int(2)),
),
"verticalCenter" => RustExpr::add(
RustExpr::atom(format!("{base}.y")),
RustExpr::div(RustExpr::atom(format!("{base}.height")), RustExpr::Int(2)),
),
_ => return None,
};
Some(expr)
}
#[derive(Default, Clone)]
struct DimResolver {
consts: std::collections::HashMap<String, f64>,
root_props: std::collections::HashMap<String, String>,
root_id: Option<String>,
asset_root: Option<PathBuf>,
qml_root: Option<PathBuf>,
}
impl DimResolver {
fn from_qml(qml_path: &Path, root: &UiItem) -> Self {
let consts = parse_js_numeric_constants(qml_path);
let mut root_props = std::collections::HashMap::new();
for p in &root.properties {
if let Some(d) = &p.default_value {
root_props.insert(p.name.clone(), d.clone());
}
}
let qml_root = component_search_root(qml_path);
let asset_root = qml_root.parent().map(|p| p.to_path_buf());
Self {
consts,
root_props,
root_id: root.id.clone(),
asset_root,
qml_root: Some(qml_root),
}
}
fn image_natural_size(&self, item: &UiItem) -> Option<(i32, i32)> {
let asset = pick_image_source(item)?;
let mut candidates: Vec<PathBuf> = Vec::new();
if let Some(root) = &self.asset_root {
candidates.push(root.join(&asset.path));
}
if let Some(root) = &self.qml_root {
candidates.push(root.join(&asset.path));
}
if let Some(hit) = candidates.into_iter().find(|p| p.exists()) {
return png_dimensions(&hit);
}
let base = asset.path.rsplit('/').next()?;
let found = self
.qml_root
.as_ref()
.and_then(|r| find_file_by_name(r, base, 0))?;
png_dimensions(&found)
}
fn resolve_dim(&self, expr: &str) -> Option<String> {
self.resolve_dim_expr(expr)
.map(|resolved| resolved.render())
}
fn resolve_dim_expr(&self, expr: &str) -> Option<RustExpr> {
let toks = tokenize_expr(expr)?;
let mut p = ExprParser {
toks: &toks,
pos: 0,
res: self,
depth: 0,
};
let out = p.parse_expr()?;
if p.pos == p.toks.len() {
Some(out)
} else {
None
}
}
fn resolve_margin_i32(&self, expr: &str) -> Option<i32> {
if let Some(n) = parse_int_literal(expr) {
return Some(n);
}
let rust = self.resolve_dim(expr)?;
if rust.contains("bounds") {
return None;
}
eval_const_int_rust(&rust)
}
}
struct ExprParser<'a> {
toks: &'a [String],
pos: usize,
res: &'a DimResolver,
depth: usize,
}
impl ExprParser<'_> {
fn peek(&self) -> Option<&str> {
self.toks.get(self.pos).map(|s| s.as_str())
}
fn bump(&mut self) -> Option<String> {
let t = self.toks.get(self.pos).cloned();
if t.is_some() {
self.pos += 1;
}
t
}
fn parse_expr(&mut self) -> Option<RustExpr> {
let mut lhs = self.parse_term()?;
while let Some(op) = self.peek()
&& (op == "+" || op == "-")
{
let op = self.bump().unwrap();
let rhs = self.parse_term()?;
lhs = match op.as_str() {
"+" => RustExpr::add(lhs, rhs),
"-" => RustExpr::sub(lhs, rhs),
_ => return None,
};
}
Some(lhs)
}
fn parse_term(&mut self) -> Option<RustExpr> {
let mut lhs = self.parse_factor()?;
while let Some(op) = self.peek()
&& (op == "*" || op == "/")
{
let op = self.bump().unwrap();
let rhs = self.parse_factor()?;
lhs = match op.as_str() {
"*" => RustExpr::mul(lhs, rhs),
"/" => RustExpr::div(lhs, rhs),
_ => return None,
};
}
Some(lhs)
}
fn parse_factor(&mut self) -> Option<RustExpr> {
let t = self.bump()?;
if t == "(" {
let e = self.parse_expr()?;
if self.bump().as_deref() != Some(")") {
return None;
}
return Some(e);
}
if let Ok(n) = t.parse::<f64>() {
return Some(RustExpr::Int(n.round() as i64));
}
let (head, member) = if self.peek() == Some(".") {
self.bump(); let m = self.bump()?;
(t, Some(m))
} else {
(t, None)
};
self.resolve_ident(&head, member.as_deref())
}
fn resolve_ident(&mut self, head: &str, member: Option<&str>) -> Option<RustExpr> {
match (head, member) {
("parent", Some("width")) => Some(RustExpr::atom("bounds.width")),
("parent", Some("height")) => Some(RustExpr::atom("bounds.height")),
(id, Some(name))
if self.res.root_id.as_deref() == Some(id)
&& self.res.root_props.contains_key(name) =>
{
self.recurse_root_prop(name)
}
(_, Some(name)) => self
.res
.consts
.get(name)
.map(|v| RustExpr::Int(v.round() as i64)),
("width", None) => Some(RustExpr::atom("bounds.width")),
("height", None) => Some(RustExpr::atom("bounds.height")),
(id, None) => {
if let Some(v) = self.res.consts.get(id) {
return Some(RustExpr::Int(v.round() as i64));
}
self.recurse_root_prop(id)
}
}
}
fn recurse_root_prop(&self, name: &str) -> Option<RustExpr> {
if self.depth >= 8 {
return None;
}
let expr = self.res.root_props.get(name).cloned()?;
let toks = tokenize_expr(&expr)?;
let mut sub = ExprParser {
toks: &toks,
pos: 0,
res: self.res,
depth: self.depth + 1,
};
let out = sub.parse_expr()?;
if sub.pos == sub.toks.len() {
Some(out)
} else {
None
}
}
}
fn tokenize_expr(s: &str) -> Option<Vec<String>> {
let mut out = Vec::new();
let mut chars = s.chars().peekable();
while let Some(&c) = chars.peek() {
if c.is_whitespace() {
chars.next();
} else if c.is_ascii_digit() {
let mut num = String::new();
while let Some(&d) = chars.peek() {
if d.is_ascii_digit() || d == '.' {
num.push(d);
chars.next();
} else {
break;
}
}
out.push(num);
} else if c.is_alphabetic() || c == '_' {
let mut id = String::new();
while let Some(&d) = chars.peek() {
if d.is_alphanumeric() || d == '_' {
id.push(d);
chars.next();
} else {
break;
}
}
out.push(id);
} else if "+-*/().".contains(c) {
out.push(c.to_string());
chars.next();
} else {
return None;
}
}
Some(out)
}
fn eval_const_int_rust(s: &str) -> Option<i32> {
let toks = tokenize_expr(s)?;
let empty = DimResolver::default();
let mut p = ExprParser {
toks: &toks,
pos: 0,
res: &empty,
depth: 0,
};
let folded = p.parse_expr()?.render();
if p.pos != p.toks.len() || folded.contains("bounds") {
return None;
}
fold_int_expr(&folded)
}
fn fold_int_expr(s: &str) -> Option<i32> {
let toks = tokenize_expr(s)?;
fn expr(t: &[String], i: &mut usize) -> Option<i64> {
let mut v = term(t, i)?;
while let Some(op) = t.get(*i) {
if op == "+" || op == "-" {
*i += 1;
let r = term(t, i)?;
v = if op == "+" { v + r } else { v - r };
} else {
break;
}
}
Some(v)
}
fn term(t: &[String], i: &mut usize) -> Option<i64> {
let mut v = factor(t, i)?;
while let Some(op) = t.get(*i) {
if op == "*" || op == "/" {
*i += 1;
let r = factor(t, i)?;
if op == "*" {
v *= r;
} else {
if r == 0 {
return None;
}
v /= r;
}
} else {
break;
}
}
Some(v)
}
fn factor(t: &[String], i: &mut usize) -> Option<i64> {
let tok = t.get(*i)?;
if tok == "(" {
*i += 1;
let v = expr(t, i)?;
if t.get(*i).map(|s| s.as_str()) != Some(")") {
return None;
}
*i += 1;
return Some(v);
}
*i += 1;
tok.parse::<i64>().ok()
}
let mut i = 0;
let v = expr(&toks, &mut i)?;
if i == toks.len() {
i32::try_from(v).ok()
} else {
None
}
}
fn parse_js_numeric_constants(qml_path: &Path) -> std::collections::HashMap<String, f64> {
let mut out = std::collections::HashMap::new();
let mut candidates: Vec<PathBuf> = Vec::new();
let root = component_search_root(qml_path);
candidates.push(root.join("AppConstants.js"));
if let Some(parent) = qml_path.parent() {
candidates.push(parent.join("AppConstants.js"));
}
let Some(path) = candidates.into_iter().find(|p| p.exists()) else {
return out;
};
let Ok(text) = fs::read_to_string(&path) else {
return out;
};
for line in text.lines() {
let line = line.trim();
let Some(rest) = line.strip_prefix("var ") else {
continue;
};
let Some((name, val)) = rest.split_once('=') else {
continue;
};
let name = name.trim();
let val = val.split([';', '/']).next().unwrap_or("").trim();
if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
continue;
}
if let Ok(n) = val.parse::<f64>() {
out.insert(name.to_string(), n);
}
}
out
}
fn png_dimensions(path: &Path) -> Option<(i32, i32)> {
let bytes = fs::read(path).ok()?;
if bytes.len() < 24 || &bytes[0..8] != b"\x89PNG\r\n\x1a\n" {
return None;
}
let w = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
let h = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
Some((i32::try_from(w).ok()?, i32::try_from(h).ok()?))
}
fn find_file_by_name(dir: &Path, name: &str, depth: usize) -> Option<PathBuf> {
if depth > 6 {
return None;
}
let entries = fs::read_dir(dir).ok()?;
let mut subdirs = Vec::new();
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
subdirs.push(p);
} else if p.file_name().and_then(|n| n.to_str()) == Some(name) {
return Some(p);
}
}
for d in subdirs {
if let Some(hit) = find_file_by_name(&d, name, depth + 1) {
return Some(hit);
}
}
None
}
fn lit_margin(child: &UiItem, name: &str, resolver: &DimResolver) -> i32 {
let Some(raw) = lookup_assignment(child, name) else {
return 0;
};
resolver.resolve_margin_i32(raw.trim()).unwrap_or(0)
}
fn solve_child_bounds(
child: &UiItem,
base_of: &dyn Fn(&str) -> Option<String>,
resolver: &DimResolver,
) -> ResolvedBounds {
let fill = lookup_assignment(child, "anchors.fill")
.map(|s| s.trim() == "parent")
.unwrap_or(false);
let center_in = lookup_assignment(child, "anchors.centerIn")
.map(|s| s.trim() == "parent")
.unwrap_or(false);
let all_margin = lookup_assignment(child, "anchors.margins")
.and_then(|raw| resolver.resolve_margin_i32(raw.trim()));
let edge = |name: &str, fill_val: Option<&str>| -> Option<String> {
if let Some(v) = lookup_assignment(child, name) {
Some(v.trim().to_string())
} else {
fill_val.map(|s| s.to_string())
}
};
let a_left = edge("anchors.left", fill.then_some("parent.left"));
let a_right = edge("anchors.right", fill.then_some("parent.right"));
let a_top = edge("anchors.top", fill.then_some("parent.top"));
let a_bottom = edge("anchors.bottom", fill.then_some("parent.bottom"));
let a_hcenter = lookup_assignment(child, "anchors.horizontalCenter")
.map(|s| s.trim().to_string())
.or_else(|| center_in.then(|| "parent.horizontalCenter".to_string()));
let a_vcenter = lookup_assignment(child, "anchors.verticalCenter")
.map(|s| s.trim().to_string())
.or_else(|| center_in.then(|| "parent.verticalCenter".to_string()));
let x_lit = lookup_assignment(child, "x").and_then(parse_int_literal);
let y_lit = lookup_assignment(child, "y").and_then(parse_int_literal);
let w_lit = lookup_assignment(child, "width")
.or_else(|| lookup_assignment(child, "implicitWidth"))
.and_then(parse_int_literal);
let h_lit = lookup_assignment(child, "height")
.or_else(|| lookup_assignment(child, "implicitHeight"))
.and_then(parse_int_literal);
let lm = all_margin.unwrap_or_else(|| lit_margin(child, "anchors.leftMargin", resolver));
let rm = all_margin.unwrap_or_else(|| lit_margin(child, "anchors.rightMargin", resolver));
let tm = all_margin.unwrap_or_else(|| lit_margin(child, "anchors.topMargin", resolver));
let bm = all_margin.unwrap_or_else(|| lit_margin(child, "anchors.bottomMargin", resolver));
let add_margin = |expr: RustExpr, m: i32| RustExpr::add(expr, RustExpr::Int(m.into()));
let sub_margin = |expr: RustExpr, m: i32| RustExpr::sub(expr, RustExpr::Int(m.into()));
let w_expr = lookup_assignment(child, "width");
let h_expr = lookup_assignment(child, "height");
let img_natural = if matches!(map_qml_type(&child.type_name), WidgetKind::Image) {
resolver.image_natural_size(child)
} else {
None
};
let default_w = || match w_lit {
Some(n) => RustExpr::Int(n.into()),
None => w_expr
.and_then(|e| resolver.resolve_dim_expr(e.trim()))
.or_else(|| img_natural.map(|(w, _)| RustExpr::Int(w.into())))
.unwrap_or_else(|| RustExpr::atom("bounds.width")),
};
let default_h = || match h_lit {
Some(n) => RustExpr::Int(n.into()),
None => h_expr
.and_then(|e| resolver.resolve_dim_expr(e.trim()))
.or_else(|| img_natural.map(|(_, h)| RustExpr::Int(h.into())))
.unwrap_or_else(|| RustExpr::atom("bounds.height")),
};
let left_e = a_left
.as_deref()
.and_then(|v| anchor_edge_expr(v, base_of))
.map(|e| add_margin(e, lm));
let right_e = a_right
.as_deref()
.and_then(|v| anchor_edge_expr(v, base_of))
.map(|e| sub_margin(e, rm));
let hcenter_e = a_hcenter
.as_deref()
.and_then(|v| anchor_edge_expr(v, base_of));
let (x, w) = match (left_e, right_e, hcenter_e) {
(Some(l), Some(r), _) => {
let w = RustExpr::sub(r, l.clone());
(l, w)
}
(Some(l), None, _) => (l, default_w()),
(None, Some(r), _) => {
let w = default_w();
(RustExpr::sub(r, w.clone()), w)
}
(None, None, Some(c)) => {
let w = default_w();
(
RustExpr::sub(c, RustExpr::div(w.clone(), RustExpr::Int(2))),
w,
)
}
(None, None, None) => (
RustExpr::add(
RustExpr::atom("bounds.x"),
RustExpr::Int(x_lit.unwrap_or(0).into()),
),
default_w(),
),
};
let top_e = a_top
.as_deref()
.and_then(|v| anchor_edge_expr(v, base_of))
.map(|e| add_margin(e, tm));
let bottom_e = a_bottom
.as_deref()
.and_then(|v| anchor_edge_expr(v, base_of))
.map(|e| sub_margin(e, bm));
let vcenter_e = a_vcenter
.as_deref()
.and_then(|v| anchor_edge_expr(v, base_of));
let (y, h) = match (top_e, bottom_e, vcenter_e) {
(Some(t), Some(b), _) => {
let h = RustExpr::sub(b, t.clone());
(t, h)
}
(Some(t), None, _) => (t, default_h()),
(None, Some(b), _) => {
let h = default_h();
(RustExpr::sub(b, h.clone()), h)
}
(None, None, Some(c)) => {
let h = default_h();
(
RustExpr::sub(c, RustExpr::div(h.clone(), RustExpr::Int(2))),
h,
)
}
(None, None, None) => (
RustExpr::add(
RustExpr::atom("bounds.y"),
RustExpr::Int(y_lit.unwrap_or(0).into()),
),
default_h(),
),
};
ResolvedBounds { x, y, w, h }
}
fn emit_solved_child_bounds(
child_fns: &[(String, &UiItem)],
resolver: &DimResolver,
out: &mut String,
) {
use std::collections::BTreeMap;
let n = child_fns.len();
let mut id_to_idx: BTreeMap<String, usize> = BTreeMap::new();
for (i, (_name, child)) in child_fns.iter().enumerate() {
if let Some(id) = &child.id {
id_to_idx.insert(id.clone(), i);
}
}
let base_of = |id: &str| -> Option<String> { id_to_idx.get(id).map(|i| format!("cb_{i}")) };
let mut deps: Vec<Vec<usize>> = vec![Vec::new(); n];
for (i, (_name, child)) in child_fns.iter().enumerate() {
for dep_id in child_anchor_deps(child) {
if let Some(&j) = id_to_idx.get(&dep_id)
&& j != i
{
deps[i].push(j);
}
}
}
fn visit(i: usize, deps: &[Vec<usize>], st: &mut [u8], order: &mut Vec<usize>) {
if st[i] != 0 {
return;
}
st[i] = 1;
for &j in &deps[i] {
visit(j, deps, st, order);
}
st[i] = 2;
order.push(i);
}
let mut order = Vec::with_capacity(n);
let mut st = vec![0u8; n];
for i in 0..n {
visit(i, &deps, &mut st, &mut order);
}
for &i in &order {
let (_name, child) = &child_fns[i];
let rb = solve_child_bounds(child, &base_of, resolver);
out.push_str(&format!(
" let cb_{i} = Rect {{\n x: {},\n y: {},\n \
width: {},\n height: {},\n }};\n",
rb.x.render(),
rb.y.render(),
rb.w.render(),
rb.h.render()
));
}
}
fn emit_child_bounds(child: &UiItem, out: &mut String) {
let fill = lookup_assignment(child, "anchors.fill")
.map(|s| s.trim() == "parent")
.unwrap_or(false);
let margins = lookup_assignment(child, "anchors.margins").and_then(parse_int_literal);
let center_in = lookup_assignment(child, "anchors.centerIn")
.map(|s| s.trim() == "parent")
.unwrap_or(false);
let anchor_left = lookup_assignment(child, "anchors.left").map(|s| s.trim().to_string());
let anchor_right = lookup_assignment(child, "anchors.right").map(|s| s.trim().to_string());
let anchor_top = lookup_assignment(child, "anchors.top").map(|s| s.trim().to_string());
let anchor_bottom = lookup_assignment(child, "anchors.bottom").map(|s| s.trim().to_string());
let edge_count = [&anchor_left, &anchor_right, &anchor_top, &anchor_bottom]
.iter()
.filter(|a| a.is_some())
.count();
let single_edge = edge_count == 1;
let x_edge_count = anchor_left.is_some() as usize + anchor_right.is_some() as usize;
let y_edge_count = anchor_top.is_some() as usize + anchor_bottom.is_some() as usize;
let corner_edge = edge_count == 2 && x_edge_count == 1 && y_edge_count == 1;
for a in &child.assignments {
if !a.target.starts_with("anchors.") {
continue;
}
let lowered = matches!(
a.target.as_str(),
"anchors.fill" | "anchors.margins" | "anchors.centerIn"
) || ((single_edge || corner_edge)
&& matches!(
a.target.as_str(),
"anchors.left" | "anchors.right" | "anchors.top" | "anchors.bottom"
));
if lowered {
continue;
}
if let UiAssignmentValue::Expression { text } = &a.value {
out.push_str(&format!(
" // emitter-skipped (QT-03c+): {}: {}\n",
a.target,
text.trim()
));
}
}
if fill {
if center_in {
out.push_str(" // QT-03c override: anchors.fill supersedes anchors.centerIn\n");
}
if let Some(m) = margins.filter(|m| *m != 0) {
out.push_str(&format!(
" let child_bounds = Rect {{\n \
x: bounds.x + {m},\n y: bounds.y + {m},\n \
width: bounds.width - 2 * {m},\n \
height: bounds.height - 2 * {m},\n }};\n"
));
return;
}
out.push_str(" let child_bounds = bounds;\n");
return;
}
let x_lit = lookup_assignment(child, "x").and_then(parse_int_literal);
let y_lit = lookup_assignment(child, "y").and_then(parse_int_literal);
let w_lit = lookup_assignment(child, "width")
.or_else(|| lookup_assignment(child, "implicitWidth"))
.and_then(parse_int_literal);
let h_lit = lookup_assignment(child, "height")
.or_else(|| lookup_assignment(child, "implicitHeight"))
.and_then(parse_int_literal);
if center_in {
match (w_lit, h_lit) {
(Some(w), Some(h)) => {
if x_lit.is_some() || y_lit.is_some() {
out.push_str(&format!(
" // QT-03c override: anchors.centerIn supersedes literal x: {}, y: {}\n",
x_lit.unwrap_or(0),
y_lit.unwrap_or(0),
));
}
out.push_str(&format!(
" // QT-03c centered: anchors.centerIn: parent (child {w}×{h})\n"
));
out.push_str(&format!(
" let child_bounds = Rect {{\n \
x: bounds.x + (bounds.width - {w}) / 2,\n \
y: bounds.y + (bounds.height - {h}) / 2,\n \
width: {w},\n height: {h},\n }};\n"
));
return;
}
_ => {
out.push_str(
" // QT-03c centerIn: parent (no explicit size — defaulted to parent bounds)\n",
);
}
}
}
if single_edge {
let edge_lowered = lower_single_edge_anchor(
anchor_left.as_deref(),
anchor_right.as_deref(),
anchor_top.as_deref(),
anchor_bottom.as_deref(),
x_lit,
y_lit,
w_lit,
h_lit,
out,
);
if edge_lowered {
return;
}
}
if corner_edge {
let corner_lowered = lower_corner_anchor(
anchor_left.as_deref(),
anchor_right.as_deref(),
anchor_top.as_deref(),
anchor_bottom.as_deref(),
w_lit,
h_lit,
out,
);
if corner_lowered {
return;
}
}
let x = x_lit.unwrap_or(0);
let y = y_lit.unwrap_or(0);
let x_expr = parent_offset_expr("bounds.x", x);
let y_expr = parent_offset_expr("bounds.y", y);
let width = match w_lit {
Some(n) => format!("{n}"),
None => "bounds.width".to_string(),
};
let height = match h_lit {
Some(n) => format!("{n}"),
None => "bounds.height".to_string(),
};
out.push_str(&format!(
" let child_bounds = Rect {{\n \
x: {x_expr},\n y: {y_expr},\n \
width: {width},\n height: {height},\n }};\n"
));
}
fn parent_offset_expr(parent: &str, offset: i32) -> String {
if offset == 0 {
parent.to_string()
} else {
format!("{parent} + {offset}")
}
}
#[allow(clippy::too_many_arguments)]
fn lower_single_edge_anchor(
left: Option<&str>,
right: Option<&str>,
top: Option<&str>,
bottom: Option<&str>,
x_lit: Option<i32>,
y_lit: Option<i32>,
w_lit: Option<i32>,
h_lit: Option<i32>,
out: &mut String,
) -> bool {
let width_expr = match w_lit {
Some(n) => format!("{n}"),
None => "bounds.width".to_string(),
};
let height_expr = match h_lit {
Some(n) => format!("{n}"),
None => "bounds.height".to_string(),
};
if let Some(v) = left {
if v != "parent.left" {
return false;
}
let y = y_lit.unwrap_or(0);
let y_expr = parent_offset_expr("bounds.y", y);
out.push_str(" // QT-03c edge: anchors.left: parent.left\n");
out.push_str(&format!(
" let child_bounds = Rect {{\n \
x: bounds.x,\n y: {y_expr},\n \
width: {width_expr},\n height: {height_expr},\n }};\n"
));
return true;
}
if let Some(v) = right {
if v != "parent.right" {
return false;
}
let Some(w) = w_lit else {
out.push_str(
" // emitter-skipped (QT-03c+): anchors.right: parent.right (no literal width)\n",
);
return false;
};
let y = y_lit.unwrap_or(0);
let y_expr = parent_offset_expr("bounds.y", y);
out.push_str(" // QT-03c edge: anchors.right: parent.right\n");
out.push_str(&format!(
" let child_bounds = Rect {{\n \
x: bounds.x + bounds.width - {w},\n y: {y_expr},\n \
width: {w},\n height: {height_expr},\n }};\n"
));
return true;
}
if let Some(v) = top {
if v != "parent.top" {
return false;
}
let x = x_lit.unwrap_or(0);
let x_expr = parent_offset_expr("bounds.x", x);
out.push_str(" // QT-03c edge: anchors.top: parent.top\n");
out.push_str(&format!(
" let child_bounds = Rect {{\n \
x: {x_expr},\n y: bounds.y,\n \
width: {width_expr},\n height: {height_expr},\n }};\n"
));
return true;
}
if let Some(v) = bottom {
if v != "parent.bottom" {
return false;
}
let Some(h) = h_lit else {
out.push_str(
" // emitter-skipped (QT-03c+): anchors.bottom: parent.bottom (no literal height)\n",
);
return false;
};
let x = x_lit.unwrap_or(0);
let x_expr = parent_offset_expr("bounds.x", x);
out.push_str(" // QT-03c edge: anchors.bottom: parent.bottom\n");
out.push_str(&format!(
" let child_bounds = Rect {{\n \
x: {x_expr},\n y: bounds.y + bounds.height - {h},\n \
width: {width_expr},\n height: {h},\n }};\n"
));
return true;
}
false
}
#[allow(clippy::too_many_arguments)]
fn lower_corner_anchor(
left: Option<&str>,
right: Option<&str>,
top: Option<&str>,
bottom: Option<&str>,
w_lit: Option<i32>,
h_lit: Option<i32>,
out: &mut String,
) -> bool {
let (x_label, x_expr, width_expr) = if let Some(v) = left {
if v != "parent.left" {
return false;
}
let width_expr = match w_lit {
Some(n) => format!("{n}"),
None => "bounds.width".to_string(),
};
("left", "bounds.x".to_string(), width_expr)
} else if let Some(v) = right {
if v != "parent.right" {
return false;
}
let Some(w) = w_lit else {
out.push_str(
" // emitter-skipped (QT-03c+): anchors.right: parent.right (no literal width)\n",
);
return false;
};
(
"right",
format!("bounds.x + bounds.width - {w}"),
format!("{w}"),
)
} else {
return false;
};
let (y_label, y_expr, height_expr) = if let Some(v) = top {
if v != "parent.top" {
return false;
}
let height_expr = match h_lit {
Some(n) => format!("{n}"),
None => "bounds.height".to_string(),
};
("top", "bounds.y".to_string(), height_expr)
} else if let Some(v) = bottom {
if v != "parent.bottom" {
return false;
}
let Some(h) = h_lit else {
out.push_str(
" // emitter-skipped (QT-03c+): anchors.bottom: parent.bottom (no literal height)\n",
);
return false;
};
(
"bottom",
format!("bounds.y + bounds.height - {h}"),
format!("{h}"),
)
} else {
return false;
};
out.push_str(&format!(
" // QT-03c corner: anchors.{x_label}+anchors.{y_label}\n"
));
out.push_str(&format!(
" let child_bounds = Rect {{\n \
x: {x_expr},\n y: {y_expr},\n \
width: {width_expr},\n height: {height_expr},\n }};\n"
));
true
}
fn lookup_assignment<'a>(item: &'a UiItem, target: &str) -> Option<&'a str> {
item.assignments.iter().find_map(|a| {
if a.target != target {
return None;
}
match &a.value {
UiAssignmentValue::Expression { text } => Some(text.as_str()),
_ => None,
}
})
}
fn parse_string_literal(expr: &str) -> Option<String> {
let s = expr.trim();
if s.len() < 2 {
return None;
}
let bytes = s.as_bytes();
let q = bytes[0];
if (q != b'"' && q != b'\'') || bytes[bytes.len() - 1] != q {
return None;
}
let inner = &s[1..s.len() - 1];
if inner.bytes().any(|b| b == q) {
return None;
}
Some(inner.to_string())
}
fn parse_int_literal(expr: &str) -> Option<i32> {
expr.trim().parse::<i32>().ok()
}
fn parse_qml_color_lit(expr: &str) -> Option<(u8, u8, u8, u8)> {
let s = parse_string_literal(expr)?;
let s = s.strip_prefix('#')?;
let parse_byte = |i: usize| u8::from_str_radix(s.get(i..i + 2)?, 16).ok();
match s.len() {
6 => Some((parse_byte(0)?, parse_byte(2)?, parse_byte(4)?, 0xff)),
8 => Some((
parse_byte(2)?,
parse_byte(4)?,
parse_byte(6)?,
parse_byte(0)?,
)),
_ => None,
}
}
#[derive(Debug, Clone)]
struct StateField {
name: String,
qml_prop: String,
owner_id: Option<String>,
ty: StateFieldType,
init_expr: String,
init_comment: Option<String>,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum StateFieldType {
I32,
F32,
Bool,
StringTy,
}
impl StateFieldType {
fn rust(self) -> &'static str {
match self {
StateFieldType::I32 => "i32",
StateFieldType::F32 => "f32",
StateFieldType::Bool => "bool",
StateFieldType::StringTy => "String",
}
}
fn from_qml(qml_ty: &str) -> Option<Self> {
match qml_ty {
"int" => Some(StateFieldType::I32),
"real" | "double" => Some(StateFieldType::F32),
"bool" => Some(StateFieldType::Bool),
"string" => Some(StateFieldType::StringTy),
_ => None,
}
}
fn rust_default(self) -> &'static str {
match self {
StateFieldType::I32 => "0",
StateFieldType::F32 => "0.0",
StateFieldType::Bool => "false",
StateFieldType::StringTy => "String::new()",
}
}
}
fn collect_state_fields(root: &UiItem) -> Vec<StateField> {
let mut out = Vec::new();
let mut seen_field_names = Vec::<String>::new();
collect_state_fields_walk(root, true, &mut out, &mut seen_field_names);
out
}
fn collect_state_fields_walk(
item: &UiItem,
is_root: bool,
out: &mut Vec<StateField>,
seen: &mut Vec<String>,
) {
let owner_id = if is_root { None } else { item.id.clone() };
for prop in &item.properties {
let Some(ty) = StateFieldType::from_qml(&prop.ty) else {
continue;
};
if !is_root && owner_id.is_none() {
continue;
}
let base_field_name = match &owner_id {
None => rust_snake_ident(&prop.name),
Some(id) => rust_snake_ident(&format!("{id}_{}", prop.name)),
};
let mut field_name = base_field_name.clone();
let mut suffix = 2usize;
while seen.contains(&field_name) {
field_name = format!("{base_field_name}_{suffix}");
suffix += 1;
}
seen.push(field_name.clone());
let (init_expr, init_comment) = lower_property_default(prop, ty);
out.push(StateField {
name: field_name,
qml_prop: prop.name.clone(),
owner_id: owner_id.clone(),
ty,
init_expr,
init_comment,
});
}
for child in &item.children {
collect_state_fields_walk(child, false, out, seen);
}
}
fn lower_property_default(prop: &UiProperty, ty: StateFieldType) -> (String, Option<String>) {
let Some(default) = prop.default_value.as_deref() else {
return (ty.rust_default().to_string(), None);
};
match ty {
StateFieldType::I32 => match parse_int_literal(default) {
Some(n) => (format!("{n}"), None),
None => (
ty.rust_default().to_string(),
Some(format!(
"QT-04b: non-literal default for `{}`: {}",
prop.name, default
)),
),
},
StateFieldType::F32 => match parse_float_literal(default) {
Some(f) => {
let mut s = format!("{f}");
if !s.contains('.') {
s.push_str(".0");
}
(s, None)
}
None => (
ty.rust_default().to_string(),
Some(format!(
"QT-04b: non-literal default for `{}`: {}",
prop.name, default
)),
),
},
StateFieldType::Bool => match parse_bool_literal(default) {
Some(b) => (format!("{b}"), None),
None => (
ty.rust_default().to_string(),
Some(format!(
"QT-04b: non-literal default for `{}`: {}",
prop.name, default
)),
),
},
StateFieldType::StringTy => match parse_string_literal(default) {
Some(s) => (format!("String::from({})", rust_str_lit(&s)), None),
None => (
ty.rust_default().to_string(),
Some(format!(
"QT-04b: non-literal default for `{}`: {}",
prop.name, default
)),
),
},
}
}
fn emit_screen_state_struct(fields: &[StateField], out: &mut String) {
out.push_str(
"/// State threaded through every helper. One field per\n\
/// QT-04b §5-supported property declared on the QML root.\n\
/// `#[rustfmt::skip]` so the field order matches QT-01a's IR\n\
/// declaration order rather than rustfmt's alphabetical sort.\n\
#[rustfmt::skip]\n\
#[derive(Debug, Clone)]\n\
pub struct ScreenState {\n",
);
if fields.is_empty() {
out.push_str(" // QT-04b: root declares no QT-04b §5-supported properties.\n");
} else {
for f in fields {
out.push_str(&format!(" pub {}: {},\n", f.name, f.ty.rust()));
}
}
out.push_str("}\n\n");
}
fn emit_screen_state_init(fields: &[StateField], out: &mut String) {
if fields.is_empty() {
out.push_str(" let state = Rc::new(RefCell::new(ScreenState {}));\n");
return;
}
out.push_str(" let state = Rc::new(RefCell::new(ScreenState {\n");
for f in fields {
if let Some(comment) = &f.init_comment {
out.push_str(&format!(" // {comment}\n"));
}
out.push_str(&format!(" {}: {},\n", f.name, f.init_expr));
}
out.push_str(" }));\n");
}
fn emit_binding_sink_struct(out: &mut String) {
out.push_str(
"struct BindingSink<'a, T> {\n \
values: &'a mut Vec<T>,\n}\n\n\
impl<'a, T> BindingSink<'a, T> {\n \
fn new(values: &'a mut Vec<T>) -> Self {\n \
Self { values }\n \
}\n\n \
fn push(&mut self, value: T) {\n \
self.values.push(value);\n \
}\n}\n\n",
);
}
fn emit_built_screen_alias(out: &mut String, has_sm: bool) {
if has_sm {
out.push_str(
"/// Widget tree, screen state, state machine, and reactive bindings\n\
/// returned by [`build_screen`].\n\
#[rustfmt::skip]\n\
pub type BuiltScreen = (\n \
WidgetNode,\n \
Rc<RefCell<ScreenState>>,\n \
Rc<RefCell<Machine>>,\n \
Vec<Binding>,\n\
);\n\n",
);
} else {
out.push_str(
"/// Widget tree, screen state, and reactive label bindings returned\n\
/// by [`build_screen`].\n\
#[rustfmt::skip]\n\
pub type BuiltScreen = (\n \
WidgetNode,\n \
Rc<RefCell<ScreenState>>,\n \
Vec<LabelBinding>,\n\
);\n\n",
);
}
}
fn emit_label_binding_struct(out: &mut String) {
out.push_str(
"/// Reactive Label-text binding emitted by QT-04e §3. Each\n\
/// entry pairs a concrete `Rc<RefCell<Label>>` with an\n\
/// accessor that reads the bound state field. Use\n\
/// [`refresh_bindings`] to re-apply every binding in one call.\n\
pub struct LabelBinding {\n \
pub label: Rc<RefCell<Label>>,\n \
pub accessor: fn(&ScreenState) -> String,\n}\n\n\
impl LabelBinding {\n \
/// Re-apply this binding from the supplied state.\n \
pub fn refresh(&self, state: &ScreenState) {\n \
self.label.borrow_mut().set_text((self.accessor)(state));\n \
}\n}\n\n",
);
}
fn emit_external_text_binding_struct(out: &mut String) {
out.push_str(
"/// QT-05k §3 — reactive Label-text binding sourced from an external\n\
/// object property (e.g. `audioPlayer.currentPlayUrlFileName`). `key`\n\
/// is the QML expression passed through verbatim (authority: derive);\n\
/// the string value is supplied by a consumer resolver via\n\
/// [`apply_external_text`]. The matching `(node tag, key)` pair is also\n\
/// surfaced in [`EXTERNAL_TEXT_BINDINGS`] for tag-keyed consumers.\n\
pub struct ExternalTextBinding {\n \
pub label: Rc<RefCell<Label>>,\n \
pub key: &'static str,\n}\n\n",
);
}
fn emit_machine_binding_struct(out: &mut String) {
out.push_str(
"/// QT-05c §3 — reactive Label-text binding sourced from the\n\
/// state-machine `DataModel`. Mirrors `LabelBinding` with\n\
/// `&DataModel` instead of `&ScreenState` as the accessor input.\n\
pub struct MachineBinding {\n \
pub label: Rc<RefCell<Label>>,\n \
pub accessor: fn(&DataModel) -> String,\n}\n\n\
impl MachineBinding {\n \
/// Re-apply this binding from the supplied DataModel.\n \
pub fn refresh(&self, dm: &DataModel) {\n \
self.label.borrow_mut().set_text((self.accessor)(dm));\n \
}\n}\n\n",
);
}
fn emit_binding_enum(out: &mut String) {
out.push_str(
"/// QT-05c §3 — sealed enum over the binding sources reactive\n\
/// `refresh_bindings` knows how to drive. `Label` reads from\n\
/// `ScreenState`; `Machine` reads from `<sm>_gen::DataModel`.\n\
pub enum Binding {\n \
Label(LabelBinding),\n \
Machine(MachineBinding),\n}\n\n",
);
}
fn emit_image_art_struct(out: &mut String) {
out.push_str(
"/// QT-05g §3 — a decoded, magenta-keyed, `'static`-leaked artwork\n\
/// buffer plus its natural dimensions.\n\
#[derive(Clone, Copy)]\n\
pub struct ImageArt {\n \
pub width: i32,\n \
pub height: i32,\n \
pub pixels: &'static [Color],\n}\n\n",
);
}
fn emit_predicate_binding_struct(out: &mut String) {
out.push_str(
"/// QT-05g §3 — reactive `Image`-source binding. Shows `on` when the\n\
/// bound state is active, else `off`. `state_id` is the QML predicate\n\
/// passed through verbatim to `Machine::is_active` (authority: derive).\n\
pub struct PredicateBinding {\n \
pub image: Rc<RefCell<Image<'static>>>,\n \
pub state_id: &'static str,\n \
pub on: ImageArt,\n \
pub off: ImageArt,\n}\n\n\
impl PredicateBinding {\n \
/// Re-apply this binding from the supplied machine.\n \
pub fn refresh(&self, machine: &Machine) {\n \
let art = if machine.is_active(self.state_id) {\n \
&self.on\n \
} else {\n \
&self.off\n \
};\n \
self.image\n \
.borrow_mut()\n \
.set_pixels(art.width, art.height, art.pixels);\n \
}\n}\n\n",
);
}
fn emit_visibility_binding_struct(out: &mut String) {
out.push_str(
"/// QT-05h §3 — reactive `Image`-visibility binding driven by\n\
/// `Machine::is_active`. `state_id` is the QML predicate passed\n\
/// through verbatim (authority: derive).\n\
pub struct VisibilityBinding {\n \
pub image: Rc<RefCell<Image<'static>>>,\n \
pub state_id: &'static str,\n}\n\n\
impl VisibilityBinding {\n \
/// Hide the Image when the bound state is inactive, else show it.\n \
pub fn refresh(&self, machine: &Machine) {\n \
self.image\n \
.borrow_mut()\n \
.set_hidden(!machine.is_active(self.state_id));\n \
}\n}\n\n",
);
}
fn emit_predicate_chain_binding_struct(out: &mut String) {
out.push_str(
"/// QT-05i §3 — one arm of a chained predicate binding: the artwork\n\
/// shown when `state_id` is the first active arm.\n\
pub struct PredicateArm {\n \
pub state_id: &'static str,\n \
pub art: ImageArt,\n}\n\n\
/// QT-05i §3 — reactive `Image`-source binding driven by a chain of\n\
/// `Machine::is_active` checks (first-true wins; `default` is the\n\
/// resting else). Each `state_id` is the QML predicate passed through\n\
/// verbatim (authority: derive).\n\
pub struct PredicateChainBinding {\n \
pub image: Rc<RefCell<Image<'static>>>,\n \
pub arms: Vec<PredicateArm>,\n \
pub default: ImageArt,\n}\n\n\
impl PredicateChainBinding {\n \
/// Re-apply this binding: show the first active arm's artwork,\n \
/// else the resting default.\n \
pub fn refresh(&self, machine: &Machine) {\n \
let art = self\n \
.arms\n \
.iter()\n \
.find(|a| machine.is_active(a.state_id))\n \
.map(|a| &a.art)\n \
.unwrap_or(&self.default);\n \
self.image\n \
.borrow_mut()\n \
.set_pixels(art.width, art.height, art.pixels);\n \
}\n}\n\n",
);
}
fn emit_binding_enum_v2(
out: &mut String,
used_predicate: bool,
used_visibility: bool,
used_predicate_chain: bool,
used_external_text: bool,
) {
out.push_str(
"/// QT-05g §3 / QT-05h §3 / QT-05i §3 — sealed enum over the binding\n\
/// sources reactive `refresh_bindings` knows how to drive.\n\
pub enum Binding {\n \
Label(LabelBinding),\n",
);
if used_predicate {
out.push_str(" Predicate(PredicateBinding),\n");
}
if used_visibility {
out.push_str(" Visibility(VisibilityBinding),\n");
}
if used_predicate_chain {
out.push_str(" Chain(PredicateChainBinding),\n");
}
if used_external_text {
out.push_str(" ExternalText(ExternalTextBinding),\n");
}
out.push_str("}\n\n");
}
fn emit_apply_external_text_fn(out: &mut String) {
out.push_str(
"/// QT-05k §6 — apply a consumer-owned resolver to every external-text\n\
/// binding. `resolve(key)` returns the current value for the verbatim\n\
/// QML key (e.g. `\"audioPlayer.currentPlayUrlFileName\"`); `None` leaves\n\
/// the Label unchanged. Call whenever the external source may have\n\
/// changed (every frame, or on a data-change signal). No-op when there\n\
/// are no external-text bindings.\n\
pub fn apply_external_text(bindings: &[Binding], resolve: impl Fn(&str) -> Option<String>) {\n \
for b in bindings {\n \
if let Binding::ExternalText(t) = b\n \
&& let Some(v) = resolve(t.key)\n \
{\n \
t.label.borrow_mut().set_text(v);\n \
}\n \
}\n}\n\n",
);
}
fn emit_refresh_bindings_fn(
out: &mut String,
has_sm: bool,
v2: bool,
used_predicate: bool,
used_visibility: bool,
used_predicate_chain: bool,
used_external_text: bool,
) {
if has_sm && v2 {
let mut arms =
String::from(" Binding::Label(lb) => lb.refresh(&s),\n");
if used_predicate {
arms.push_str(" Binding::Predicate(pb) => pb.refresh(&m),\n");
}
if used_visibility {
arms.push_str(" Binding::Visibility(vb) => vb.refresh(&m),\n");
}
if used_predicate_chain {
arms.push_str(" Binding::Chain(cb) => cb.refresh(&m),\n");
}
if used_external_text {
arms.push_str(" Binding::ExternalText(_) => {}\n");
}
out.push_str(&format!(
"/// Re-apply every QT-04e / QT-05g / QT-05h binding from the\n\
/// current state and machine. Idempotent; safe to call after any\n\
/// `machine.step(…)`. No-op when `bindings` is empty.\n\
#[rustfmt::skip]\n\
pub fn refresh_bindings(state: &Rc<RefCell<ScreenState>>, machine: &Rc<RefCell<Machine>>, bindings: &[Binding]) {{\n \
let s = state.borrow();\n \
let m = machine.borrow();\n \
for b in bindings {{\n \
match b {{\n\
{arms} \
}}\n \
}}\n}}\n\n"
));
return;
}
if has_sm {
out.push_str(
"/// Re-apply every QT-04e / QT-05c binding from the current\n\
/// state and machine. Idempotent; safe to call after any\n\
/// mutation. No-op when `bindings` is empty.\n\
#[rustfmt::skip]\n\
pub fn refresh_bindings(state: &Rc<RefCell<ScreenState>>, machine: &Rc<RefCell<Machine>>, bindings: &[Binding]) {\n \
let s = state.borrow();\n \
let m = machine.borrow();\n \
for b in bindings {\n \
match b {\n \
Binding::Label(lb) => lb.refresh(&s),\n \
Binding::Machine(mb) => mb.refresh(&m.dm),\n \
}\n \
}\n}\n\n",
);
} else {
out.push_str(
"/// Re-apply every QT-04e binding from the current state.\n\
/// Idempotent; safe to call after every `state.borrow_mut()`\n\
/// mutation. No-op when `bindings` is empty.\n\
pub fn refresh_bindings(state: &Rc<RefCell<ScreenState>>, bindings: &[LabelBinding]) {\n \
let s = state.borrow();\n \
for b in bindings {\n \
b.refresh(&s);\n \
}\n}\n\n",
);
}
}
fn parse_float_literal(expr: &str) -> Option<f32> {
expr.trim().parse::<f32>().ok()
}
fn parse_bool_literal(expr: &str) -> Option<bool> {
match expr.trim() {
"true" => Some(true),
"false" => Some(false),
_ => None,
}
}
fn emit_qt04b_or_qt04_handler(
body: &str,
state_fields: &[StateField],
sm_id: Option<&str>,
out: &mut String,
) {
emit_qt04b_or_qt04_handler_for("button", body, state_fields, sm_id, out);
}
fn emit_qt04b_or_qt04_handler_for(
binding: &str,
body: &str,
state_fields: &[StateField],
sm_id: Option<&str>,
out: &mut String,
) {
if sm_id.is_some()
&& let Some(events) = lower_dispatch_body(body)
{
out.push_str(" {\n let machine = Rc::clone(&machine);\n");
out.push_str(&format!(" {binding}.set_on_click(move |_b| {{\n"));
out.push_str(" let mut m = machine.borrow_mut();\n");
for ev in &events {
out.push_str(&format!(
" // QT-05b dispatch: {qml} → Event::{pascal}\n",
qml = ev.qml,
pascal = ev.pascal,
));
out.push_str(&format!(
" m.dispatch(Event::{pascal});\n",
pascal = ev.pascal,
));
}
out.push_str(" });\n }\n");
return;
}
if let Some(stmts) = lower_handler_body(body, state_fields) {
out.push_str(" {\n let state = Rc::clone(&state);\n");
out.push_str(&format!(" {binding}.set_on_click(move |_b| {{\n"));
out.push_str(" let mut s = state.borrow_mut();\n");
for line in body.trim().lines() {
out.push_str(&format!(" // QT-04b body: {line}\n"));
}
for stmt in stmts {
out.push_str(&format!(" {stmt}\n"));
}
out.push_str(" });\n }\n");
} else {
emit_qt04_handler_body(body, out);
out.push_str(&format!(
" {binding}.set_on_click(|_b| {{\n \
// TODO QT-04e: lower QML expression to Rust.\n }});\n"
));
}
}
struct DispatchEvent {
qml: String,
pascal: String,
}
fn lower_dispatch_body(body: &str) -> Option<Vec<DispatchEvent>> {
let cleaned = body.trim();
if cleaned.is_empty() {
return None;
}
let mut events = Vec::new();
for raw_stmt in cleaned.split(';') {
let stmt = raw_stmt.trim();
if stmt.is_empty() {
continue;
}
events.push(parse_dispatch_call(stmt)?);
}
if events.is_empty() {
None
} else {
Some(events)
}
}
fn parse_dispatch_call(stmt: &str) -> Option<DispatchEvent> {
let after = stmt.strip_prefix("dispatch")?;
let after = after.trim_start();
let inner = after.strip_prefix('(')?.strip_suffix(')')?.trim();
let qml = if let Some(s) = inner.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
s
} else if let Some(s) = inner.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) {
s
} else {
return None;
};
if qml.is_empty() {
return None;
}
let pascal = pascal_case_event(qml)?;
Some(DispatchEvent {
qml: qml.to_string(),
pascal,
})
}
fn pascal_case_event(input: &str) -> Option<String> {
let mut chars = input.chars();
let first = chars.next()?;
if first.is_ascii_digit() {
return None;
}
let mut out = String::new();
let mut next_upper = true;
for ch in input.chars() {
if matches!(ch, '_' | '-' | '.' | ' ') {
next_upper = true;
continue;
}
if !ch.is_ascii_alphanumeric() {
return None;
}
if next_upper {
for u in ch.to_uppercase() {
out.push(u);
}
} else {
for l in ch.to_lowercase() {
out.push(l);
}
}
next_upper = false;
}
if out.is_empty() { None } else { Some(out) }
}
fn lower_handler_body(body: &str, state_fields: &[StateField]) -> Option<Vec<String>> {
let cleaned = body.trim();
if cleaned.is_empty() {
return None;
}
let mut stmts = Vec::new();
for raw_stmt in cleaned.split(';') {
let stmt = raw_stmt.trim();
if stmt.is_empty() {
continue;
}
let lowered = lower_handler_statement(stmt, state_fields)?;
stmts.push(lowered);
}
if stmts.is_empty() { None } else { Some(stmts) }
}
fn lower_handler_statement(stmt: &str, state_fields: &[StateField]) -> Option<String> {
let (op, lhs_raw, rhs_raw) = split_assignment(stmt)?;
let field = resolve_state_field_ref(lhs_raw, state_fields)?;
match op {
AssignOp::PlusEq => match field.ty {
StateFieldType::I32 => {
let n = parse_int_literal(rhs_raw)?;
Some(format!(
"s.{} = s.{}.saturating_add({});",
field.name, field.name, n
))
}
StateFieldType::F32 => {
let f = parse_float_literal(rhs_raw)?;
let lit = if f.fract() == 0.0 {
format!("{f:.1}")
} else {
format!("{f}")
};
Some(format!("s.{} += {};", field.name, lit))
}
StateFieldType::StringTy => {
let s = parse_string_literal(rhs_raw)?;
Some(format!("s.{}.push_str({});", field.name, rust_str_lit(&s)))
}
StateFieldType::Bool => None,
},
AssignOp::MinusEq => match field.ty {
StateFieldType::I32 => {
let n = parse_int_literal(rhs_raw)?;
Some(format!(
"s.{} = s.{}.saturating_sub({});",
field.name, field.name, n
))
}
StateFieldType::F32 => {
let f = parse_float_literal(rhs_raw)?;
let lit = if f.fract() == 0.0 {
format!("{f:.1}")
} else {
format!("{f}")
};
Some(format!("s.{} -= {};", field.name, lit))
}
_ => None,
},
AssignOp::Eq => {
if field.ty == StateFieldType::Bool
&& let Some(toggled_raw) = rhs_raw.strip_prefix('!')
&& let Some(toggled_field) =
resolve_state_field_ref(toggled_raw.trim(), state_fields)
&& toggled_field.name == field.name
{
return Some(format!("s.{} = !s.{};", field.name, field.name));
}
match field.ty {
StateFieldType::I32 => {
let n = parse_int_literal(rhs_raw)?;
Some(format!("s.{} = {};", field.name, n))
}
StateFieldType::F32 => {
let f = parse_float_literal(rhs_raw)?;
let lit = if f.fract() == 0.0 {
format!("{f:.1}")
} else {
format!("{f}")
};
Some(format!("s.{} = {};", field.name, lit))
}
StateFieldType::Bool => {
let b = parse_bool_literal(rhs_raw)?;
Some(format!("s.{} = {};", field.name, b))
}
StateFieldType::StringTy => {
let s = parse_string_literal(rhs_raw)?;
Some(format!(
"s.{} = String::from({});",
field.name,
rust_str_lit(&s)
))
}
}
}
}
}
#[derive(Debug, Copy, Clone)]
enum AssignOp {
Eq,
PlusEq,
MinusEq,
}
fn split_assignment(stmt: &str) -> Option<(AssignOp, &str, &str)> {
if let Some(idx) = stmt.find("+=") {
return Some((AssignOp::PlusEq, stmt[..idx].trim(), stmt[idx + 2..].trim()));
}
if let Some(idx) = stmt.find("-=") {
return Some((
AssignOp::MinusEq,
stmt[..idx].trim(),
stmt[idx + 2..].trim(),
));
}
let bytes = stmt.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'=' {
let prev = if i > 0 { Some(bytes[i - 1]) } else { None };
let next = bytes.get(i + 1).copied();
if matches!(prev, Some(b'!' | b'<' | b'>' | b'=')) || next == Some(b'=') {
i += 1;
continue;
}
return Some((AssignOp::Eq, stmt[..i].trim(), stmt[i + 1..].trim()));
}
i += 1;
}
None
}
fn resolve_state_field_ref<'a>(
expr: &str,
state_fields: &'a [StateField],
) -> Option<&'a StateField> {
let s = expr.trim();
if s.is_empty() {
return None;
}
if !s.contains('.') {
if !is_simple_ident(s) {
return None;
}
return state_fields
.iter()
.find(|f| f.owner_id.is_none() && f.qml_prop == s);
}
let mut parts = s.split('.');
let owner = parts.next()?.trim();
let prop = parts.next()?.trim();
if parts.next().is_some() {
return None;
}
if !is_simple_ident(owner) || !is_simple_ident(prop) {
return None;
}
if owner == "root" {
return state_fields
.iter()
.find(|f| f.owner_id.is_none() && f.qml_prop == prop);
}
state_fields
.iter()
.find(|f| f.owner_id.as_deref() == Some(owner) && f.qml_prop == prop)
}
fn is_simple_ident(s: &str) -> bool {
!s.is_empty()
&& s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
&& !s.bytes().next().is_some_and(|b| b.is_ascii_digit())
}
fn resolve_string_state_ref<'a>(expr: &str, state_fields: &'a [StateField]) -> Option<&'a str> {
let field = resolve_state_field_ref(expr, state_fields)?;
if field.ty != StateFieldType::StringTy {
return None;
}
Some(field.name.as_str())
}
fn parse_dm_text_ref(expr: &str) -> Option<String> {
let trimmed = expr.trim();
let after = trimmed.strip_prefix("sm.dm.")?;
if after.is_empty() {
return None;
}
if !after.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return None;
}
if after
.chars()
.next()
.map(|c| c.is_ascii_digit())
.unwrap_or(true)
{
return None;
}
Some(after.to_string())
}
fn rust_snake_ident(input: &str) -> String {
let chars: Vec<char> = input.chars().collect();
let mut out = String::with_capacity(input.len());
let mut previous_source: Option<char> = None;
for (index, ch) in chars.iter().copied().enumerate() {
if !ch.is_ascii_alphanumeric() {
if !out.ends_with('_') {
out.push('_');
}
previous_source = Some(ch);
continue;
}
if ch.is_ascii_uppercase() {
let next_is_lower = chars
.get(index + 1)
.is_some_and(|next| next.is_ascii_lowercase());
let boundary = previous_source.is_some_and(|previous| {
previous.is_ascii_lowercase()
|| previous.is_ascii_digit()
|| (previous.is_ascii_uppercase() && next_is_lower)
});
if boundary && !out.ends_with('_') {
out.push('_');
}
out.push(ch.to_ascii_lowercase());
} else {
out.push(ch);
}
previous_source = Some(ch);
}
while out.ends_with('_') {
out.pop();
}
while out.starts_with('_') {
out.remove(0);
}
if out.is_empty() {
out.push_str("generated");
}
if out.as_bytes()[0].is_ascii_digit() {
out.insert_str(0, "n_");
}
if is_rust_keyword(&out) {
out.push('_');
}
out
}
fn is_rust_keyword(ident: &str) -> bool {
matches!(
ident,
"abstract"
| "as"
| "async"
| "await"
| "become"
| "box"
| "break"
| "const"
| "continue"
| "crate"
| "dyn"
| "do"
| "else"
| "enum"
| "extern"
| "false"
| "final"
| "fn"
| "for"
| "gen"
| "if"
| "impl"
| "in"
| "let"
| "loop"
| "macro"
| "match"
| "mod"
| "move"
| "mut"
| "override"
| "priv"
| "pub"
| "ref"
| "return"
| "self"
| "Self"
| "static"
| "struct"
| "super"
| "trait"
| "true"
| "try"
| "type"
| "typeof"
| "union"
| "unsafe"
| "unsized"
| "use"
| "virtual"
| "where"
| "while"
| "yield"
)
}
pub(crate) fn ingest(input: &Path, out: &Path) -> Result<()> {
if input.is_dir() {
return ingest_dir(input, out);
}
let source =
fs::read_to_string(input).with_context(|| format!("reading {}", input.display()))?;
let mut module =
parse_module(&source, input).with_context(|| format!("parsing {}", input.display()))?;
attach_scjson_side_file(&mut module, input)?;
fs::create_dir_all(out).with_context(|| format!("creating output dir {}", out.display()))?;
let out_path: PathBuf = out.join("qt-ir.json");
let json = serde_json::to_string_pretty(&module)?;
fs::write(&out_path, json).with_context(|| format!("writing {}", out_path.display()))?;
Ok(())
}
fn ingest_dir(input: &Path, out: &Path) -> Result<()> {
fs::create_dir_all(out).with_context(|| format!("creating output dir {}", out.display()))?;
for qml in qt08_collect_qml_files(input)? {
let source =
fs::read_to_string(&qml).with_context(|| format!("reading {}", qml.display()))?;
let mut module =
parse_module(&source, &qml).with_context(|| format!("parsing {}", qml.display()))?;
attach_scjson_side_file(&mut module, &qml)?;
let stem = qml
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("input has no usable file stem: {}", qml.display()))?;
let out_path = out.join(format!("{stem}.qt-ir.json"));
let json = serde_json::to_string_pretty(&module)?;
fs::write(&out_path, json).with_context(|| format!("writing {}", out_path.display()))?;
}
Ok(())
}
fn qt08_collect_qml_files(dir: &Path) -> Result<Vec<PathBuf>> {
let read = fs::read_dir(dir).with_context(|| format!("reading dir {}", dir.display()))?;
let mut files: Vec<PathBuf> = Vec::new();
let mut seen_stems: Vec<String> = Vec::new();
for entry in read {
let entry = entry.with_context(|| format!("iterating {}", dir.display()))?;
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
if name.starts_with('.') {
continue;
}
if path.extension().and_then(|s| s.to_str()) != Some("qml") {
continue;
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("no usable file stem: {}", path.display()))?
.to_string();
if seen_stems.contains(&stem) {
bail!(
"duplicate basename `{stem}` in {} — refusing to clobber output",
dir.display()
);
}
seen_stems.push(stem);
files.push(path);
}
files.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
Ok(files)
}
pub fn parse_module(source: &str, source_path: &Path) -> Result<UiModule> {
let mut p = Parser::new(source);
let mut imports = Vec::new();
let mut pragmas = Vec::new();
p.skip_trivia();
while !p.eof() {
if p.match_keyword("import") {
imports.push(p.parse_import()?);
p.skip_trivia();
continue;
}
if p.match_keyword("pragma") {
pragmas.push(p.parse_pragma()?);
p.skip_trivia();
continue;
}
break;
}
let root = p.parse_item().context("parsing root item")?;
p.skip_trivia();
if !p.eof() {
let (line, col) = p.line_col();
bail!(
"unexpected trailing content at line {line}, column {col} (expected single root item)"
);
}
Ok(UiModule {
version: QT_IR_VERSION,
source: source_path.display().to_string(),
imports,
pragmas,
root,
state_machine: None,
})
}
struct Parser<'a> {
src: &'a [u8],
pos: usize,
}
impl<'a> Parser<'a> {
fn new(src: &'a str) -> Self {
Self {
src: src.as_bytes(),
pos: 0,
}
}
fn eof(&self) -> bool {
self.pos >= self.src.len()
}
fn peek(&self) -> Option<u8> {
self.src.get(self.pos).copied()
}
fn peek_at(&self, off: usize) -> Option<u8> {
self.src.get(self.pos + off).copied()
}
fn line_col(&self) -> (usize, usize) {
let mut line = 1usize;
let mut col = 1usize;
for &b in &self.src[..self.pos] {
if b == b'\n' {
line += 1;
col = 1;
} else {
col += 1;
}
}
(line, col)
}
fn skip_trivia(&mut self) {
loop {
match self.peek() {
Some(b) if (b as char).is_whitespace() => {
self.pos += 1;
}
Some(b'/') if self.peek_at(1) == Some(b'/') => {
while let Some(b) = self.peek() {
self.pos += 1;
if b == b'\n' {
break;
}
}
}
Some(b'/') if self.peek_at(1) == Some(b'*') => {
self.pos += 2;
while !self.eof() {
if self.peek() == Some(b'*') && self.peek_at(1) == Some(b'/') {
self.pos += 2;
break;
}
self.pos += 1;
}
}
_ => return,
}
}
}
fn match_keyword(&mut self, kw: &str) -> bool {
self.skip_trivia();
let bytes = kw.as_bytes();
if self.pos + bytes.len() > self.src.len() {
return false;
}
if &self.src[self.pos..self.pos + bytes.len()] != bytes {
return false;
}
if let Some(next) = self.src.get(self.pos + bytes.len()).copied()
&& is_ident_cont(next)
{
return false;
}
self.pos += bytes.len();
true
}
fn expect(&mut self, ch: u8, what: &str) -> Result<()> {
self.skip_trivia();
match self.peek() {
Some(b) if b == ch => {
self.pos += 1;
Ok(())
}
_ => {
let (line, col) = self.line_col();
bail!(
"expected `{}` ({what}) at line {line}, column {col}",
ch as char
);
}
}
}
fn read_ident(&mut self) -> Result<String> {
self.skip_trivia();
let start = self.pos;
match self.peek() {
Some(b) if is_ident_start(b) => self.pos += 1,
_ => {
let (line, col) = self.line_col();
bail!("expected identifier at line {line}, column {col}");
}
}
while let Some(b) = self.peek() {
if is_ident_cont(b) {
self.pos += 1;
} else {
break;
}
}
Ok(std::str::from_utf8(&self.src[start..self.pos])?.to_string())
}
fn read_dotted_ident(&mut self) -> Result<String> {
let mut s = self.read_ident()?;
loop {
self.skip_trivia();
if self.peek() == Some(b'.') {
let after = self.peek_at(1);
if matches!(after, Some(b) if is_ident_start(b)) {
self.pos += 1;
s.push('.');
s.push_str(&self.read_ident()?);
continue;
}
}
break;
}
Ok(s)
}
fn read_string_literal(&mut self) -> Result<String> {
self.skip_trivia();
let q = self.peek();
if !matches!(q, Some(b'"') | Some(b'\'')) {
let (line, col) = self.line_col();
bail!("expected string literal at line {line}, column {col}");
}
let start = self.pos;
let quote = q.unwrap();
self.pos += 1;
while let Some(b) = self.peek() {
self.pos += 1;
if b == b'\\' {
self.pos += 1; continue;
}
if b == quote {
break;
}
}
Ok(std::str::from_utf8(&self.src[start..self.pos])?.to_string())
}
fn parse_import(&mut self) -> Result<UiImport> {
self.skip_trivia();
let module = if matches!(self.peek(), Some(b'"') | Some(b'\'')) {
let raw = self.read_string_literal()?;
raw.trim_matches(|c| c == '"' || c == '\'').to_string()
} else {
self.read_dotted_ident()?
};
self.skip_trivia();
let version = if matches!(self.peek(), Some(b'0'..=b'9')) {
let start = self.pos;
while let Some(b) = self.peek() {
if b.is_ascii_digit() || b == b'.' {
self.pos += 1;
} else {
break;
}
}
Some(std::str::from_utf8(&self.src[start..self.pos])?.to_string())
} else {
None
};
let alias = if self.match_keyword("as") {
Some(self.read_ident()?)
} else {
None
};
self.skip_trivia();
if self.peek() == Some(b';') {
self.pos += 1;
}
Ok(UiImport {
module,
version,
alias,
})
}
fn parse_pragma(&mut self) -> Result<String> {
self.skip_trivia();
let start = self.pos;
while let Some(b) = self.peek() {
if b == b'\n' || b == b';' {
break;
}
self.pos += 1;
}
let s = std::str::from_utf8(&self.src[start..self.pos])?
.trim()
.to_string();
if self.peek() == Some(b';') {
self.pos += 1;
}
Ok(s)
}
fn parse_item(&mut self) -> Result<UiItem> {
let type_name = self.read_dotted_ident().context("expected QML type name")?;
self.skip_trivia();
self.expect(b'{', "after type name")?;
let mut item = UiItem {
type_name,
..UiItem::default()
};
self.parse_item_body(&mut item)?;
Ok(item)
}
fn parse_item_body(&mut self, item: &mut UiItem) -> Result<()> {
loop {
self.skip_trivia();
match self.peek() {
None => {
let (line, col) = self.line_col();
bail!("unterminated item body at line {line}, column {col}");
}
Some(b'}') => {
self.pos += 1;
return Ok(());
}
Some(b';') => {
self.pos += 1;
continue;
}
_ => self.parse_member(item)?,
}
}
}
fn parse_member(&mut self, item: &mut UiItem) -> Result<()> {
if self.match_keyword("default") {
return self.parse_property_decl(item, true, false);
}
if self.match_keyword("readonly") {
return self.parse_property_decl(item, false, true);
}
if self.match_keyword("property") {
return self.finish_property_decl(item, false, false);
}
if self.match_keyword("signal") {
let sig = self.parse_signal_decl()?;
item.signals.push(sig);
return Ok(());
}
if self.match_keyword("enum") {
let _name = self.read_ident().context("enum name")?;
self.skip_trivia();
let _body = self.read_balanced(b'{', b'}')?;
return Ok(());
}
if self.match_keyword("required") {
if self.match_keyword("default") {
return self.finish_property_decl(item, true, false);
}
if self.match_keyword("readonly") {
return self.finish_property_decl(item, false, true);
}
if self.match_keyword("property") {
return self.finish_property_decl(item, false, false);
}
let _name = self.read_ident().context("required property name")?;
return Ok(());
}
if self.match_keyword("function") {
let name = self.read_ident()?;
self.skip_trivia();
if self.peek() == Some(b'(') {
self.skip_balanced(b'(', b')')?;
}
self.skip_trivia();
let body = if self.peek() == Some(b'{') {
self.read_balanced(b'{', b'}')?
} else {
String::new()
};
item.handlers.push(UiHandler {
signal: format!("function:{name}"),
body,
});
return Ok(());
}
let lead = self.read_dotted_ident()?;
self.skip_trivia();
if starts_uppercase(&lead) && self.match_keyword("on") {
let _prop = self.read_dotted_ident().context("`on` target property")?;
self.skip_trivia();
let _body = self.read_balanced(b'{', b'}')?;
return Ok(());
}
match self.peek() {
Some(b':') => {
self.pos += 1;
self.parse_assignment_after_colon(item, lead)
}
Some(b'{') => {
if starts_uppercase(&lead) {
self.pos += 1;
let mut child = UiItem {
type_name: lead,
..UiItem::default()
};
self.parse_item_body(&mut child)?;
item.children.push(child);
} else {
self.pos += 1;
let mut group = UiItem {
type_name: lead.clone(),
..UiItem::default()
};
self.parse_item_body(&mut group)?;
item.assignments.push(UiAssignment {
target: lead,
value: UiAssignmentValue::Object {
item: Box::new(group),
},
});
}
Ok(())
}
_ => {
let (line, col) = self.line_col();
bail!("expected `:` or `{{` after `{lead}` at line {line}, column {col}");
}
}
}
fn parse_property_decl(
&mut self,
item: &mut UiItem,
default_kw: bool,
readonly: bool,
) -> Result<()> {
if self.match_keyword("readonly") {
return self.finish_property_decl(item, default_kw, true);
}
if self.match_keyword("default") {
return self.finish_property_decl(item, true, readonly);
}
if self.match_keyword("property") {
return self.finish_property_decl(item, default_kw, readonly);
}
let (line, col) = self.line_col();
bail!("expected `property` after modifier at line {line}, column {col}");
}
fn finish_property_decl(
&mut self,
item: &mut UiItem,
default_kw: bool,
readonly: bool,
) -> Result<()> {
let ty = self.read_dotted_ident().context("property type")?;
let name = self.read_ident().context("property name")?;
self.skip_trivia();
let default_value = if self.peek() == Some(b':') {
self.pos += 1;
Some(self.read_expression_text())
} else {
None
};
item.properties.push(UiProperty {
name,
ty,
default_value,
readonly,
default_kw,
});
Ok(())
}
fn parse_signal_decl(&mut self) -> Result<UiSignal> {
let name = self.read_ident().context("signal name")?;
self.skip_trivia();
let mut params = Vec::new();
if self.peek() == Some(b'(') {
self.pos += 1;
loop {
self.skip_trivia();
if self.peek() == Some(b')') {
self.pos += 1;
break;
}
let ty = self.read_dotted_ident().context("signal param type")?;
let pname = self.read_ident().context("signal param name")?;
params.push(UiSignalParam { name: pname, ty });
self.skip_trivia();
match self.peek() {
Some(b',') => {
self.pos += 1;
}
Some(b')') => {
self.pos += 1;
break;
}
_ => {
let (line, col) = self.line_col();
bail!(
"expected `,` or `)` in signal param list at line {line}, column {col}"
);
}
}
}
}
Ok(UiSignal { name, params })
}
fn parse_assignment_after_colon(&mut self, item: &mut UiItem, target: String) -> Result<()> {
if target == "id" {
self.skip_trivia();
let id_val = self.read_ident().context("id value")?;
item.id = Some(id_val);
return Ok(());
}
if target.starts_with("on") && target.len() > 2 && is_upper_byte(target.as_bytes()[2]) {
let body = self.read_handler_body();
item.handlers.push(UiHandler {
signal: target,
body,
});
return Ok(());
}
self.skip_trivia();
let value = match self.peek() {
Some(b'[') => {
self.pos += 1;
let items = self.parse_assignment_list()?;
UiAssignmentValue::List { items }
}
Some(b'{') => {
let body = self.read_balanced(b'{', b'}')?;
UiAssignmentValue::Expression { text: body }
}
Some(b)
if is_ident_start(b) && {
let saved = self.pos;
let id = self.read_dotted_ident().unwrap_or_default();
self.skip_trivia();
let is_object =
!id.is_empty() && starts_uppercase(&id) && self.peek() == Some(b'{');
self.pos = saved;
is_object
} =>
{
let id = self.read_dotted_ident()?;
self.skip_trivia();
self.expect(b'{', "after object value type name")?;
let mut sub = UiItem {
type_name: id,
..UiItem::default()
};
self.parse_item_body(&mut sub)?;
UiAssignmentValue::Object {
item: Box::new(sub),
}
}
_ => UiAssignmentValue::Expression {
text: self.read_expression_text(),
},
};
item.assignments.push(UiAssignment { target, value });
Ok(())
}
fn parse_assignment_list(&mut self) -> Result<Vec<UiAssignmentValue>> {
let mut out = Vec::new();
loop {
self.skip_trivia();
if self.peek() == Some(b']') {
self.pos += 1;
return Ok(out);
}
let saved = self.pos;
let id = self.read_dotted_ident().unwrap_or_default();
self.skip_trivia();
let value = if !id.is_empty() && starts_uppercase(&id) && self.peek() == Some(b'{') {
self.pos += 1;
let mut sub = UiItem {
type_name: id,
..UiItem::default()
};
self.parse_item_body(&mut sub)?;
UiAssignmentValue::Object {
item: Box::new(sub),
}
} else {
self.pos = saved;
let text = self.read_expression_text_until(b',', b']');
UiAssignmentValue::Expression { text }
};
out.push(value);
self.skip_trivia();
match self.peek() {
Some(b',') => {
self.pos += 1;
continue;
}
Some(b']') => {
self.pos += 1;
return Ok(out);
}
_ => {
let (line, col) = self.line_col();
bail!("expected `,` or `]` in list at line {line}, column {col}");
}
}
}
}
fn read_expression_text(&mut self) -> String {
while let Some(b) = self.peek() {
if b == b' ' || b == b'\t' {
self.pos += 1;
} else {
break;
}
}
let start = self.pos;
let mut paren = 0i32;
let mut bracket = 0i32;
let mut brace = 0i32;
let mut last_sig: Option<u8> = None;
let mut last_sig2: Option<u8> = None;
while let Some(b) = self.peek() {
match b {
b'"' | b'\'' => {
self.skip_string();
last_sig2 = last_sig;
last_sig = Some(b'"');
continue;
}
b'/' if self.peek_at(1) == Some(b'/') => {
break;
}
b'/' if self.peek_at(1) == Some(b'*') => {
self.pos += 2;
while !self.eof() {
if self.peek() == Some(b'*') && self.peek_at(1) == Some(b'/') {
self.pos += 2;
break;
}
self.pos += 1;
}
continue;
}
b'(' => paren += 1,
b')' => paren -= 1,
b'[' => bracket += 1,
b']' => bracket -= 1,
b'{' => brace += 1,
b'}' => {
if brace == 0 && paren == 0 && bracket == 0 {
break;
}
brace -= 1;
}
b';' if paren == 0 && bracket == 0 && brace == 0 => break,
b'\n' if paren == 0 && bracket == 0 && brace == 0 => {
if !self.expr_newline_continues(last_sig, last_sig2) {
break;
}
}
_ => {}
}
if !matches!(b, b' ' | b'\t' | b'\n' | b'\r') {
last_sig2 = last_sig;
last_sig = Some(b);
}
self.pos += 1;
}
let text = std::str::from_utf8(&self.src[start..self.pos])
.unwrap_or("")
.trim()
.to_string();
if self.peek() == Some(b';') {
self.pos += 1;
}
text
}
fn expr_newline_continues(&self, last_sig: Option<u8>, last_sig2: Option<u8>) -> bool {
if let Some(b) = last_sig {
let trailing = matches!(
b,
b'?' | b':'
| b','
| b'.'
| b'+'
| b'-'
| b'*'
| b'/'
| b'%'
| b'&'
| b'|'
| b'^'
| b'='
| b'<'
| b'>'
| b'~'
);
if trailing {
let postfix = matches!((last_sig2, b), (Some(b'+'), b'+') | (Some(b'-'), b'-'));
if !postfix {
return true;
}
}
}
match self.peek_next_significant() {
Some(b) => matches!(
b,
b'?' | b':'
| b','
| b'.'
| b'+'
| b'-'
| b'*'
| b'/'
| b'%'
| b'&'
| b'|'
| b'^'
| b'='
| b'<'
| b'>'
),
None => false,
}
}
fn peek_next_significant(&self) -> Option<u8> {
let mut i = self.pos;
while i < self.src.len() {
let b = self.src[i];
if matches!(b, b' ' | b'\t' | b'\n' | b'\r') {
i += 1;
} else if b == b'/' && self.src.get(i + 1) == Some(&b'/') {
i += 2;
while i < self.src.len() && self.src[i] != b'\n' {
i += 1;
}
} else if b == b'/' && self.src.get(i + 1) == Some(&b'*') {
i += 2;
while i + 1 < self.src.len() && !(self.src[i] == b'*' && self.src[i + 1] == b'/') {
i += 1;
}
i += 2;
} else {
return Some(b);
}
}
None
}
fn read_expression_text_until(&mut self, term1: u8, term2: u8) -> String {
while let Some(b) = self.peek() {
if b == b' ' || b == b'\t' {
self.pos += 1;
} else {
break;
}
}
let start = self.pos;
let mut paren = 0i32;
let mut bracket = 0i32;
let mut brace = 0i32;
while let Some(b) = self.peek() {
match b {
b'"' | b'\'' => {
self.skip_string();
continue;
}
b'(' => paren += 1,
b')' => paren -= 1,
b'[' => bracket += 1,
b']' if bracket == 0 && b == term2 && paren == 0 && brace == 0 => break,
b']' => bracket -= 1,
b'{' => brace += 1,
b'}' => brace -= 1,
_ if b == term1 && paren == 0 && bracket == 0 && brace == 0 => break,
_ if b == term2 && paren == 0 && bracket == 0 && brace == 0 => break,
_ => {}
}
self.pos += 1;
}
std::str::from_utf8(&self.src[start..self.pos])
.unwrap_or("")
.trim()
.to_string()
}
fn read_handler_body(&mut self) -> String {
self.skip_trivia();
if self.peek() == Some(b'{') {
let raw = self.read_balanced(b'{', b'}').unwrap_or_default();
let trimmed = raw.trim();
if trimmed.starts_with('{') && trimmed.ends_with('}') {
trimmed[1..trimmed.len() - 1].trim().to_string()
} else {
trimmed.to_string()
}
} else {
self.read_expression_text()
}
}
fn read_balanced(&mut self, open: u8, close: u8) -> Result<String> {
self.skip_trivia();
if self.peek() != Some(open) {
let (line, col) = self.line_col();
bail!("expected `{}` at line {line}, column {col}", open as char);
}
let start = self.pos;
self.pos += 1;
let mut depth = 1i32;
while let Some(b) = self.peek() {
match b {
b'"' | b'\'' => {
self.skip_string();
continue;
}
b'/' if self.peek_at(1) == Some(b'/') => {
while let Some(c) = self.peek() {
self.pos += 1;
if c == b'\n' {
break;
}
}
continue;
}
b'/' if self.peek_at(1) == Some(b'*') => {
self.pos += 2;
while !self.eof() {
if self.peek() == Some(b'*') && self.peek_at(1) == Some(b'/') {
self.pos += 2;
break;
}
self.pos += 1;
}
continue;
}
_ if b == open => depth += 1,
_ if b == close => {
depth -= 1;
self.pos += 1;
if depth == 0 {
return Ok(std::str::from_utf8(&self.src[start..self.pos])?.to_string());
}
continue;
}
_ => {}
}
self.pos += 1;
}
let (line, col) = self.line_col();
bail!(
"unterminated `{}…{}` block at line {line}, column {col}",
open as char,
close as char
);
}
fn skip_balanced(&mut self, open: u8, close: u8) -> Result<()> {
let _ = self.read_balanced(open, close)?;
Ok(())
}
fn skip_string(&mut self) {
let quote = match self.peek() {
Some(q) => q,
None => return,
};
self.pos += 1;
while let Some(b) = self.peek() {
self.pos += 1;
if b == b'\\' {
self.pos += 1;
continue;
}
if b == quote {
return;
}
}
}
}
fn is_ident_start(b: u8) -> bool {
b.is_ascii_alphabetic() || b == b'_' || b == b'$'
}
fn is_ident_cont(b: u8) -> bool {
is_ident_start(b) || b.is_ascii_digit()
}
fn is_upper_byte(b: u8) -> bool {
b.is_ascii_uppercase()
}
fn starts_uppercase(s: &str) -> bool {
s.as_bytes()
.first()
.map(|b| b.is_ascii_uppercase())
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn parse(src: &str) -> UiModule {
parse_module(src, &PathBuf::from("test.qml")).expect("parse ok")
}
#[test]
fn parses_imports_and_root() {
let src = r#"
import QtQuick 2.15
import QtQuick.Controls as QC
import "." as Local
Item {
id: root
width: 800
height: 480
}
"#;
let m = parse(src);
assert_eq!(m.version, QT_IR_VERSION);
assert_eq!(m.imports.len(), 3);
assert_eq!(m.imports[0].module, "QtQuick");
assert_eq!(m.imports[0].version.as_deref(), Some("2.15"));
assert_eq!(m.imports[1].module, "QtQuick.Controls");
assert_eq!(m.imports[1].alias.as_deref(), Some("QC"));
assert_eq!(m.imports[2].module, ".");
assert_eq!(m.imports[2].alias.as_deref(), Some("Local"));
assert_eq!(m.root.type_name, "Item");
assert_eq!(m.root.id.as_deref(), Some("root"));
assert_eq!(m.root.assignments.len(), 2);
assert_eq!(m.root.assignments[0].target, "width");
}
#[test]
fn parses_property_declarations() {
let src = r#"
Item {
property string title: "Hello"
property int count: 0
readonly property real ratio: 1.5
default property var children
}
"#;
let m = parse(src);
let p = &m.root.properties;
assert_eq!(p.len(), 4);
assert_eq!(p[0].name, "title");
assert_eq!(p[0].ty, "string");
assert_eq!(p[0].default_value.as_deref(), Some("\"Hello\""));
assert!(!p[0].readonly && !p[0].default_kw);
assert_eq!(p[2].ty, "real");
assert!(p[2].readonly);
assert!(p[3].default_kw);
assert_eq!(p[3].default_value, None);
}
#[test]
fn parses_signal_decl_and_handlers() {
let src = r#"
Item {
signal pressed(int x, int y)
signal triggered()
onPressed: console.log(x, y)
onTriggered: { count += 1; emit() }
}
"#;
let m = parse(src);
assert_eq!(m.root.signals.len(), 2);
assert_eq!(m.root.signals[0].name, "pressed");
assert_eq!(m.root.signals[0].params.len(), 2);
assert_eq!(m.root.signals[0].params[0].ty, "int");
assert_eq!(m.root.signals[1].params.len(), 0);
assert_eq!(m.root.handlers.len(), 2);
assert_eq!(m.root.handlers[0].signal, "onPressed");
assert_eq!(m.root.handlers[0].body, "console.log(x, y)");
assert_eq!(m.root.handlers[1].signal, "onTriggered");
assert!(m.root.handlers[1].body.contains("count += 1"));
}
#[test]
fn parses_grouped_property_and_child() {
let src = r##"
Item {
font { pixelSize: 48; family: "Inter" }
Rectangle {
id: bg
color: "#1e1e2e"
}
}
"##;
let m = parse(src);
assert_eq!(m.root.assignments.len(), 1);
assert_eq!(m.root.assignments[0].target, "font");
match &m.root.assignments[0].value {
UiAssignmentValue::Object { item } => {
assert_eq!(item.assignments.len(), 2);
assert_eq!(item.assignments[0].target, "pixelSize");
}
_ => panic!("expected grouped object"),
}
assert_eq!(m.root.children.len(), 1);
assert_eq!(m.root.children[0].type_name, "Rectangle");
assert_eq!(m.root.children[0].id.as_deref(), Some("bg"));
}
#[test]
fn parses_dotted_assignment_target() {
let src = r#"
Item {
anchors.fill: parent
anchors.margins: 16
}
"#;
let m = parse(src);
assert_eq!(m.root.assignments.len(), 2);
assert_eq!(m.root.assignments[0].target, "anchors.fill");
assert_eq!(m.root.assignments[1].target, "anchors.margins");
}
#[test]
fn parses_object_value_assignment() {
let src = r#"
Item {
transitions: Transition { from: "a"; to: "b" }
}
"#;
let m = parse(src);
assert_eq!(m.root.assignments.len(), 1);
match &m.root.assignments[0].value {
UiAssignmentValue::Object { item } => {
assert_eq!(item.type_name, "Transition");
assert_eq!(item.assignments.len(), 2);
}
_ => panic!("expected object value"),
}
}
#[test]
fn ignores_comments() {
let src = r#"
// top comment
import QtQuick 2.15 // trailing
/* block
comment */
Item {
width: 100 // trailing on assignment
/* inline */ height: 200
}
"#;
let m = parse(src);
assert_eq!(m.imports.len(), 1);
assert_eq!(m.root.assignments.len(), 2);
assert_eq!(m.root.assignments[0].target, "width");
assert_eq!(m.root.assignments[1].target, "height");
}
#[test]
fn round_trips_through_serde_json() {
let src = r#"
import QtQuick 2.15
Item {
id: root
width: 100
Rectangle { color: "red" }
}
"#;
let m = parse(src);
let json = serde_json::to_string(&m).unwrap();
let m2: UiModule = serde_json::from_str(&json).unwrap();
assert_eq!(m, m2);
}
#[test]
fn dimension_expressions_preserve_precedence_and_remove_identities() {
let resolver = DimResolver::default();
assert_eq!(
resolver.resolve_dim("parent.width + 0").as_deref(),
Some("bounds.width")
);
assert_eq!(
resolver
.resolve_dim("parent.width / (2 + 3) * 4")
.as_deref(),
Some("bounds.width / (2 + 3) * 4")
);
assert_eq!(
resolver.resolve_dim("parent.width - (8 - 3)").as_deref(),
Some("bounds.width - (8 - 3)")
);
assert_eq!(
resolver.resolve_dim("parent.width / (8 / 2)").as_deref(),
Some("bounds.width / (8 / 2)")
);
assert_eq!(
resolver.resolve_dim("(parent.width + 8) / 2").as_deref(),
Some("(bounds.width + 8) / 2")
);
}
#[test]
fn solved_bounds_omit_zero_offsets() {
let module = parse(
r#"
Item {
Rectangle { x: 0; y: 0; width: 10; height: 20 }
}
"#,
);
let bounds =
solve_child_bounds(&module.root.children[0], &|_| None, &DimResolver::default());
assert_eq!(bounds.x.render(), "bounds.x");
assert_eq!(bounds.y.render(), "bounds.y");
assert_eq!(bounds.w.render(), "10");
assert_eq!(bounds.h.render(), "20");
}
#[test]
fn generated_helpers_distinguish_leaf_and_parent_nodes() {
let leaf = render_rlvgl(&parse("Item { id: leafNode }"));
assert!(leaf.contains("fn build_leaf_node("));
assert!(leaf.contains(" WidgetNode {\n widget,"));
assert!(!leaf.contains("let node = WidgetNode"));
let parent = render_rlvgl(&parse(
r#"
Item {
id: parentNode
Rectangle { id: childNode }
}
"#,
));
assert!(parent.contains("fn build_parent_node("));
assert!(parent.contains("let mut node = WidgetNode"));
assert!(parent.contains("node.children.push(build_child_node"));
}
#[test]
fn generated_binding_leaves_use_binding_sink() {
let generated = render_rlvgl(&parse(
r#"
Item {
id: root
property string title: "Hello"
Text { id: titleLabel; text: root.title }
}
"#,
));
assert!(generated.contains("struct BindingSink<'a, T>"));
assert!(generated.contains("label_bindings: &mut BindingSink<'_, LabelBinding>"));
assert!(generated.contains("label_bindings.push(LabelBinding"));
assert!(generated.contains("fn build_title_label("));
}
#[test]
fn generated_rust_identifiers_are_snake_case_and_collision_safe() {
let generated = render_rlvgl(&parse(
r#"
Item {
id: root
property int i_ROW_SPACING: 1
property int fooBar: 2
property int foo_bar: 3
Item {
id: paneMouseArea
property bool itemSelected: false
}
Item { id: __buttonTarget }
}
"#,
));
assert!(generated.contains("pub i_row_spacing: i32"));
assert!(generated.contains("pub foo_bar: i32"));
assert!(generated.contains("pub foo_bar_2: i32"));
assert!(generated.contains("pub pane_mouse_area_item_selected: bool"));
assert!(generated.contains("fn build_pane_mouse_area("));
assert!(generated.contains("tag: Some(\"paneMouseArea\")"));
assert!(generated.contains("fn build_button_target("));
assert!(generated.contains("tag: Some(\"__buttonTarget\")"));
}
#[test]
fn generated_rust_identifiers_escape_reserved_keywords() {
for keyword in ["abstract", "become", "box", "do", "gen", "match", "try"] {
assert_eq!(rust_snake_ident(keyword), format!("{keyword}_"));
}
}
#[test]
fn click_area_is_mutable_only_when_a_handler_is_lowered() {
let immutable = render_rlvgl(&parse("MouseArea { id: target }"));
assert!(immutable.contains("let click_area = ClickArea::new(bounds);"));
assert!(!immutable.contains("let mut click_area = ClickArea::new(bounds);"));
let mutable = render_rlvgl(&parse(
r#"
MouseArea {
id: target
property int taps: 0
onClicked: taps += 1
}
"#,
));
assert!(mutable.contains("let mut click_area = ClickArea::new(bounds);"));
}
#[test]
fn schema_matches_checked_in_canonical() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let path = std::path::Path::new(manifest_dir).join("schemas/qt-ir.schema.json");
let canonical = std::fs::read_to_string(&path).unwrap_or_else(|e| {
panic!(
"missing canonical schema at {} ({e}). Run: \
cargo run --features creator --bin rlvgl-creator -- \
qt schema --out schemas/qt-ir.schema.json",
path.display()
)
});
let regenerated = render_schema().expect("render_schema");
if canonical != regenerated {
let canonical_t = canonical.trim_end();
let regenerated_t = regenerated.trim_end();
if canonical_t == regenerated_t {
return;
}
panic!(
"qt-ir.schema.json drifted from the IR types defined in src/bin/creator/qt.rs.\n\
Regenerate with:\n \
cargo run --features creator --bin rlvgl-creator -- \
qt schema --out schemas/qt-ir.schema.json\n\
If this drift is intentional, follow the QT-00 §7 / QT-02 bumping policy \
(docs/qt-support/02-ir-schema.md)."
);
}
}
#[test]
fn state_machine_ir_roundtrips() {
let sm = UiStateMachine {
id: "stopwatch".to_string(),
source: "stopwatch.scjson".to_string(),
initial: Some("idle".to_string()),
states: vec![
UiState {
id: "idle".to_string(),
on_entry: vec![UiAction::Assign {
location: "elapsed".to_string(),
expr: Some("0".to_string()),
}],
on_exit: vec![],
},
UiState {
id: "running".to_string(),
on_entry: vec![UiAction::Script {
name: "tick_start".to_string(),
}],
on_exit: vec![UiAction::Raise {
event: "stopped".to_string(),
}],
},
],
transitions: vec![
UiTransition {
source: "idle".to_string(),
event: Some("start".to_string()),
target: Some("running".to_string()),
cond: Some("elapsed >= 0".to_string()),
actions: vec![UiAction::Assign {
location: "elapsed".to_string(),
expr: None,
}],
},
UiTransition {
source: "running".to_string(),
event: Some("stop".to_string()),
target: Some("idle".to_string()),
cond: None,
actions: vec![],
},
],
datamodel: vec![
UiDmField {
id: "elapsed".to_string(),
initial: Some(0.0),
},
UiDmField {
id: "lap".to_string(),
initial: None,
},
],
scripts: vec![
UiScript {
name: "tick_start".to_string(),
origin: UiScriptOrigin::OnEntry {
state: "running".to_string(),
},
},
UiScript {
name: "diag_log".to_string(),
origin: UiScriptOrigin::Transition {
index: 0,
from: "idle".to_string(),
to: Some("running".to_string()),
},
},
UiScript {
name: "wrap_up".to_string(),
origin: UiScriptOrigin::OnExit {
state: "running".to_string(),
},
},
],
};
let json = serde_json::to_string_pretty(&sm).expect("serialize");
let back: UiStateMachine = serde_json::from_str(&json).expect("deserialize");
assert_eq!(sm, back, "UiStateMachine does not roundtrip");
let module = UiModule {
version: QT_IR_VERSION,
source: "stopwatch.qml".to_string(),
imports: vec![],
pragmas: vec![],
root: UiItem {
type_name: "Item".to_string(),
..UiItem::default()
},
state_machine: Some(sm.clone()),
};
let json = serde_json::to_string_pretty(&module).expect("serialize");
let back: UiModule = serde_json::from_str(&json).expect("deserialize");
assert_eq!(module, back);
assert_eq!(back.version, 2, "QT-05 IR version is 2");
let v1_json = r#"{
"version": 1,
"source": "legacy.qml",
"imports": [],
"pragmas": [],
"root": {
"type_name": "Item",
"properties": [],
"assignments": [],
"signals": [],
"handlers": [],
"children": []
}
}"#;
let legacy: UiModule =
serde_json::from_str(v1_json).expect("v1 IR (no state_machine key) must still parse");
assert!(legacy.state_machine.is_none());
}
#[test]
fn canonical_fixture_roundtrips() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let qml_path = std::path::Path::new(manifest_dir).join("tests/fixtures/qt/hello.qml");
let source = std::fs::read_to_string(&qml_path).expect("read hello.qml");
let parsed = parse_module(&source, &qml_path).expect("parse hello.qml");
let json = serde_json::to_string_pretty(&parsed).expect("serialize");
let reparsed: UiModule = serde_json::from_str(&json).expect("deserialize");
assert_eq!(
parsed, reparsed,
"qt-ir does not roundtrip through serde_json"
);
}
}