Python+Slint实现蜂巢菜单功能的完整代码
作者:小灰灰搞电子
一、效果展示



二、源码分享
1、工程结构

2、main.py
# -*- coding: utf-8 -*-
import math
import sys
import threading
from pathlib import Path
import slint
BASE_DIR = Path(__file__).parent.resolve()
IMAGE_DIR = BASE_DIR / "image"
SLINT_PATH = BASE_DIR / "main.slint"
IMG_EXTS = {".svg", ".png", ".jpg", ".jpeg", ".bmp", ".webp"}
# ── 蜂巢几何 ────────────────────────────────────────
CELL_SIZE = 110 # 单元尺寸 px
ICON_D = CELL_SIZE # 圆形底盘直径
H_SPACE = int(ICON_D * 0.98)
V_SPACE = int(ICON_D * 0.85)
CANVAS_W = 960
CANVAS_H = 580
# 5 行蜂巢布局(3-4-5-4-3 对称,共 19 格)
HEX_LAYOUT = [
[0, 1, 2],
[3, 4, 5, 6],
[7, 8, 9, 10, 11],
[12, 13, 14, 15],
[16, 17, 18],
]
# ── 鱼眼参数 ────────────────────────────────────────
SCALE_MIN = 0.55
SCALE_MAX = 1.50
FISHEYE_R = 200.0
UPDATE_MS = 25
# ══════════════════════════════════════════════════════
# 图片加载
# ══════════════════════════════════════════════════════
def load_images():
if not IMAGE_DIR.exists():
print(f"[错误] 图片目录不存在: {IMAGE_DIR}")
sys.exit(1)
imgs = sorted(
f for f in IMAGE_DIR.iterdir()
if f.is_file() and f.suffix.lower() in IMG_EXTS
)
if not imgs:
print("[错误] image 文件夹中没有图片")
sys.exit(1)
# print(f"[信息] 已加载 {len(imgs)} 张图片")
#for i, f in enumerate(imgs):
# print(f" {i:2d}: {f.name}")
return imgs
# ══════════════════════════════════════════════════════
# 蜂巢坐标
# ══════════════════════════════════════════════════════
def build_items(imgs):
n = len(imgs)
items = []
center_x = CANVAS_W / 2
for ri, row in enumerate(HEX_LAYOUT):
cnt = len(row)
first_cx = center_x - (cnt - 1) * H_SPACE / 2
cy = 100 + ri * V_SPACE
for ci, idx in enumerate(row):
cx = first_cx + ci * H_SPACE
actual = idx % n
items.append({
"cx": float(cx),
"cy": float(cy),
"name": imgs[actual].stem,
"img_idx": actual,
})
return items
# ══════════════════════════════════════════════════════
# 鱼眼缩放
# ══════════════════════════════════════════════════════
_model_ref = None
_items_cache = None
def compute_scales(items, mx, my):
result = []
for it in items:
dx = it["cx"] - mx
dy = it["cy"] - my
dist = math.sqrt(dx * dx + dy * dy)
if dist >= FISHEYE_R:
scale = SCALE_MIN
else:
t = dist / FISHEYE_R
scale = SCALE_MIN + (SCALE_MAX - SCALE_MIN) * math.exp(-2.5 * t * t)
result.append(round(scale, 3))
return result
def update_model(scales):
if _model_ref is None or _items_cache is None:
return
for i, it in enumerate(_items_cache):
_model_ref.set_row_data(i, {
"cx": it["cx"],
"cy": it["cy"],
"scale": scales[i],
"name": it["name"],
"img-idx": it["img_idx"],
})
# ══════════════════════════════════════════════════════
# 主程序
# ══════════════════════════════════════════════════════
def main():
imgs = load_images()
items = build_items(imgs)
# 编译 main.slint
try:
ui_mod = slint.load_file(str(SLINT_PATH))
except Exception as e:
print("[错误] main.slint 编译失败:")
for d in (e.args[1] if len(e.args) > 1 else []):
print(f" {d}")
sys.exit(1)
MainWindow = ui_mod.MainWindow
ui = MainWindow()
# 创建 model
global _model_ref, _items_cache
_items_cache = items
init_data = []
for it in items:
init_data.append({
"cx": it["cx"], "cy": it["cy"],
"scale": SCALE_MIN,
"name": it["name"], "img-idx": it["img_idx"],
})
model = slint.ListModel(init_data)
_model_ref = model
ui.hex_items = model
# 鼠标追踪
mouse_state = {"x": CANVAS_W / 2, "y": CANVAS_H / 2, "dirty": False}
lock = threading.Lock()
def on_mouse_moved(mx, my):
with lock:
mouse_state["x"] = float(mx)
mouse_state["y"] = float(my)
mouse_state["dirty"] = True
ui.mouse_moved = on_mouse_moved
# 定时器
def tick():
with lock:
if mouse_state["dirty"]:
mouse_state["dirty"] = False
mx, my = mouse_state["x"], mouse_state["y"]
else:
return
scales = compute_scales(items, mx, my)
update_model(scales)
timer = slint.Timer()
timer.start(slint.TimerMode.Repeated,
__import__("datetime").timedelta(milliseconds=UPDATE_MS),
tick)
# 点击回调
def on_click(name, idx):
print(f"[选择] {name}(索引 {idx})")
ui.item_clicked = on_click
ui.run()
if __name__ == "__main__":
main()
4、main.slint
export struct HexItem {
cx: length,
cy: length,
scale: float,
name: string,
img-idx: int,
}
component IconCell inherits Rectangle {
in property <length> cell-size: 110px;
in property <float> cell-scale: 1.0;
in property <string> item-name;
in property <int> img-idx: -1;
in property <[image]> fruit-imgs;
callback cell-clicked();
background: #00000000;
property <float> anim-scale <=> cell-scale;
animate anim-scale { duration: 180ms; easing: ease-out; }
property <length> sz: cell-size * anim-scale;
property <length> off: (cell-size - sz) / 2;
// 圆形深色底盘
Rectangle {
x: off; y: off; width: sz; height: sz;
border-radius: sz / 2;
background: @radial-gradient(circle, #1e293b, #0f172a);
}
// 圆形边框环
Rectangle {
x: off; y: off; width: sz; height: sz;
border-radius: sz / 2;
background: #00000000;
border-width: 1.5px;
border-color: #334155;
}
// 图片(数组索引)
property <length> img-pad: sz * 0.18;
if img-idx >= 0 && img-idx < fruit-imgs.length: Image {
x: off + img-pad;
y: off + img-pad;
width: sz - img-pad * 2;
height: sz - img-pad * 2;
source: fruit-imgs[img-idx];
image-fit: contain;
}
// 名称标签
if anim-scale > 1.15: Text {
y: cell-size + 2px;
width: cell-size;
text: item-name;
font-size: 11px;
color: #e2e8f0;
horizontal-alignment: center;
overflow: elide;
}
touch := TouchArea {
clicked => { root.cell-clicked(); }
}
}
export component MainWindow inherits Window {
title: "蜂巢菜单";
preferred-width: 1000px;
preferred-height: 660px;
min-width: 800px; min-height: 600px;
background: #060d1a;
// 水果图片数组
property <[image]> fruit-imgs: [
@image-url("image/apple.svg"),
@image-url("image/avocado.svg"),
@image-url("image/banana.svg"),
@image-url("image/bayberry.svg"),
@image-url("image/blueberry.svg"),
@image-url("image/cherry.svg"),
@image-url("image/dragonfruit.svg"),
@image-url("image/grape.svg"),
@image-url("image/mango.svg"),
@image-url("image/mangosteen.svg"),
@image-url("image/orange.svg"),
@image-url("image/peach.svg"),
@image-url("image/persimmon.svg"),
@image-url("image/pineapple.svg"),
@image-url("image/strawberry.svg"),
@image-url("image/watermelon.svg"),
@image-url("image/waxapple.svg")
];
in property <[HexItem]> hex-items;
in-out property <int> selected-idx: -1;
in-out property <string> selected-name: "";
callback item-clicked(string, int);
callback mouse-moved(float, float);
Rectangle {
width: 100%; height: 100%;
background: #060d1a;
// 背景微光
Rectangle {
x: parent.width / 2 - 300px;
y: parent.height / 2 - 300px;
width: 600px; height: 600px;
border-radius: 300px;
background: @radial-gradient(circle, #0f1e38, #060d1a00);
}
// 蜂巢图标
for itm[idx] in hex-items: IconCell {
x: itm.cx - 55px;
y: itm.cy - 55px;
width: 110px;
height: 110px;
cell-scale: itm.scale;
item-name: itm.name;
img-idx: itm.img-idx;
fruit-imgs: root.fruit-imgs;
cell-clicked => {
selected-idx = idx;
selected-name = itm.name;
root.item-clicked(itm.name, idx);
}
}
// 鼠标追踪覆盖层
TouchArea {
width: 100%; height: 100%;
/*moved => {
root.mouse-moved(self.mouse-x / 1px, self.mouse-y / 1px);
}*/
pointer-event(event) => {
if event.kind == PointerEventKind.move {
root.mouse-moved(self.mouse-x / 1px, self.mouse-y / 1px);
}
}
}
}
// 底部信息栏
Rectangle {
y: parent.height - 36px;
width: parent.width; height: 36px;
background: #0d1b2ecc;
HorizontalLayout {
padding-left: 20px; padding-right: 20px;
Rectangle { y: 14px; width: 8px; height: 8px; border-radius: 4px;
}
}
// 底部信息栏
Rectangle {
y: parent.height - 36px;
width: parent.width; height: 36px;
background: #0d1b2ecc;
HorizontalLayout {
padding-left: 20px; padding-right: 20px;
Rectangle { y: 14px; width: 8px; height: 8px; border-radius: 4px;
background: selected-idx >= 0 ? #fbbf24 : #4a5568; }
Text {
text: selected-idx >= 0
? ("已选择:" + selected-name)
: "移动鼠标体验鱼眼 + 蜂巢效果 · 点击选择";
font-size: 12px; color: #94a3b8; vertical-alignment: center;
}
Rectangle { horizontal-stretch: 1; }
Text { text: "共 " + hex-items.length + " 项";
font-size: 11px; color: #4a5568; vertical-alignment: center; }
}
}
}
}
三、实现原理
本项目整体采用 Python 负责逻辑计算、Slint 负责界面渲染 的前后端分离架构:Python 端负责蜂巢坐标计算、鱼眼缩放算法、图片加载与事件回调,Slint 端负责绘制蜂巢图标、处理鼠标交互与动画过渡。两者通过 Slint 的模型(Model)与回调(Callback)机制进行数据交换,实现界面与逻辑的解耦。
1、蜂巢布局算法
蜂巢(Hexagon)布局的核心是错位排列:奇数行与偶数行在水平方向上相互错开半个单元间距,从而形成六边形蜂窝结构。本项目采用 5 行对称布局(3-4-5-4-3,共 19 格),通过二维数组 HEX_LAYOUT 描述每一行的格子数量:
HEX_LAYOUT = [
[0, 1, 2],
[3, 4, 5, 6],
[7, 8, 9, 10, 11],
[12, 13, 14, 15],
[16, 17, 18],
]
在 build_items() 函数中,每一行的起始 X 坐标通过 center_x - (cnt - 1) * H_SPACE / 2 计算,使整行水平居中;Y 坐标则按 100 + ri * V_SPACE 逐行递增。其中 H_SPACE = ICON_D * 0.98、V_SPACE = ICON_D * 0.85,水平间距略小于直径、垂直间距略小于直径,正是这种行间错位 + 间距压缩的组合,让圆形图标在视觉上呈现出蜂巢般的紧密排列效果。
2、鱼眼缩放算法
鱼眼效果(Fisheye)是本项目的视觉亮点:鼠标附近的图标放大、远离鼠标的图标缩小,形成类似鱼眼镜头的光学畸变感。核心实现在 compute_scales() 函数中:
def compute_scales(items, mx, my):
result = []
for it in items:
dx = it["cx"] - mx
dy = it["cy"] - my
dist = math.sqrt(dx * dx + dy * dy)
if dist >= FISHEYE_R:
scale = SCALE_MIN
else:
t = dist / FISHEYE_R
scale = SCALE_MIN + (SCALE_MAX - SCALE_MIN) * math.exp(-2.5 * t * t)
result.append(round(scale, 3))
return result
算法流程如下:
- 计算距离:遍历每个蜂巢格子的中心坐标
(cx, cy),计算其与鼠标位置(mx, my)的欧几里得距离dist; - 阈值判断:若距离超过影响半径
FISHEYE_R = 200px,则缩放系数直接取最小值SCALE_MIN = 0.55,即远离鼠标的图标保持最小尺寸; - 高斯衰减:若距离在影响半径内,则通过高斯函数
exp(-2.5 * t²)计算缩放系数——距离越近,t越小,exp(-2.5 * t²)越接近 1,缩放系数越接近最大值SCALE_MAX = 1.50;距离越远,缩放系数平滑衰减至最小值。
这种连续平滑的衰减曲线避免了生硬的跳变,让图标在鼠标移动时产生流畅的放大/缩小过渡,配合 Slint 端的 180ms 动画,视觉体验更加柔和自然。
3、Python 与 Slint 的数据交互
Python 与 Slint 之间通过**模型(Model)和回调(Callback)**两条通道进行通信:
3.1 Python → Slint:数据推送
Python 端将蜂巢格子的坐标、缩放系数、名称、图片索引等数据封装为 ListModel,并赋值给 Slint 端的 hex-items 属性:
model = slint.ListModel(init_data) ui.hex_items = model
当鼠标移动触发鱼眼计算后,update_model() 函数通过 set_row_data() 逐行更新模型数据,Slint 端监听到模型变化后自动触发界面重绘:
def update_model(scales):
for i, it in enumerate(_items_cache):
_model_ref.set_row_data(i, {
"cx": it["cx"], "cy": it["cy"],
"scale": scales[i],
"name": it["name"], "img-idx": it["img_idx"],
})
3.2 Slint → Python:事件回调
Slint 端通过 TouchArea 捕获鼠标移动事件,并通过 mouse-moved 回调将坐标传给 Python:
pointer-event(event) => {
if event.kind == PointerEventKind.move {
root.mouse-moved(self.mouse-x / 1px, self.mouse-y / 1px);
}
}
Python 端注册回调函数接收坐标,并存入线程安全的共享状态:
def on_mouse_moved(mx, my):
with lock:
mouse_state["x"] = float(mx)
mouse_state["y"] = float(my)
mouse_state["dirty"] = True
ui.mouse_moved = on_mouse_moved
4、定时器驱动的渲染循环
为了避免鼠标高频移动导致频繁重绘、消耗过多 CPU,本项目采用定时器 + 脏标记的优化策略:
- 鼠标移动事件只负责更新
mouse_state中的坐标并置dirty = True,不立即触发重绘; - 定时器每
UPDATE_MS = 25ms触发一次tick(),检查dirty标记; - 若标记为
True,则取出最新坐标,执行鱼眼计算并更新模型;若为False,则直接跳过本次计算。
def tick():
with lock:
if mouse_state["dirty"]:
mouse_state["dirty"] = False
mx, my = mouse_state["x"], mouse_state["y"]
else:
return
scales = compute_scales(items, mx, my)
update_model(scales)
这种事件驱动 + 定时采样的模式,将渲染频率稳定控制在 40 FPS(1000ms / 25ms),既保证了动画的流畅性,又避免了无谓的计算开销。
5、Slint 端的动画与视觉呈现
Slint 端通过 IconCell 组件封装单个蜂巢图标,其核心是属性绑定驱动的动画:
property <float> anim-scale <=> cell-scale;
animate anim-scale { duration: 180ms; easing: ease-out; }
anim-scale 与外部传入的 cell-scale 双向绑定,当 Python 端更新缩放系数时,Slint 自动以 180ms 的缓动动画过渡到新值,产生平滑的放大/缩小效果。同时,sz = cell-size * anim-scale 动态计算图标尺寸,off = (cell-size - sz) / 2 保证缩放时图标始终居中。
此外,Slint 端还实现了两个细节:
- 名称标签显隐:当
anim-scale > 1.15(即图标放大到一定程度)时才显示名称文字,避免小图标上文字拥挤; - 圆形底盘 + 边框环:通过
@radial-gradient径向渐变绘制深色圆形底盘,叠加半透明边框环,营造立体质感。
以上就是Python+Slint实现蜂巢菜单功能的完整代码的详细内容,更多关于Python Slint蜂巢菜单功能的资料请关注脚本之家其它相关文章!
