本文按当前
lvgl-meter工程整理:主工程用法 +vue-lvgl工具包全部源码 。流程:
PowerMeter.vue→shared/default-layout.json→generated/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"
}
}
- 代码里这样引用组件 / 导出钩子:
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__ === truewindow.__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.wave为number[]。生成固件后可用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 相关配置