一、效果展示



二、源码分享
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蜂巢菜单功能的资料请关注代码网其它相关文章!
发表评论