python

关注公众号 jb51net

关闭
首页 > 脚本专栏 > python > Python Slint蜂巢菜单功能

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

作者:小灰灰搞电子

想知道如何用Python实现蜂巢图标鱼眼缩放效果吗,本文详细解析了蜂巢布局算法、鱼眼缩放算法以及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

算法流程如下:

  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 端还实现了两个细节:

以上就是Python+Slint实现蜂巢菜单功能的完整代码的详细内容,更多关于Python Slint蜂巢菜单功能的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文