从 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 相关配置
相关推荐
郭邯1 小时前
用 AI 辅助开发一个文件大小转换工具:从需求到上线的完整过程
前端
勾勾圈圈蛋蛋1 小时前
前中台项目
前端
触底反弹1 小时前
🔐 前端鉴权不再难!手把手带你搞懂 JWT + Zustand + Axios 的完整登录方案
前端·axios
打呵欠的猫2 小时前
我用 AI 写了一个"需求翻译器",产品的 PRD 直接变成开发任务清单
前端·ai编程
fatcoder2 小时前
玩转Nginx 09 — 运维排障:三场"火灾"实战演练
前端·后端·nginx
前端玩坑坑2 小时前
View Transitions API 入门教程,优雅过度
前端
李高钢2 小时前
WPF MVVM Light(三):RelayCommand 命令与 Messenger 消息
前端·c#·wpf
lichenyang4533 小时前
Socket.IO 原理与技术选型
前端
qq21084629533 小时前
在python中什么是 self 和 cls?
开发语言·前端·python