当前位置: 代码网 > it编程>前端脚本>Python > Python+Slint实现蜂巢菜单功能的完整代码

Python+Slint实现蜂巢菜单功能的完整代码

2026年10月10日 • Python •我要评论
一、效果展示二、源码分享1、工程结构2、main.py# -*- coding: utf-8 -*-import mathimport sysimport threadingfrom pathlib

一、效果展示

二、源码分享

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

算法流程如下:

  1. 计算距离:遍历每个蜂巢格子的中心坐标 (cx, cy),计算其与鼠标位置 (mx, my) 的欧几里得距离 dist;
  2. 阈值判断:若距离超过影响半径 fisheye_r = 200px,则缩放系数直接取最小值 scale_min = 0.55,即远离鼠标的图标保持最小尺寸;
  3. 高斯衰减:若距离在影响半径内,则通过高斯函数 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,本项目采用定时器 + 脏标记的优化策略:

  1. 鼠标移动事件只负责更新 mouse_state 中的坐标并置 dirty = true,不立即触发重绘;
  2. 定时器每 update_ms = 25ms 触发一次 tick(),检查 dirty 标记;
  3. 若标记为 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蜂巢菜单功能的资料请关注代码网其它相关文章!

赞 (0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2026  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com