从 0 用 Vue 做屏并生成 LVGL 单片机代码(完整源码备忘)

本文按当前 lvgl-meter 工程整理:主工程用法 + vue-lvgl 工具包全部源码

流程:PowerMeter.vueshared/default-layout.jsongenerated/ui.c / ui.h / ui_images.c → 拷进固件 main/ui_init()


1. 整体说明

板子不跑 Vue、不读 JSON,只跑生成好的 C。

scss 复制代码
页面.vue
  → vue-lvgl export(Vite + Playwright 量坐标)
  → shared/default-layout.json
  → vue-lvgl gen-ui
  → generated/ui.c  ui.h  ui_images.c
  → 固件 ui_init() / ui_set_*()

日常命令:

bat 复制代码
npm install
npx playwright install chromium
npm run dev
npm run export-and-gen
copy /Y generated\ui.c generated\ui.h generated\ui_images.c ..\lvgl_test\main\

2. 主工程文件

目录

bash 复制代码
lvgl-meter/
├── package.json
├── vue-lvgl.config.js
├── src/App.vue
├── src/pages/PowerMeter.vue
├── shared/assets/
├── generated/
└── vue-lvgl/

package.json

json 复制代码
{
  "name": "lvgl-meter",
  "private": true,
  "version": "0.4.2",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "export-layout": "vue-lvgl export",
    "gen-ui": "vue-lvgl gen-ui",
    "export-and-gen": "vue-lvgl build"
  },
  "dependencies": {
    "pngjs": "^7.0.0",
    "vue": "^2.7.16",
    "vue-lvgl": "file:./vue-lvgl"
  },
  "devDependencies": {
    "@vitejs/plugin-vue2": "^2.3.3",
    "playwright": "^1.49.1",
    "vite": "^5.4.11",
    "vue-template-compiler": "^2.7.16"
  }
}

vue-lvgl.config.js

js 复制代码
/**
 * Paths are relative to this project root only.
 */
export default {
  layoutJson: "shared/default-layout.json",
  outDir: "generated",
  viteConfig: "vite.config.js",
  exportPort: 5179,
  display: { width: 320, height: 240 },
  fonts: { threshold: 24, small: 14, large: 28 },
  keyOverlay: false
};

src/App.vue

vue 复制代码
<script>
import { installExportHooks, uninstallExportHooks } from "vue-lvgl";
import PowerMeter from "./pages/PowerMeter.vue";

const WAVE_POINTS = 1024;
const WAVE_CYCLES = 2;

function sinePoint(i, n = WAVE_POINTS, cycles = WAVE_CYCLES) {
  return Math.round(50 + 38 * Math.sin((2 * Math.PI * cycles * i) / n));
}

function sineWave(n = WAVE_POINTS, cycles = WAVE_CYCLES) {
  return Array.from({ length: n }, (_, i) => sinePoint(i, n, cycles));
}

export default {
  name: "App",
  components: { PowerMeter },
  data() {
    const exporting =
      typeof location !== "undefined" && /(?:\?|&)export=1(?:&|$)/.test(location.search);
    return {
      waveTick: 0,
      waveTimer: null,
      exporting,
      data: {
        outputOn: false,
        voltage: 36.0,
        current: 2.0,
        power: 72.0,
        wh: 0.166,
        ah: 0.166,
        runSec: 600,
        temp: 125,
        setU: 5.0,
        setI: 3.0,
        mode: "CC",
        mem: "M09",
        wave: sineWave()
      }
    };
  },
  mounted() {
    installExportHooks(() => this.$refs.page.exportLayout());
    if (this.exporting) return;
    this.waveTimer = setInterval(() => {
      this.waveTick += 1;
      const next = sinePoint(this.waveTick);
      const wave = this.data.wave.slice(1);
      wave.push(next);
      this.data.wave = wave;

      /* 按 72W / 2A 积分累加,与单片机演示一致;50ms = 50/3600000 h */
      const dtH = 50 / 3600000;
      this.data.wh += 72 * dtH;
      this.data.ah += 2 * dtH;
    }, 50);
  },
  beforeDestroy() {
    if (this.waveTimer) clearInterval(this.waveTimer);
    uninstallExportHooks();
  }
};
</script>

<template>
  <div class="app">
    <PowerMeter ref="page" :data="data" />
  </div>
</template>

<style scoped>
.app {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #0b0d12;
}
</style>

src/pages/PowerMeter.vue

vue 复制代码
<script>
import { MeterScreen, MeterBox, MeterLabel, MeterChart } from "vue-lvgl";

export default {
  name: "PowerMeter",
  components: { MeterScreen, MeterBox, MeterLabel, MeterChart },
  props: {
    data: { type: Object, required: true },
  },
  methods: {
    exportLayout() {
      return this.$refs.screen.toLayoutJSON();
    },
  },
};
</script>

<template>
  <!-- 设计分辨率:320×240 -->
  <MeterScreen ref="screen" :width="320" :height="240" background="#2A2A2A" background-image="shared/assets/back.png" :data="data" :scale="1">
    <div class="page">
      <div class="top">
        <MeterBox id="offBg" class="off" bg="#3A3A3A" :radius="4">
          <MeterLabel id="off" color="#FFFFFF" :font="16" align="center" bind="outputOn" format="onoff">OFF</MeterLabel>
        </MeterBox>
        <MeterLabel id="clock" class="clock" color="#000" :font="14" align="center" bind="clock">2026-08-14 12:25:43</MeterLabel>
        <div class="icons">
          <MeterLabel id="iconBt" class="icon" color="#ff0000" :font="14" align="center">BT</MeterLabel>
          <MeterLabel id="iconNet" class="icon" color="#ff0000" :font="14" align="center">N2</MeterLabel>
          <MeterLabel id="iconWifi" class="icon" color="#ff0000" :font="14" align="center">WF</MeterLabel>
        </div>
      </div>

      <div class="middle">
        <MeterBox id="mainPanel" class="main-panel" bg="#000000" :radius="6">
          <MeterChart id="wave" class="wave" bind="wave" color="#F5C518" bg="#000000" :points="1024" :ymin="0" :ymax="100" :line-width="2" :div-h="3" :div-v="4" :radius="4" />
        </MeterBox>

        <div class="right-col">
          <div class="mem-row">
            <MeterLabel id="lock" class="lock" color="#FF8A3D" :font="14" align="center">LK</MeterLabel>
            <MeterBox id="memBg" class="mem" bg="#000000" :radius="4">
              <MeterLabel id="mem" color="#FFFFFF" :font="14" align="center" bind="mem">M09</MeterLabel>
            </MeterBox>
          </div>

          <MeterBox id="statsPanel" class="stats" bg="#000000" border="#5EC8FF" :border-width="1" :radius="4">
            <MeterLabel id="wh" color="#FFFFFF" :font="14" bind="wh" format="%.3fWh">0.166Wh</MeterLabel>
            <MeterLabel id="ah" color="#FFFFFF" :font="14" bind="ah" format="%.3fAh">0.166Ah</MeterLabel>
            <MeterLabel id="timer" color="#FFFFFF" :font="14" bind="runSec" format="hms">00:10:00</MeterLabel>
          </MeterBox>
        </div>
      </div>

      <div class="bottom">
        <MeterBox id="modeBg" class="mode" bg="#3DFF6E" bg-grad="#000000" bg-grad-dir="ver" :radius="4">
          <MeterBox id="modeDot" class="dot" bg="#3DFF6E" :radius="5" />
          <MeterLabel id="mode" color="#000000" color-grad="#3DFF6E" color-grad-dir="hor" :font="16" bind="mode">CC</MeterLabel>
        </MeterBox>

        <MeterBox id="tempBg" class="temp" bg="#000000" border="#E070B0" :border-width="1" :radius="4">
          <MeterLabel id="temp" color="#FFFFFF" :font="16" align="center" bind="temp" format="%.0f">125</MeterLabel>
          <MeterLabel id="tempUnit" color="#E070B0" :font="14">C</MeterLabel>
        </MeterBox>

        <MeterBox id="setBg" class="set" bg="#000000" :radius="4">
          <div class="set-line">
            <MeterLabel id="setUKey" color="#FFFFFF" :font="14">SET-U</MeterLabel>
            <MeterLabel id="setU" color="#F5C518" :font="14" bind="setU" format="%05.2fV">05.00V</MeterLabel>
          </div>
          <div class="set-line">
            <MeterLabel id="setIKey" color="#FFFFFF" :font="14">SET-I</MeterLabel>
            <MeterLabel id="setI" color="#4CC9F0" :font="14" bind="setI" format="%.3fA">3.000A</MeterLabel>
          </div>
        </MeterBox>
      </div>
    </div>
  </MeterScreen>
</template>

<style scoped>
.page {
  width: 100%;
  height: 100%;
  padding: 6px;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.top {
  display: flex;
  align-items: center;
  gap: 6px;
  height: 28px;
  flex: 0 0 auto;
}

.off {
  width: 48px;
  height: 28px;
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: center;
}

.clock {
  flex: 1;
  height: 28px;
}

.icons {
  display: flex;
  gap: 2px;
  flex: 0 0 auto;
}

.icon {
  width: 30px;
  height: 28px;
}

.middle {
  flex: 1;
  min-height: 0;
  display: flex;
  gap: 6px;
}

.main-panel {
  flex: 1;
  min-width: 0;
  padding: 4px;
  display: flex;
}

.wave {
  flex: 1;
  min-height: 0;
}

.right-col {
  width: 100px;
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.mem-row {
  display: flex;
  align-items: center;
  gap: 4px;
  height: 24px;
}

.lock {
  width: 24px;
  height: 24px;
  flex: 0 0 auto;
}

.mem {
  flex: 1;
  height: 24px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.stats {
  flex: 1;
  padding: 6px 8px;
  display: flex;
  flex-direction: column;
  justify-content: space-around;
}

.bottom {
  display: flex;
  gap: 6px;
  height: 40px;
  flex: 0 0 auto;
}

.mode {
  width: 64px;
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 0 8px;
}

.dot {
  width: 10px;
  height: 10px;
  flex: 0 0 auto;
}

.temp {
  width: 72px;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 2px;
  padding: 0 4px;
}

.set {
  flex: 1;
  padding: 2px 8px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 0;
}

.set-line {
  display: flex;
  align-items: center;
  gap: 8px;
  height: 16px;
}

.set-line .meter-label:last-child {
  flex: 1;
}
</style>

3. vue-lvgl 工具包(完整源码)

目录

bash 复制代码
vue-lvgl/
├── package.json
├── bin/vue-lvgl.mjs
├── lib/config.mjs
├── lib/export-layout.mjs
├── lib/json-to-ui-c.mjs
├── lib/png-to-lvgl-c.mjs
├── src/index.js
├── src/measure.js
├── src/format.js
├── src/components/MeterScreen.vue
├── src/components/MeterBox.vue
├── src/components/MeterLabel.vue
├── src/components/MeterChart.vue
├── src/components/MeterImage.vue
├── templates/vue-lvgl.config.js
└── README.md

vue-lvgl/package.json

json 复制代码
{
  "name": "vue-lvgl",
  "version": "0.1.1",
  "description": "Vue layout editor toolkit: export JSON + generate LVGL9 ui.c/ui.h for MCU projects",
  "type": "module",
  "bin": {
    "vue-lvgl": "./bin/vue-lvgl.mjs"
  },
  "exports": {
    ".": "./src/index.js",
    "./measure": "./src/measure.js",
    "./format": "./src/format.js"
  },
  "files": [
    "bin",
    "lib",
    "src",
    "templates",
    "README.md"
  ],
  "peerDependencies": {
    "playwright": "^1.49.0",
    "vite": "^5.0.0 || ^6.0.0",
    "vue": "^2.7.0 || ^3.0.0"
  },
  "dependencies": {
    "pngjs": "^7.0.0"
  },
  "keywords": [
    "lvgl",
    "vue",
    "esp32",
    "ui",
    "codegen"
  ],
  "license": "MIT"
}

vue-lvgl/bin/vue-lvgl.mjs

js 复制代码
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { runExportLayoutCli } from "../lib/export-layout.mjs";
import { runGenUiCli } from "../lib/json-to-ui-c.mjs";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const pkgRoot = path.resolve(__dirname, "..");

function help() {
  console.log(`vue-lvgl --- Vue layout → JSON → LVGL ui.c

Usage (run inside your Vue UI project):
  npx vue-lvgl init                 Create vue-lvgl.config.js
  npx vue-lvgl export               Headless export → layout JSON
  npx vue-lvgl gen-ui [json]        JSON → ui.c / ui.h
  npx vue-lvgl gen-ui --copy DIR    Optional in-project copy
  npx vue-lvgl build                export + gen-ui

Options:
  export --out FILE
  gen-ui --out DIR --copy DIR --no-copy

Config: vue-lvgl.config.js (paths relative to this project only)
App must expose:
  window.__LVGL_EXPORT_READY__ = true
  window.__LVGL_EXPORT_LAYOUT__ = () => layoutObject
`);
}

async function init() {
  const dest = path.resolve(process.cwd(), "vue-lvgl.config.js");
  if (fs.existsSync(dest)) {
    console.log(`[SKIP] already exists: ${dest}`);
    return;
  }
  const tpl = path.join(pkgRoot, "templates/vue-lvgl.config.js");
  fs.copyFileSync(tpl, dest);
  console.log(`[OK] wrote ${dest}`);
}

async function main() {
  const [cmd, ...rest] = process.argv.slice(2);
  if (!cmd || cmd === "-h" || cmd === "--help" || cmd === "help") {
    help();
    return;
  }

  if (cmd === "init") {
    await init();
    return;
  }
  if (cmd === "export" || cmd === "export-layout") {
    await runExportLayoutCli(rest);
    return;
  }
  if (cmd === "gen-ui" || cmd === "gen") {
    await runGenUiCli(rest);
    return;
  }
  if (cmd === "build" || cmd === "export-and-gen") {
    await runExportLayoutCli([]);
    await runGenUiCli(rest);
    return;
  }

  console.error(`[ERROR] unknown command: ${cmd}`);
  help();
  process.exit(1);
}

main().catch((err) => {
  console.error("[ERROR]", err.message || err);
  process.exit(1);
});

vue-lvgl/lib/config.mjs

js 复制代码
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";

const DEFAULTS = {
  layoutJson: "shared/default-layout.json",
  outDir: "generated",
  /** Optional in-project copy target only; default none */
  copyTo: null,
  viteConfig: "vite.config.js",
  exportPort: 5179,
  exportQuery: "export=1",
  display: { width: 320, height: 240 },
  fonts: { threshold: 24, small: 14, large: 28 },
  keyOverlay: true
};

/**
 * Load vue-lvgl.config.js / .mjs / .cjs from project root (cwd).
 * Falls back to package.json "vue-lvgl" field, then defaults.
 */
export async function loadConfig(cwd = process.cwd()) {
  const root = path.resolve(cwd);
  const candidates = [
    "vue-lvgl.config.js",
    "vue-lvgl.config.mjs",
    "vue-lvgl.config.cjs"
  ];

  let user = {};
  for (const name of candidates) {
    const file = path.join(root, name);
    if (!fs.existsSync(file)) continue;
    const mod = await import(pathToFileURL(file).href);
    user = mod.default || mod;
    break;
  }

  if (!Object.keys(user).length) {
    const pkgFile = path.join(root, "package.json");
    if (fs.existsSync(pkgFile)) {
      try {
        const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
        if (pkg["vue-lvgl"] && typeof pkg["vue-lvgl"] === "object") {
          user = pkg["vue-lvgl"];
        }
      } catch {
        /* ignore */
      }
    }
  }

  const cfg = {
    ...DEFAULTS,
    ...user,
    display: { ...DEFAULTS.display, ...(user.display || {}) },
    fonts: { ...DEFAULTS.fonts, ...(user.fonts || {}) },
    root
  };

  cfg.layoutJson = path.resolve(root, cfg.layoutJson);
  cfg.outDir = path.resolve(root, cfg.outDir);
  cfg.viteConfig = path.resolve(root, cfg.viteConfig);
  if (cfg.copyTo) cfg.copyTo = path.resolve(root, cfg.copyTo);

  return cfg;
}

export { DEFAULTS };

vue-lvgl/lib/export-layout.mjs

js 复制代码
import fs from "node:fs";
import { createServer } from "vite";
import { chromium } from "playwright";
import { loadConfig } from "./config.mjs";

/**
 * Headless: Vite + Playwright → layout JSON
 */
export async function exportLayout(options = {}) {
  const cfg = options.config || (await loadConfig(options.cwd));
  const outFile = options.out || cfg.layoutJson;
  const port = options.port || cfg.exportPort;
  const query = options.query || cfg.exportQuery;

  if (!fs.existsSync(cfg.viteConfig)) {
    throw new Error(`vite config not found: ${cfg.viteConfig}`);
  }

  const server = await createServer({
    configFile: cfg.viteConfig,
    root: cfg.root,
    server: {
      host: "127.0.0.1",
      port,
      strictPort: true,
      open: false
    }
  });
  await server.listen();
  const baseUrl = server.resolvedUrls?.local?.[0] || `http://127.0.0.1:${port}/`;

  const browser = await chromium.launch({ headless: true });
  try {
    const page = await browser.newPage({
      viewport: { width: 1200, height: 900 }
    });
    const sep = baseUrl.includes("?") ? "&" : "?";
    await page.goto(`${baseUrl}${sep}${query}`, { waitUntil: "networkidle" });
    await page.waitForFunction(() => window.__LVGL_EXPORT_READY__ === true, null, {
      timeout: 30000
    });
    await page.waitForTimeout(200);

    const layout = await page.evaluate(() => window.__LVGL_EXPORT_LAYOUT__());
    if (!layout || !Array.isArray(layout.widgets)) {
      throw new Error("export returned invalid layout (missing window.__LVGL_EXPORT_LAYOUT__)");
    }

    fs.mkdirSync(pathDirname(outFile), { recursive: true });
    fs.writeFileSync(outFile, JSON.stringify(layout, null, 2) + "\n", "utf8");
    return { outFile, layout };
  } finally {
    await browser.close();
    await server.close();
  }
}

function pathDirname(p) {
  const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
  return i >= 0 ? p.slice(0, i) : ".";
}

export async function runExportLayoutCli(argv = []) {
  const cfg = await loadConfig();
  let out = cfg.layoutJson;
  for (let i = 0; i < argv.length; i++) {
    if (argv[i] === "--out") out = argv[++i];
  }
  const { outFile, layout } = await exportLayout({ config: cfg, out });
  console.log(`[OK] wrote ${outFile}`);
  console.log(`[OK] widgets=${layout.widgets.length} size=${layout.width}x${layout.height}`);
}

vue-lvgl/lib/png-to-lvgl-c.mjs

js 复制代码
import fs from "node:fs";
import path from "node:path";
import { PNG } from "pngjs";

function rgb565Bytes(png, targetW, targetH) {
  const w = targetW > 0 ? targetW : png.width;
  const h = targetH > 0 ? targetH : png.height;
  const bytes = Buffer.alloc(w * h * 2);
  for (let y = 0; y < h; y++) {
    for (let x = 0; x < w; x++) {
      const sx = Math.min(png.width - 1, Math.floor((x * png.width) / w));
      const sy = Math.min(png.height - 1, Math.floor((y * png.height) / h));
      const i = (png.width * sy + sx) << 2;
      const r = png.data[i];
      const g = png.data[i + 1];
      const b = png.data[i + 2];
      const rgb565 = ((r & 0xf8) << 8) | ((g & 0xfc) << 3) | (b >> 3);
      const o = (y * w + x) * 2;
      bytes[o] = rgb565 & 0xff;
      bytes[o + 1] = (rgb565 >> 8) & 0xff;
    }
  }
  return { w, h, bytes };
}

function formatMap(bytes) {
  const lines = [];
  for (let i = 0; i < bytes.length; i += 24) {
    const chunk = [];
    for (let j = i; j < Math.min(i + 24, bytes.length); j++) {
      chunk.push(`0x${bytes[j].toString(16).padStart(2, "0")}`);
    }
    lines.push("    " + chunk.join(", ") + (i + 24 < bytes.length ? "," : ""));
  }
  return lines.join("\n");
}

/**
 * @param {{ pngPath: string, symbol: string, width?: number, height?: number }[]} entries
 * @returns {string} C source
 */
export function pngEntriesToLvglC(entries) {
  if (!entries.length) {
    return `/**
 * @file ui_images.c
 * @brief AUTO-GENERATED --- no images in layout.
 */
#include "lvgl.h"
`;
  }

  const parts = [
    `/**
 * @file ui_images.c
 * @brief AUTO-GENERATED image assets (RGB565) --- do not edit.
 */
#include "lvgl.h"

#ifndef LV_ATTRIBUTE_MEM_ALIGN
#define LV_ATTRIBUTE_MEM_ALIGN
#endif
`
  ];

  for (const e of entries) {
    const png = PNG.sync.read(fs.readFileSync(e.pngPath));
    const { w, h, bytes } = rgb565Bytes(png, e.width | 0, e.height | 0);
    const stride = w * 2;
    const sym = e.symbol;
    parts.push(`
/* ${path.basename(e.pngPath)} → ${w}x${h} */
const LV_ATTRIBUTE_MEM_ALIGN uint8_t ${sym}_map[] = {
${formatMap(bytes)}
};

const lv_image_dsc_t ${sym} = {
    .header.magic = LV_IMAGE_HEADER_MAGIC,
    .header.cf = LV_COLOR_FORMAT_RGB565,
    .header.flags = 0,
    .header.w = ${w},
    .header.h = ${h},
    .header.stride = ${stride},
    .data_size = sizeof(${sym}_map),
    .data = ${sym}_map,
};
`);
  }

  return parts.join("\n");
}

export function writeImagesC(entries, outCPath) {
  const code = pngEntriesToLvglC(entries);
  fs.mkdirSync(path.dirname(outCPath), { recursive: true });
  fs.writeFileSync(outCPath, code, "utf8");
  return outCPath;
}

vue-lvgl/lib/json-to-ui-c.mjs

js 复制代码
import fs from "node:fs";
import path from "node:path";
import { loadConfig } from "./config.mjs";
import { writeImagesC } from "./png-to-lvgl-c.mjs";

function cIdent(id) {
  let s = String(id || "w").replace(/[^a-zA-Z0-9_]/g, "_");
  if (/^[0-9]/.test(s)) s = `w_${s}`;
  return s;
}

function hexToC(hex) {
  if (!hex || typeof hex !== "string") return "0xFFFFFF";
  let h = hex.replace("#", "").trim();
  // Expand #RGB → #RRGGBB
  if (h.length === 3 && /^[0-9a-fA-F]{3}$/.test(h)) {
    h = h
      .split("")
      .map((c) => c + c)
      .join("");
  }
  if (h.length < 6 || !/^[0-9a-fA-F]{6}/.test(h)) return "0xFFFFFF";
  return `0x${h.slice(0, 6).toUpperCase()}`;
}

function fontSymbol(px, fonts) {
  const n = Number(px) || fonts.small;
  if (n >= (fonts.threshold || 24)) {
    return `&lv_font_montserrat_${fonts.large}`;
  }
  return `&lv_font_montserrat_${fonts.small}`;
}

function escCString(s) {
  return String(s ?? "")
    .replace(/\\/g, "\\\\")
    .replace(/"/g, '\\"')
    .replace(/\n/g, "\\n");
}

function textAlign(align) {
  if (align === "center") return "LV_TEXT_ALIGN_CENTER";
  if (align === "right") return "LV_TEXT_ALIGN_RIGHT";
  return "LV_TEXT_ALIGN_LEFT";
}

/** Emit common label geometry/style lines (includes vertical centering). */
function emitLabelStyles(initLines, objExpr, w, fonts, indent) {
  const font = fontSymbol(w.font, fonts);
  initLines.push(`${indent}lv_obj_set_pos(${objExpr}, ${w.x | 0}, ${w.y | 0});`);
  initLines.push(`${indent}lv_obj_set_size(${objExpr}, ${w.w | 0}, ${w.h | 0});`);
  initLines.push(`${indent}lv_label_set_text(${objExpr}, "${escCString(w.text || "")}");`);
  initLines.push(
    `${indent}lv_obj_set_style_text_color(${objExpr}, lv_color_hex(${hexToC(w.color)}), 0);`
  );
  initLines.push(`${indent}lv_obj_set_style_text_font(${objExpr}, ${font}, 0);`);
  initLines.push(
    `${indent}lv_obj_set_style_text_align(${objExpr}, ${textAlign(w.align)}, 0);`
  );
  // LV_TEXT_ALIGN_* is horizontal only --- pad_top centers text in the measured box.
  initLines.push(
    `${indent}{ int _vh = (${w.h | 0}) - (int)lv_font_get_line_height(${font}); lv_obj_set_style_pad_top(${objExpr}, _vh > 0 ? _vh / 2 : 0, 0); }`
  );
  initLines.push(`${indent}lv_obj_set_style_pad_bottom(${objExpr}, 0, 0);`);
}

/**
 * Gradient text via LVGL bitmap mask (canvas L8 + bg gradient).
 * Returns C helper function source for this widget (empty if unbound one-shot only needs init).
 */
function emitGradLabel(staticVars, initLines, helpers, w, fonts) {
  const id = cIdent(w.id);
  const ww = Math.max(1, w.w | 0);
  const hh = Math.max(1, w.h | 0);
  const font = fontSymbol(w.font, fonts);
  const align = textAlign(w.align);
  const gradDir =
    String(w.colorGradDir || "hor").toLowerCase() === "ver" ? "LV_GRAD_DIR_VER" : "LV_GRAD_DIR_HOR";
  const mask = `mask_${id}`;
  const objName = w.bind ? `lbl_${id}` : `grad_${id}`;

  staticVars.push(`LV_DRAW_BUF_DEFINE_STATIC(${mask}, ${ww}, ${hh}, LV_COLOR_FORMAT_L8);`);
  if (!w.bind) staticVars.push(`static lv_obj_t *${objName};`);

  helpers.push(`static void ui_gradtext_${id}_apply(const char *text)
{
    if(!${objName} || !text) return;
    LV_DRAW_BUF_INIT_STATIC(${mask});
    lv_obj_t *canvas = lv_canvas_create(lv_screen_active());
    lv_canvas_set_draw_buf(canvas, &${mask});
    lv_canvas_fill_bg(canvas, lv_color_black(), LV_OPA_TRANSP);
    lv_layer_t layer;
    lv_canvas_init_layer(canvas, &layer);
    lv_draw_label_dsc_t label_dsc;
    lv_draw_label_dsc_init(&label_dsc);
    label_dsc.color = lv_color_white();
    label_dsc.align = ${align};
    label_dsc.text = text;
    label_dsc.font = ${font};
    lv_area_t a = {0, 0, ${ww} - 1, ${hh} - 1};
    lv_draw_label(&layer, &label_dsc, &a);
    lv_canvas_finish_layer(canvas, &layer);
    lv_obj_delete(canvas);
    lv_obj_set_style_bitmap_mask_src(${objName}, &${mask}, 0);
    lv_obj_invalidate(${objName});
}`);

  initLines.push(`    /* gradient text: ${w.id}${w.bind ? ` bind=${w.bind}` : ""} */`);
  initLines.push(`    ${objName} = lv_obj_create(scr);`);
  initLines.push(`    lv_obj_set_pos(${objName}, ${w.x | 0}, ${w.y | 0});`);
  initLines.push(`    lv_obj_set_size(${objName}, ${ww}, ${hh});`);
  initLines.push(`    lv_obj_set_style_radius(${objName}, 0, 0);`);
  initLines.push(`    lv_obj_set_style_border_width(${objName}, 0, 0);`);
  initLines.push(`    lv_obj_set_style_pad_all(${objName}, 0, 0);`);
  initLines.push(`    lv_obj_remove_flag(${objName}, LV_OBJ_FLAG_SCROLLABLE);`);
  initLines.push(`    lv_obj_set_style_bg_color(${objName}, lv_color_hex(${hexToC(w.color)}), 0);`);
  initLines.push(`    lv_obj_set_style_bg_grad_color(${objName}, lv_color_hex(${hexToC(w.colorGrad)}), 0);`);
  initLines.push(`    lv_obj_set_style_bg_grad_dir(${objName}, ${gradDir}, 0);`);
  initLines.push(`    lv_obj_set_style_bg_opa(${objName}, LV_OPA_COVER, 0);`);
  initLines.push(`    ui_gradtext_${id}_apply("${escCString(w.text || "")}");`);
  initLines.push("");
}

function resolveAssetPath(root, rel) {
  if (!rel) return null;
  const normalized = String(rel).replace(/\\/g, "/");
  const candidates = [
    path.resolve(root, normalized),
    path.resolve(root, "public", normalized),
    path.resolve(root, normalized.replace(/^public\//, ""))
  ];
  for (const p of candidates) {
    if (fs.existsSync(p)) return p;
  }
  throw new Error(`image not found: ${rel}`);
}

function imageSymbol(src, w, h) {
  const base = cIdent(path.basename(src, path.extname(src)));
  return `img_${base}_${w | 0}x${h | 0}`;
}

/**
 * Collect unique image assets from layout (background + image widgets).
 * @returns {{ entries: object[], symbolByKey: Map<string,string>, bgSymbol: string|null }}
 */
export function collectImageAssets(root, layout) {
  const symbolByKey = new Map();
  const entries = [];
  let bgSymbol = null;

  const add = (rel, w, h, isBg = false) => {
    const key = `${rel.replace(/\\/g, "/").toLowerCase()}@${w}x${h}`;
    if (symbolByKey.has(key)) {
      if (isBg) bgSymbol = symbolByKey.get(key);
      return symbolByKey.get(key);
    }
    const symbol = imageSymbol(rel, w, h);
    const pngPath = resolveAssetPath(root, rel);
    entries.push({ pngPath, symbol, width: w | 0, height: h | 0, src: rel });
    symbolByKey.set(key, symbol);
    if (isBg) bgSymbol = symbol;
    return symbol;
  };

  if (layout.backgroundImage) {
    add(layout.backgroundImage, layout.width, layout.height, true);
  }

  for (const w of layout.widgets || []) {
    if (w.type === "image" && w.src) {
      add(w.src, w.w, w.h, false);
    }
  }

  return { entries, symbolByKey, bgSymbol };
}

function symbolForWidget(layout, w, symbolByKey) {
  if (w.type !== "image" || !w.src) return null;
  const key = `${String(w.src).replace(/\\/g, "/").toLowerCase()}@${w.w | 0}x${w.h | 0}`;
  return symbolByKey.get(key) || null;
}

function genHeader(layout, boundIds, charts) {
  const binds = boundIds.map(
    (id) =>
      `/** Update label generated from widget id "${id}". */\nvoid ui_set_${cIdent(id)}(const char *text);`
  );
  const chartApis = (charts || []).map((w) => {
    const id = cIdent(w.id);
    const n = Math.max(2, w.points | 0);
    return `/** Line chart "${w.id}" (${n} points, Y ${w.ymin}..${w.ymax}). */
#define UI_CHART_${id.toUpperCase()}_POINTS ${n}
void ui_chart_${id}_set_next(int32_t value);
void ui_chart_${id}_set_values(const int32_t *values, uint16_t count);
void ui_chart_${id}_set_range(int32_t ymin, int32_t ymax);`;
  });
  const extra = [binds.join("\n\n"), chartApis.join("\n\n")].filter(Boolean).join("\n\n");
  return `/**
 * @file ui.h
 * @brief AUTO-GENERATED from layout.json --- do not edit by hand.
 * @note Regenerate: npx vue-lvgl gen-ui
 */
#ifndef UI_H
#define UI_H

#include <stdint.h>

#ifdef __cplusplus
extern "C" {
#endif

/** Create all widgets from exported Vue layout (${layout.width}x${layout.height}). */
void ui_init(void);

/** Keep compatibility with display_port key debug label (optional overlay). */
void ui_set_key(uint8_t key);

${extra}

#ifdef __cplusplus
}
#endif

#endif /* UI_H */
`;
}

function emitImage(initLines, comment, symbol, x, y, w, h, moveBg = false) {
  initLines.push(`    /* ${comment} */`);
  initLines.push(`    {`);
  initLines.push(`        lv_obj_t *img = lv_image_create(scr);`);
  initLines.push(`        lv_image_set_src(img, &${symbol});`);
  initLines.push(`        lv_obj_set_pos(img, ${x | 0}, ${y | 0});`);
  initLines.push(`        lv_obj_set_size(img, ${w | 0}, ${h | 0});`);
  if (moveBg) initLines.push(`        lv_obj_move_background(img);`);
  initLines.push(`    }`);
  initLines.push("");
}

function genSource(layout, opts) {
  const fonts = opts.fonts;
  const keyOverlay = opts.keyOverlay !== false;
  const symbolByKey = opts.symbolByKey || new Map();
  const bgSymbol = opts.bgSymbol || null;
  const widgets = Array.isArray(layout.widgets) ? layout.widgets : [];
  const labels = widgets.filter((w) => w.type === "label");
  const bound = labels.filter((w) => w.bind);
  const charts = widgets.filter((w) => w.type === "chart");
  const staticVars = [];
  const initLines = [];
  const helpers = [];

  if (keyOverlay) staticVars.push("static lv_obj_t *key_label;");

  for (const w of bound) {
    staticVars.push(`static lv_obj_t *lbl_${cIdent(w.id)};`);
  }
  for (const w of charts) {
    const id = cIdent(w.id);
    staticVars.push(`static lv_obj_t *chart_${id};`);
    staticVars.push(`static lv_chart_series_t *ser_${id};`);
  }

  initLines.push("    lv_obj_t *scr = lv_screen_active();");
  initLines.push(
    "    lv_obj_set_style_bg_color(scr, lv_color_hex(" + hexToC(layout.background) + "), 0);"
  );
  initLines.push("    lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);");
  initLines.push("");

  if (bgSymbol) {
    emitImage(
      initLines,
      "background image",
      bgSymbol,
      0,
      0,
      layout.width,
      layout.height,
      true
    );
  }

  for (const w of widgets) {
    const id = cIdent(w.id);

    if (w.type === "rect") {
      initLines.push(`    /* rect: ${w.id} */`);
      initLines.push(`    {`);
      initLines.push(`        lv_obj_t *obj = lv_obj_create(scr);`);
      initLines.push(`        lv_obj_set_pos(obj, ${w.x | 0}, ${w.y | 0});`);
      initLines.push(`        lv_obj_set_size(obj, ${w.w | 0}, ${w.h | 0});`);
      initLines.push(`        lv_obj_set_style_radius(obj, ${w.radius | 0}, 0);`);
      initLines.push(`        lv_obj_set_style_bg_color(obj, lv_color_hex(${hexToC(w.bg)}), 0);`);
      initLines.push(`        lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, 0);`);
      if (w.bgGrad) {
        const gradDir =
          String(w.bgGradDir || "ver").toLowerCase() === "hor" ? "LV_GRAD_DIR_HOR" : "LV_GRAD_DIR_VER";
        initLines.push(
          `        lv_obj_set_style_bg_grad_color(obj, lv_color_hex(${hexToC(w.bgGrad)}), 0);`
        );
        initLines.push(`        lv_obj_set_style_bg_grad_dir(obj, ${gradDir}, 0);`);
      }
      initLines.push(`        lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE);`);
      if (w.border) {
        initLines.push(`        lv_obj_set_style_border_width(obj, ${w.borderWidth | 1}, 0);`);
        initLines.push(
          `        lv_obj_set_style_border_color(obj, lv_color_hex(${hexToC(w.border)}), 0);`
        );
        initLines.push(`        lv_obj_set_style_border_opa(obj, LV_OPA_COVER, 0);`);
      } else {
        initLines.push(`        lv_obj_set_style_border_width(obj, 0, 0);`);
      }
      initLines.push(`        lv_obj_set_style_pad_all(obj, 0, 0);`);
      initLines.push(`    }`);
      initLines.push("");
      continue;
    }

    if (w.type === "chart") {
      const n = Math.max(2, w.points | 0);
      const ymin = w.ymin | 0;
      const ymax = (w.ymax | 0) <= ymin ? ymin + 1 : w.ymax | 0;
      const lw = Math.max(1, w.lineWidth | 0);
      const vals = Array.isArray(w.values) ? w.values : [];
      initLines.push(`    /* chart: ${w.id}${w.bind ? ` bind=${w.bind}` : ""} */`);
      initLines.push(`    chart_${id} = lv_chart_create(scr);`);
      initLines.push(`    lv_obj_set_pos(chart_${id}, ${w.x | 0}, ${w.y | 0});`);
      initLines.push(`    lv_obj_set_size(chart_${id}, ${w.w | 0}, ${w.h | 0});`);
      initLines.push(`    lv_chart_set_type(chart_${id}, LV_CHART_TYPE_LINE);`);
      initLines.push(`    lv_chart_set_update_mode(chart_${id}, LV_CHART_UPDATE_MODE_SHIFT);`);
      initLines.push(`    lv_chart_set_point_count(chart_${id}, ${n});`);
      initLines.push(`    lv_chart_set_axis_range(chart_${id}, LV_CHART_AXIS_PRIMARY_Y, ${ymin}, ${ymax});`);
      initLines.push(`    lv_chart_set_div_line_count(chart_${id}, ${w.divH | 0}, ${w.divV | 0});`);
      initLines.push(`    lv_obj_set_style_bg_color(chart_${id}, lv_color_hex(${hexToC(w.bg)}), 0);`);
      initLines.push(`    lv_obj_set_style_bg_opa(chart_${id}, LV_OPA_COVER, 0);`);
      initLines.push(`    lv_obj_set_style_radius(chart_${id}, ${w.radius | 0}, 0);`);
      initLines.push(`    lv_obj_set_style_border_width(chart_${id}, 0, 0);`);
      initLines.push(`    lv_obj_set_style_pad_all(chart_${id}, 2, 0);`);
      initLines.push(`    lv_obj_set_style_line_width(chart_${id}, ${lw}, LV_PART_ITEMS);`);
      initLines.push(`    lv_obj_set_style_line_rounded(chart_${id}, false, LV_PART_ITEMS);`);
      initLines.push(`    lv_obj_set_style_size(chart_${id}, 0, 0, LV_PART_INDICATOR);`);
      initLines.push(
        `    ser_${id} = lv_chart_add_series(chart_${id}, lv_color_hex(${hexToC(w.color)}), LV_CHART_AXIS_PRIMARY_Y);`
      );
      const nums = [];
      for (let i = 0; i < n; i++) {
        const v = vals[i] != null && Number.isFinite(Number(vals[i])) ? Math.round(Number(vals[i])) : ymin;
        nums.push(v);
      }
      const rows = [];
      for (let i = 0; i < nums.length; i += 16) {
        const chunk = nums.slice(i, i + 16).join(", ");
        const comma = i + 16 < nums.length ? "," : "";
        rows.push(`        ${chunk}${comma}`);
      }
      staticVars.push(`static const int32_t init_chart_${id}[${n}] = {`);
      staticVars.push(...rows);
      staticVars.push(`};`);
      initLines.push(`    {`);
      initLines.push(`        int32_t *y = lv_chart_get_series_y_array(chart_${id}, ser_${id});`);
      initLines.push(`        if(y) {`);
      initLines.push(`            lv_memcpy(y, init_chart_${id}, sizeof(init_chart_${id}));`);
      initLines.push(`            lv_chart_refresh(chart_${id});`);
      initLines.push(`        }`);
      initLines.push(`    }`);
      initLines.push("");
      continue;
    }

    if (w.type === "image") {
      const sym = symbolForWidget(layout, w, symbolByKey);
      if (!sym) {
        initLines.push(`    /* image skipped (missing asset): ${w.id} */`);
        initLines.push("");
        continue;
      }
      emitImage(initLines, `image: ${w.id}`, sym, w.x, w.y, w.w, w.h, false);
      continue;
    }

    if (w.type === "label") {
      if (w.colorGrad) {
        emitGradLabel(staticVars, initLines, helpers, w, fonts);
        continue;
      }
      const varName = w.bind ? `lbl_${id}` : null;
      initLines.push(`    /* label: ${w.id}${w.bind ? ` bind=${w.bind}` : ""} */`);
      if (varName) {
        initLines.push(`    ${varName} = lv_label_create(scr);`);
        initLines.push(`    lv_label_set_long_mode(${varName}, LV_LABEL_LONG_CLIP);`);
        emitLabelStyles(initLines, varName, w, fonts, "    ");
      } else {
        initLines.push(`    {`);
        initLines.push(`        lv_obj_t *lab = lv_label_create(scr);`);
        initLines.push(`        lv_label_set_long_mode(lab, LV_LABEL_LONG_CLIP);`);
        emitLabelStyles(initLines, "lab", w, fonts, "        ");
        initLines.push(`    }`);
      }
      initLines.push("");
    }
  }

  if (keyOverlay) {
    initLines.push("    /* debug key overlay (used by display_port / app) */");
    initLines.push("    key_label = lv_label_create(scr);");
    initLines.push('    lv_label_set_text(key_label, "Key:0");');
    initLines.push("    lv_obj_set_style_text_color(key_label, lv_color_hex(0x0FFFF0), 0);");
    initLines.push(
      `    lv_obj_set_style_text_font(key_label, &lv_font_montserrat_${fonts.small}, 0);`
    );
    initLines.push("    lv_obj_align(key_label, LV_ALIGN_BOTTOM_MID, 0, -2);");
  }

  const setters = bound
    .map((w) => {
      const id = cIdent(w.id);
      if (w.colorGrad) {
        return `void ui_set_${id}(const char *text)
{
    ui_gradtext_${id}_apply(text);
}`;
      }
      return `void ui_set_${id}(const char *text)
{
    if(lbl_${id} && text) lv_label_set_text(lbl_${id}, text);
}`;
    })
    .join("\n\n");

  const chartFns = charts
    .map((w) => {
      const id = cIdent(w.id);
      const n = Math.max(2, w.points | 0);
      return `void ui_chart_${id}_set_next(int32_t value)
{
    if(chart_${id} && ser_${id}) {
        lv_chart_set_next_value(chart_${id}, ser_${id}, value);
        lv_chart_refresh(chart_${id});
    }
}

void ui_chart_${id}_set_values(const int32_t *values, uint16_t count)
{
    if(!chart_${id} || !ser_${id} || !values) return;
    int32_t *y = lv_chart_get_series_y_array(chart_${id}, ser_${id});
    if(!y) return;
    uint16_t n = ${n};
    uint16_t i;
    if(count > n) count = n;
    lv_memcpy(y, values, (size_t)count * sizeof(int32_t));
    for(i = count; i < n; i++) y[i] = values[count ? count - 1 : 0];
    lv_chart_set_x_start_point(chart_${id}, ser_${id}, 0);
    lv_chart_refresh(chart_${id});
}

void ui_chart_${id}_set_range(int32_t ymin, int32_t ymax)
{
    if(!chart_${id}) return;
    if(ymax <= ymin) ymax = ymin + 1;
    lv_chart_set_axis_range(chart_${id}, LV_CHART_AXIS_PRIMARY_Y, ymin, ymax);
    lv_chart_refresh(chart_${id});
}`;
    })
    .join("\n\n");

  const keyFn = keyOverlay
    ? `void ui_set_key(uint8_t key)
{
    if(key_label) lv_label_set_text_fmt(key_label, "Key:%u", (unsigned)key);
}`
    : `void ui_set_key(uint8_t key)
{
    (void)key;
}`;

  const decls = [...new Set([bgSymbol, ...symbolByKey.values()].filter(Boolean))]
    .map((s) => `LV_IMAGE_DECLARE(${s});`)
    .join("\n");

  return `/**
 * @file ui.c
 * @brief AUTO-GENERATED from layout.json --- do not edit by hand.
 *
 * Design size: ${layout.width} x ${layout.height}
 *
 * Regenerate:
 *   npx vue-lvgl gen-ui
 *
 * Images: see ui_images.c (RGB565). Fonts map to montserrat_${fonts.small}/${fonts.large}.
 */
#include "ui.h"
#include "lvgl.h"
${decls ? decls + "\n" : ""}
${staticVars.join("\n")}
${helpers.length ? "\n" + helpers.join("\n\n") + "\n" : ""}
void ui_init(void)
{
${initLines.join("\n")}
}

${keyFn}

${setters}
${chartFns ? "\n" + chartFns + "\n" : ""}
`;
}

export function generateUiC(layout, opts = {}) {
  const fonts = opts.fonts || { threshold: 24, small: 14, large: 28 };
  const widgets = layout.widgets || [];
  const boundIds = widgets
    .filter((w) => w.type === "label" && w.bind)
    .map((w) => w.id);
  const charts = widgets.filter((w) => w.type === "chart");
  return {
    uiH: genHeader(layout, boundIds, charts),
    uiC: genSource(layout, {
      fonts,
      keyOverlay: opts.keyOverlay,
      bgSymbol: opts.bgSymbol,
      symbolByKey: opts.symbolByKey
    }),
    boundIds
  };
}

export async function writeUiFiles(options = {}) {
  const cfg = options.config || (await loadConfig(options.cwd));
  const jsonPath = options.json || cfg.layoutJson;
  const outDir = options.out || cfg.outDir;
  const copyDir = options.copy !== undefined ? options.copy : cfg.copyTo;

  if (!fs.existsSync(jsonPath)) {
    throw new Error(`layout json not found: ${jsonPath}`);
  }

  const layout = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
  const { entries, symbolByKey, bgSymbol } = collectImageAssets(cfg.root, layout);

  fs.mkdirSync(outDir, { recursive: true });
  const imagesC = path.join(outDir, "ui_images.c");
  writeImagesC(entries, imagesC);
  console.log(`[OK] wrote ${imagesC} (${entries.length} image(s))`);

  const { uiH, uiC } = generateUiC(layout, {
    fonts: cfg.fonts,
    keyOverlay: cfg.keyOverlay,
    bgSymbol,
    symbolByKey
  });

  const outH = path.join(outDir, "ui.h");
  const outC = path.join(outDir, "ui.c");
  fs.writeFileSync(outH, uiH, "utf8");
  fs.writeFileSync(outC, uiC, "utf8");

  // remove legacy single-bg file if present
  const legacyBg = path.join(outDir, "ui_bg_img.c");
  if (fs.existsSync(legacyBg)) fs.unlinkSync(legacyBg);

  const result = { outH, outC, imagesC, imageCount: entries.length, layout, copied: null };

  if (copyDir) {
    fs.mkdirSync(copyDir, { recursive: true });
    const files = [
      [outH, path.join(copyDir, "ui.h")],
      [outC, path.join(copyDir, "ui.c")],
      [imagesC, path.join(copyDir, "ui_images.c")]
    ];
    const legacyDst = path.join(copyDir, "ui_bg_img.c");
    for (const [src, dst] of files) {
      if (fs.existsSync(dst) && !fs.existsSync(dst + ".bak")) {
        fs.copyFileSync(dst, dst + ".bak");
        console.log(`[OK] backup ${dst}.bak`);
      }
      fs.copyFileSync(src, dst);
    }
    if (fs.existsSync(legacyDst)) {
      fs.unlinkSync(legacyDst);
      console.log(`[OK] removed legacy ${legacyDst}`);
    }
    result.copied = copyDir;
  }

  const dw = cfg.display?.width;
  const dh = cfg.display?.height;
  if (dw && dh && (layout.width !== dw || layout.height !== dh)) {
    console.warn(
      `[WARN] layout is ${layout.width}x${layout.height}, configured display is ${dw}x${dh}.`
    );
  }

  return result;
}

export async function runGenUiCli(argv = []) {
  const cfg = await loadConfig();
  const args = { json: cfg.layoutJson, out: cfg.outDir, copy: cfg.copyTo };
  const rest = [];
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    if (a === "--out") args.out = path.resolve(argv[++i]);
    else if (a === "--copy") args.copy = path.resolve(argv[++i]);
    else if (a === "--no-copy") args.copy = null;
    else rest.push(a);
  }
  if (rest[0]) args.json = path.resolve(rest[0]);

  const result = await writeUiFiles({
    config: { ...cfg, layoutJson: args.json, outDir: args.out, copyTo: args.copy },
    json: args.json,
    out: args.out,
    copy: args.copy
  });

  console.log(`[OK] wrote ${result.outC}`);
  console.log(`[OK] wrote ${result.outH}`);
  if (result.copied) console.log(`[OK] copied to ${result.copied}`);
  console.log(`[OK] images embedded: ${result.imageCount}`);
}

vue-lvgl/src/index.js

js 复制代码
export { formatBoundValue, formatHms, formatClock, fontSize, pad2 } from "./format.js";
export { measureInScreen, readPx, rgbToHex } from "./measure.js";

export { default as MeterScreen } from "./components/MeterScreen.vue";
export { default as MeterBox } from "./components/MeterBox.vue";
export { default as MeterLabel } from "./components/MeterLabel.vue";
export { default as MeterImage } from "./components/MeterImage.vue";
export { default as MeterChart } from "./components/MeterChart.vue";

/**
 * Call from App mounted() so `npx vue-lvgl export` can read the layout.
 * @param {() => object} getLayout
 */
export function installExportHooks(getLayout) {
  if (typeof window === "undefined") return;
  window.__LVGL_EXPORT_LAYOUT__ = typeof getLayout === "function" ? getLayout : () => getLayout;
  window.__LVGL_EXPORT_READY__ = true;
}

export function uninstallExportHooks() {
  if (typeof window === "undefined") return;
  try {
    delete window.__LVGL_EXPORT_LAYOUT__;
    delete window.__LVGL_EXPORT_READY__;
  } catch {
    /* ignore */
  }
}

vue-lvgl/src/measure.js

js 复制代码
/** Measure a child element in design pixels relative to the screen root. */
export function measureInScreen(el, screenEl, scale = 1) {
  const s = screenEl.getBoundingClientRect();
  const r = el.getBoundingClientRect();
  const k = scale || 1;
  return {
    x: Math.round((r.left - s.left) / k),
    y: Math.round((r.top - s.top) / k),
    w: Math.max(1, Math.round(r.width / k)),
    h: Math.max(1, Math.round(r.height / k))
  };
}

export function readPx(styleValue) {
  if (!styleValue) return 0;
  const n = parseFloat(styleValue);
  return Number.isFinite(n) ? Math.round(n) : 0;
}

export function rgbToHex(color) {
  if (!color) return "#FFFFFF";
  if (color.startsWith("#")) {
    let h = color.slice(1);
    if (h.length === 3 && /^[0-9a-fA-F]{3}$/.test(h)) {
      h = h
        .split("")
        .map((c) => c + c)
        .join("");
    }
    return h.length >= 6 ? `#${h.slice(0, 6).toUpperCase()}` : color;
  }
  const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
  if (!m) return "#FFFFFF";
  const hex = (n) => Number(n).toString(16).padStart(2, "0");
  return `#${hex(m[1])}${hex(m[2])}${hex(m[3])}`.toUpperCase();
}

vue-lvgl/src/format.js

js 复制代码
export function pad2(n) {
  return (n < 10 ? "0" : "") + n;
}

export function formatHms(totalSec) {
  const h = Math.floor(totalSec / 3600);
  const m = Math.floor((totalSec % 3600) / 60);
  const s = Math.floor(totalSec % 60);
  return pad2(h) + ":" + pad2(m) + ":" + pad2(s);
}

export function formatClock(date) {
  date = date || new Date();
  return (
    date.getFullYear() +
    "-" +
    pad2(date.getMonth() + 1) +
    "-" +
    pad2(date.getDate()) +
    "  " +
    pad2(date.getHours()) +
    ":" +
    pad2(date.getMinutes()) +
    ":" +
    pad2(date.getSeconds())
  );
}

/** Mirror firmware formatting rules used by LVGL runtime. */
export function formatBoundValue(widget, data) {
  const key = widget.bind;
  if (!key) return widget.text != null ? widget.text : "";

  if (key === "clock") return formatClock();
  if (key === "outputOn") return data.outputOn ? "ON" : "OFF";

  const value = data[key];
  const fmt = widget.format;

  if (!fmt) return String(value != null ? value : "");
  if (fmt === "onoff") return data.outputOn ? "ON" : "OFF";
  if (fmt === "hms") return formatHms(Number(value) || 0);

  if (fmt.indexOf("%") !== -1) {
    if (fmt === "%05.2f") return (Number(value) < 10 ? "0" : "") + Number(value).toFixed(2);
    if (fmt === "%.2f") return Number(value).toFixed(2);
    if (fmt === "%.3f") return Number(value).toFixed(3);
    if (fmt === "%.0f") return String(Math.round(Number(value)));
    if (fmt === "%.3fWh") return Number(value).toFixed(3) + "Wh";
    if (fmt === "%.3fAh") return Number(value).toFixed(3) + "Ah";
    if (fmt === "%05.2fV")
      return (Number(value) < 10 ? "0" : "") + Number(value).toFixed(2) + "V";
    if (fmt === "%.3fA") return Number(value).toFixed(3) + "A";
  }

  return String(value != null ? value : widget.text != null ? widget.text : "");
}

export function fontSize(px) {
  return px + "px";
}

vue-lvgl/src/components/MeterScreen.vue

vue 复制代码
<script>
export default {
  name: "MeterScreen",
  props: {
    width: { type: Number, default: 320 },
    height: { type: Number, default: 240 },
    background: { type: String, default: "#2A2A2A" },
    /** Project-relative path, e.g. shared/assets/back.png --- exported into layout JSON */
    backgroundImage: { type: String, default: "" },
    data: { type: Object, required: true },
    scale: { type: Number, default: 1 }
  },
  provide() {
    return { meterScreen: this };
  },
  data() {
    return { widgets: [] };
  },
  computed: {
    stageStyle() {
      return {
        width: `${this.width * this.scale}px`,
        height: `${this.height * this.scale}px`
      };
    },
    screenStyle() {
      const style = {
        width: `${this.width}px`,
        height: `${this.height}px`,
        backgroundColor: this.background,
        transform: `scale(${this.scale})`,
        transformOrigin: "top left"
      };
      if (this.backgroundImage) {
        // served from /shared/... via public/ or vite static
        const url = this.backgroundImage.startsWith("/")
          ? this.backgroundImage
          : `/${this.backgroundImage.replace(/\\/g, "/")}`;
        style.backgroundImage = `url(${url})`;
        style.backgroundSize = "100% 100%";
        style.backgroundRepeat = "no-repeat";
        style.backgroundPosition = "center";
      }
      return style;
    }
  },
  methods: {
    registerWidget(vm) {
      if (this.widgets.indexOf(vm) === -1) this.widgets.push(vm);
    },
    unregisterWidget(vm) {
      const i = this.widgets.indexOf(vm);
      if (i >= 0) this.widgets.splice(i, 1);
    },
    getScreenEl() {
      return this.$refs.screen;
    },
    toLayoutJSON() {
      // Keep registration order so z-order matches the Vue template.
      const widgets = this.widgets
        .map((w) => w.toWidgetJSON())
        .filter(Boolean);
      const layout = {
        width: this.width,
        height: this.height,
        background: this.background,
        data: JSON.parse(JSON.stringify(this.data)),
        widgets
      };
      if (this.backgroundImage) {
        layout.backgroundImage = this.backgroundImage.replace(/\\/g, "/");
      }
      return layout;
    }
  }
};
</script>

<template>
  <div class="stage" :style="stageStyle">
    <div ref="screen" class="screen" :style="screenStyle">
      <slot />
    </div>
  </div>
</template>

<style scoped>
.stage {
  position: relative;
}
.screen {
  position: relative;
  overflow: hidden;
  box-sizing: border-box;
}
</style>

vue-lvgl/src/components/MeterBox.vue

vue 复制代码
<script>
import { measureInScreen, readPx, rgbToHex } from "../measure.js";

/**
 * Layout box --- CSS place it.
 * Optional bgImage (project-relative PNG) exports as type "image" for LVGL.
 */
export default {
  name: "MeterBox",
  inject: ["meterScreen"],
  props: {
    id: { type: String, required: true },
    bg: { type: String, default: "" },
    /** Second color for gradient (with bg). */
    bgGrad: { type: String, default: "" },
    /** Gradient direction: "ver" (top→bottom) or "hor" (left→right). */
    bgGradDir: { type: String, default: "ver" },
    /** Project-relative image, e.g. shared/assets/panel.png */
    bgImage: { type: String, default: "" },
    border: { type: String, default: "" },
    borderWidth: { type: Number, default: null },
    radius: { type: Number, default: null }
  },
  computed: {
    boxStyle() {
      const style = {};
      if (this.bgImage) {
        const url = this.bgImage.startsWith("/")
          ? this.bgImage
          : `/${this.bgImage.replace(/\\/g, "/")}`;
        style.backgroundImage = `url(${url})`;
        style.backgroundSize = "100% 100%";
        style.backgroundRepeat = "no-repeat";
        style.backgroundPosition = "center";
        style.backgroundColor = "transparent";
      } else if (this.bg && this.bgGrad) {
        const dir = String(this.bgGradDir || "ver").toLowerCase() === "hor" ? "to right" : "to bottom";
        style.background = `linear-gradient(${dir}, ${this.bg}, ${this.bgGrad})`;
      } else if (this.bg) {
        style.background = this.bg;
      }
      if (this.radius != null) style.borderRadius = `${this.radius}px`;
      if (this.border) {
        style.border = `${this.borderWidth != null ? this.borderWidth : 1}px solid ${this.border}`;
      }
      return style;
    }
  },
  created() {
    this.meterScreen.registerWidget(this);
  },
  beforeDestroy() {
    this.meterScreen.unregisterWidget(this);
  },
  methods: {
    toWidgetJSON() {
      const screenEl = this.meterScreen.getScreenEl();
      const box = measureInScreen(this.$el, screenEl, this.meterScreen.scale);
      const cs = window.getComputedStyle(this.$el);
      const radius = this.radius != null ? this.radius : readPx(cs.borderTopLeftRadius);

      if (this.bgImage) {
        const json = {
          id: this.id,
          type: "image",
          x: box.x,
          y: box.y,
          w: box.w,
          h: box.h,
          src: this.bgImage.replace(/\\/g, "/"),
          radius
        };
        const bw = this.borderWidth != null ? this.borderWidth : readPx(cs.borderTopWidth);
        const borderColor = this.border || (bw > 0 ? rgbToHex(cs.borderTopColor) : "");
        if (borderColor && bw > 0) {
          json.border = borderColor;
          json.borderWidth = bw;
        }
        return json;
      }

      const json = {
        id: this.id,
        type: "rect",
        x: box.x,
        y: box.y,
        w: box.w,
        h: box.h,
        bg: this.bg || rgbToHex(cs.backgroundColor),
        radius
      };
      if (this.bgGrad) {
        json.bgGrad = rgbToHex(this.bgGrad);
        json.bgGradDir = String(this.bgGradDir || "ver").toLowerCase() === "hor" ? "hor" : "ver";
      }
      const bw = this.borderWidth != null ? this.borderWidth : readPx(cs.borderTopWidth);
      const borderColor = this.border || (bw > 0 ? rgbToHex(cs.borderTopColor) : "");
      if (borderColor && bw > 0) {
        json.border = borderColor;
        json.borderWidth = bw;
      }
      return json;
    }
  }
};
</script>

<template>
  <div class="meter-box" :style="boxStyle">
    <slot />
  </div>
</template>

<style scoped>
.meter-box {
  box-sizing: border-box;
  position: relative;
}
</style>

vue-lvgl/src/components/MeterLabel.vue

vue 复制代码
<script>
import { formatBoundValue, fontSize as fontSizeCss } from "../format.js";
import { measureInScreen, readPx, rgbToHex } from "../measure.js";

/**
 * Text widget --- place with CSS; export measures DOM box for LVGL.
 * Optional colorGrad + colorGradDir for black→green (etc.) gradient text.
 */
export default {
  name: "MeterLabel",
  inject: ["meterScreen"],
  props: {
    id: { type: String, default: "" },
    text: { type: String, default: "" },
    color: { type: String, default: "" },
    /** Second color for text gradient (with color). */
    colorGrad: { type: String, default: "" },
    /** Gradient direction: "ver" (top→bottom) or "hor" (left→right). */
    colorGradDir: { type: String, default: "hor" },
    font: { type: Number, default: null },
    align: { type: String, default: "" },
    bind: { type: String, default: "" },
    format: { type: String, default: "" }
  },
  computed: {
    widgetId() {
      return this.id || this.bind || `label_${this._uid}`;
    },
    displayText() {
      const widget = {
        text: this.slotText || this.text,
        bind: this.bind,
        format: this.format
      };
      return formatBoundValue(widget, this.meterScreen.data);
    },
    slotText() {
      const slot = this.$slots.default;
      if (!slot || !slot.length) return "";
      return slot
        .map((vnode) => (vnode.text != null ? String(vnode.text) : ""))
        .join("")
        .trim();
    },
    useGrad() {
      return !!(this.color && this.colorGrad);
    },
    labelStyle() {
      const style = {};
      if (this.font != null) {
        style.fontSize = fontSizeCss(this.font);
        style.fontWeight = this.font >= 28 ? "700" : "600";
      }
      if (this.align === "center") style.justifyContent = "center";
      if (this.align === "right") style.justifyContent = "flex-end";
      if (this.align === "left") style.justifyContent = "flex-start";
      if (this.useGrad) {
        const dir = String(this.colorGradDir || "hor").toLowerCase() === "ver" ? "to bottom" : "to right";
        style.backgroundImage = `linear-gradient(${dir}, ${this.color}, ${this.colorGrad})`;
        style.WebkitBackgroundClip = "text";
        style.backgroundClip = "text";
        style.WebkitTextFillColor = "transparent";
        style.color = "transparent";
      } else if (this.color) {
        style.color = this.color;
      }
      return style;
    }
  },
  created() {
    this.meterScreen.registerWidget(this);
  },
  beforeDestroy() {
    this.meterScreen.unregisterWidget(this);
  },
  methods: {
    toWidgetJSON() {
      const screenEl = this.meterScreen.getScreenEl();
      const box = measureInScreen(this.$el, screenEl, this.meterScreen.scale);
      const cs = window.getComputedStyle(this.$el);
      const fontPx = this.font != null ? this.font : readPx(cs.fontSize);
      let align = this.align;
      if (!align) {
        const jc = cs.justifyContent;
        if (jc === "center") align = "center";
        else if (jc === "flex-end" || jc === "end" || jc === "right") align = "right";
        else align = "left";
      }
      const json = {
        id: this.widgetId,
        type: "label",
        x: box.x,
        y: box.y,
        w: box.w,
        h: box.h,
        text: this.slotText || this.text || this.displayText,
        color: rgbToHex(this.color || cs.color),
        font: fontPx,
        align
      };
      if (this.useGrad) {
        json.colorGrad = rgbToHex(this.colorGrad);
        json.colorGradDir = String(this.colorGradDir || "hor").toLowerCase() === "ver" ? "ver" : "hor";
      }
      if (this.bind) json.bind = this.bind;
      if (this.format) json.format = this.format;
      return json;
    }
  }
};
</script>

<template>
  <div class="meter-label" :style="labelStyle">{{ displayText }}</div>
</template>

<style scoped>
.meter-label {
  display: flex;
  align-items: center;
  box-sizing: border-box;
  white-space: nowrap;
  line-height: 1;
  font-variant-numeric: tabular-nums;
  font-weight: 600;
}
</style>

vue-lvgl/src/components/MeterChart.vue

vue 复制代码
<script>
import { measureInScreen, rgbToHex } from "../measure.js";

/**
 * Line chart widget --- CSS place it; export records geometry + series for LVGL lv_chart.
 *
 * bind: key in MeterScreen.data, value is number[] (or a single number ignored).
 * points: rolling window size (LVGL point count).
 */
export default {
  name: "MeterChart",
  inject: ["meterScreen"],
  props: {
    id: { type: String, required: true },
    bind: { type: String, default: "" },
    color: { type: String, default: "#F5C518" },
    bg: { type: String, default: "#000000" },
    points: { type: Number, default: 1024 },
    ymin: { type: Number, default: 0 },
    ymax: { type: Number, default: 100 },
    lineWidth: { type: Number, default: 2 },
    divH: { type: Number, default: 3 },
    divV: { type: Number, default: 4 },
    radius: { type: Number, default: 0 },
    smooth: { type: Boolean, default: false }
  },
  computed: {
    series() {
      const key = this.bind;
      const raw = key && this.meterScreen.data ? this.meterScreen.data[key] : null;
      const arr = Array.isArray(raw) ? raw.map((n) => Number(n)).filter((n) => Number.isFinite(n)) : [];
      const n = Math.max(2, this.points | 0);
      if (arr.length >= n) return arr.slice(-n);
      if (!arr.length) {
        const mid = (this.ymin + this.ymax) / 2;
        return Array.from({ length: n }, () => mid);
      }
      const out = arr.slice();
      while (out.length < n) out.unshift(out[0]);
      return out;
    },
    pathD() {
      const vals = this.series;
      const n = vals.length;
      if (n < 2) return "";
      const ymin = this.ymin;
      const ymax = this.ymax <= ymin ? ymin + 1 : this.ymax;
      const span = ymax - ymin;
      const pts = vals.map((v, i) => {
        const y = 100 - ((v - ymin) / span) * 100;
        return { x: i, y: Math.max(0, Math.min(100, y)) };
      });
      if (!this.smooth) {
        return pts.map((p, i) => `${i === 0 ? "M" : "L"}${p.x} ${p.y.toFixed(2)}`).join(" ");
      }
      let d = `M${pts[0].x} ${pts[0].y.toFixed(2)}`;
      for (let i = 0; i < n - 1; i++) {
        const p0 = pts[i - 1] || pts[i];
        const p1 = pts[i];
        const p2 = pts[i + 1];
        const p3 = pts[i + 2] || p2;
        const c1x = p1.x + (p2.x - p0.x) / 6;
        const c1y = p1.y + (p2.y - p0.y) / 6;
        const c2x = p2.x - (p3.x - p1.x) / 6;
        const c2y = p2.y - (p3.y - p1.y) / 6;
        d += ` C${c1x.toFixed(2)} ${c1y.toFixed(2)} ${c2x.toFixed(2)} ${c2y.toFixed(2)} ${p2.x} ${p2.y.toFixed(2)}`;
      }
      return d;
    },
    plotViewBox() {
      const n = Math.max(2, this.series.length);
      return `0 0 ${n - 1} 100`;
    },
    gridLines() {
      const h = Math.max(0, this.divH | 0);
      const v = Math.max(0, this.divV | 0);
      const x2 = Math.max(1, this.series.length - 1);
      const lines = [];
      for (let i = 1; i < h; i++) {
        const y = (i / h) * 100;
        lines.push({ x1: 0, y1: y, x2, y2: y });
      }
      for (let i = 1; i < v; i++) {
        const x = (i / v) * x2;
        lines.push({ x1: x, y1: 0, x2: x, y2: 100 });
      }
      return lines;
    },
    wrapStyle() {
      const style = { background: this.bg };
      if (this.radius) style.borderRadius = `${this.radius}px`;
      return style;
    }
  },
  created() {
    this.meterScreen.registerWidget(this);
  },
  beforeDestroy() {
    this.meterScreen.unregisterWidget(this);
  },
  methods: {
    toWidgetJSON() {
      const screenEl = this.meterScreen.getScreenEl();
      const box = measureInScreen(this.$el, screenEl, this.meterScreen.scale);
      const json = {
        id: this.id,
        type: "chart",
        x: box.x,
        y: box.y,
        w: box.w,
        h: box.h,
        color: rgbToHex(this.color),
        bg: rgbToHex(this.bg),
        points: Math.max(2, this.points | 0),
        ymin: this.ymin,
        ymax: this.ymax,
        lineWidth: this.lineWidth,
        divH: this.divH,
        divV: this.divV,
        radius: this.radius | 0,
        smooth: !!this.smooth,
        values: this.series.slice()
      };
      if (this.bind) json.bind = this.bind;
      return json;
    }
  }
};
</script>

<template>
  <div class="meter-chart" :style="wrapStyle">
    <svg class="plot" :viewBox="plotViewBox" preserveAspectRatio="none">
      <line
        v-for="(g, i) in gridLines"
        :key="i"
        :x1="g.x1"
        :y1="g.y1"
        :x2="g.x2"
        :y2="g.y2"
        stroke="#2A3344"
        stroke-width="0.4"
      />
      <path
        :d="pathD"
        fill="none"
        :stroke="color"
        :stroke-width="lineWidth"
        stroke-linejoin="miter"
        stroke-linecap="butt"
        shape-rendering="geometricPrecision"
        vector-effect="non-scaling-stroke"
      />
    </svg>
  </div>
</template>

<style scoped>
.meter-chart {
  box-sizing: border-box;
  position: relative;
  overflow: hidden;
  width: 100%;
  height: 100%;
}
.plot {
  display: block;
  width: 100%;
  height: 100%;
}
</style>

vue-lvgl/src/components/MeterImage.vue

vue 复制代码
<script>
import { measureInScreen } from "../measure.js";

/**
 * Image widget --- CSS place it; export records src + measured box for LVGL.
 * src: project-relative path, e.g. shared/assets/icon.png
 */
export default {
  name: "MeterImage",
  inject: ["meterScreen"],
  props: {
    id: { type: String, required: true },
    src: { type: String, required: true },
    alt: { type: String, default: "" }
  },
  computed: {
    imgUrl() {
      const s = this.src.replace(/\\/g, "/");
      return s.startsWith("/") ? s : `/${s}`;
    },
    imgStyle() {
      return {
        backgroundImage: `url(${this.imgUrl})`,
        backgroundSize: "100% 100%",
        backgroundRepeat: "no-repeat",
        backgroundPosition: "center"
      };
    }
  },
  created() {
    this.meterScreen.registerWidget(this);
  },
  beforeDestroy() {
    this.meterScreen.unregisterWidget(this);
  },
  methods: {
    toWidgetJSON() {
      const screenEl = this.meterScreen.getScreenEl();
      const box = measureInScreen(this.$el, screenEl, this.meterScreen.scale);
      return {
        id: this.id,
        type: "image",
        x: box.x,
        y: box.y,
        w: box.w,
        h: box.h,
        src: this.src.replace(/\\/g, "/")
      };
    }
  }
};
</script>

<template>
  <div class="meter-image" :style="imgStyle" :aria-label="alt || id" role="img" />
</template>

<style scoped>
.meter-image {
  box-sizing: border-box;
  background-color: transparent;
}
</style>

vue-lvgl/templates/vue-lvgl.config.js

js 复制代码
/**
 * Example config --- copy with: npx vue-lvgl init
 * All paths are relative to the host project root (this project only).
 */
export default {
  /** Exported layout JSON */
  layoutJson: "shared/default-layout.json",

  /** Where ui.c / ui.h are written */
  outDir: "generated",

  /**
   * Optional: also copy ui.c/ui.h into a directory inside this project.
   * Leave unset / null --- do not point at other repositories.
   */
  // copyTo: "firmware/main",

  viteConfig: "vite.config.js",
  exportPort: 5179,
  exportQuery: "export=1",
  display: { width: 320, height: 240 },
  fonts: { threshold: 24, small: 14, large: 28 },
  keyOverlay: false // show a keyboard overlay on the display for testing
};

vue-lvgl/README.md

md 复制代码
# vue-lvgl

Vue 布局 → layout JSON → LVGL9 `ui.c` / `ui.h` / `ui_images.c`。  
随宿主项目一起存放,**不要用 `file:../其它项目/vue-lvgl` 跨仓库引用**。

---

## 备注:主项目怎么引用

1. 把整个 `vue-lvgl/` 目录放在**主项目根目录**下(不要带它里面的 `node_modules`)。
2. 在主项目 `package.json` 里用**本目录相对路径**声明依赖:

```json
{
  "dependencies": {
    "vue": "^2.7.16",
    "vue-lvgl": "file:./vue-lvgl"
  },
  "devDependencies": {
    "@vitejs/plugin-vue2": "^2.3.3",
    "playwright": "^1.49.1",
    "pngjs": "^7.0.0",
    "vite": "^5.4.11",
    "vue-template-compiler": "^2.7.16"
  },
  "scripts": {
    "dev": "vite",
    "export-layout": "vue-lvgl export",
    "gen-ui": "vue-lvgl gen-ui",
    "export-and-gen": "vue-lvgl build"
  }
}
  1. 代码里这样引用组件 / 导出钩子:
js 复制代码
import {
  MeterScreen,
  MeterBox,
  MeterLabel,
  MeterImage,
  installExportHooks,
  uninstallExportHooks
} from "vue-lvgl";

file:./vue-lvgl 会让 npm 把本地包链到 node_modules/vue-lvgl,并注册命令 vue-lvgl(来自包内 bin)。


备注:怎么下载依赖

主项目根目录 执行(不要先跑进 vue-lvgl/ 里单独乱装,除非排错):

bat 复制代码
cd /d 你的主项目根目录
npm install

会安装:

依赖 谁提供 用途
vue / vue-template-compiler 主项目 页面
vite / @vitejs/plugin-vue2 主项目 预览 + 无头导出起服务
playwright 主项目(peer) 无头浏览器测布局
pngjs vue-lvgl 自带 / 也可写在主项目 PNG → RGB565
vue-lvgl file:./vue-lvgl 组件 + CLI

首次做 export 前还要装 Chromium(Playwright 浏览器,体积较大):

powershell 复制代码
$env:PLAYWRIGHT_DOWNLOAD_HOST='https://cdn.npmmirror.com/binaries/playwright'
npx playwright install chromium

(国内网络建议加上面的镜像环境变量。)

初始化配置文件(若还没有 vue-lvgl.config.js):

bat 复制代码
npx vue-lvgl init

命令

命令 作用
npm run dev 浏览器预览
npx vue-lvgl export / npm run export-layout shared/default-layout.json
npx vue-lvgl gen-ui / npm run gen-ui generated/ui.c + ui.h + ui_images.c
npx vue-lvgl build / npm run export-and-gen export + gen-ui

Vue 导出约定

js 复制代码
mounted() {
  installExportHooks(() => this.$refs.page.exportLayout());
}
  • window.__LVGL_EXPORT_READY__ === true
  • window.__LVGL_EXPORT_LAYOUT__() → layout 对象

图片

  • 全屏:background-image="shared/assets/xxx.png"
  • 控件:<MeterImage id="a" src="shared/assets/a.png" />
  • 盒子底图:<MeterBox id="p" bg-image="shared/assets/p.png" />
  • 折线图:<MeterChart id="wave" bind="wave" :points="40" :ymin="0" :ymax="100" color="#F5C518" />
    data.wavenumber[]。生成固件后可用 ui_chart_wave_set_next(v) / ui_chart_wave_set_values(...) 动态改数据。

路径相对主项目根目录 ;预览时保证 Vite 能访问到该路径(例如同步到 public/shared/...)。

折线图:MeterChart 导出为 JSON type:"chart"gen-ui 生成 LVGL lv_chart 以及 ui_chart_<id>_set_next / set_values / set_range。固件需开启 CONFIG_LV_USE_CHART


配置 vue-lvgl.config.js(主项目根目录)

js 复制代码
export default {
  layoutJson: "shared/default-layout.json",
  outDir: "generated",
  display: { width: 320, height: 240 },
  fonts: { threshold: 24, small: 14, large: 28 },
  keyOverlay: false
};

vue-lvgl/templates/vue-lvgl.config.js 只是模板,以主项目根目录下的配置为准

yaml 复制代码
---

## 4. 固件侧用法

```c
ui_init();

/* 必须在 LVGL 任务里调用 */
ui_set_setU("05.00V");
ui_set_mode("CC");
ui_chart_wave_set_next(sample);

ui.h 里由生成器声明 ui_set_* / ui_chart_*,对应页面里带 bind 的控件。


5. 注意

  • 改布局:改 Vue → npm run export-and-gen → 再拷 generated 到固件
  • 改实时数:只调 ui_set_*,不必每次重导
  • ui_set_* 只能在 LVGL 任务调用
  • 有折线图需 CONFIG_LV_USE_CHART;渐变字需 Canvas 相关配置
相关推荐
2分钟速写快排24 分钟前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60611 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角2 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!2 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
芯片人0072 小时前
纯硬件一键开关机ASIC芯片,为什么比 MCU 方案更适合更可靠
人工智能·单片机·嵌入式硬件·物联网·芯片
北岛贰2 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi4 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen5 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马5 小时前
AI源码分析:拆解模型底层实现逻辑
前端
IT_陈寒6 小时前
React子组件莫名其妙重渲染?你可能漏了这个Hook
前端·人工智能·后端