Глава 27: GUI-библиотеки на Rust

Содержание

Rust — мощный системный язык программирования, который привлекает разработчиков своей производительностью и безопасностью памяти. Однако, когда речь заходит о создании графических интерфейсов (GUI), новички могут столкнуться с вопросом: "С чего начать?". Экосистема Rust для GUI пока не так развита, как у C++ (Qt) или Python (Tkinter), но она активно растёт, предлагая как низкоуровневые инструменты, так и высокоуровневые фреймворки. В этой статье мы разберём, как можно создавать GUI на Rust — от работы с нативным Windows API до использования современных библиотек вроде iced, с примерами кода, плюсами и минусами каждого подхода. Мы также затронем реальные задачи, такие как отображение RTSP-потоков с IP-камер в сетке 3x3, чтобы показать, как применять эти инструменты на практике.


1. Почему GUI в Rust интересен?

GUI-приложения — это визуальный способ взаимодействия с пользователем, будь то простое окно с кнопкой или сложный интерфейс вроде Discord или RustDesk (программа для удалённого доступа). Rust подходит для таких задач, потому что:

Но есть и вызовы: экосистема GUI в Rust молодая, и выбор инструментов может сбивать с толку. Давайте разберём основные подходы.


2. Низкоуровневый подход: Windows API

Если вы хотите максимальный контроль и работаете только с Windows, можно использовать Windows API (Win32) напрямую через привязки в Rust, такие как крейт windows.

Как это работает?

Windows API — это набор функций, предоставляемых операционной системой Windows для создания окон, обработки событий и управления элементами интерфейса (кнопками, списками и т.д.). В Rust мы вызываем эти функции через крейт windows, который делает их безопасными и удобными.

Пример: Окно с кнопкой

//rust
use windows::{
    core::*,
    Win32::Foundation::*,
    Win32::System::LibraryLoader::GetModuleHandleA,
    Win32::UI::WindowsAndMessaging::*,
};

fn main() -> Result<()> {
    unsafe {
        let instance = GetModuleHandleA(None)?;
        let wc = WNDCLASSA {
            style: CS_HREDRAW | CS_VREDRAW,
            lpfnWndProc: Some(wnd_proc),
            hInstance: instance,
            lpszClassName: s!("RustWindowClass"),
            ..Default::default()
        };
        RegisterClassA(&wc)?;

        let hwnd = CreateWindowExA(
            WINDOW_EX_STYLE::default(),
            s!("RustWindowClass"),
            s!("Окно с кнопкой"),
            WS_OVERLAPPEDWINDOW | WS_VISIBLE,
            CW_USEDEFAULT, CW_USEDEFAULT, 800, 600,
            None, None, instance, None,
        );

        CreateWindowExA(
            WINDOW_EX_STYLE::default(),
            s!("BUTTON"),
            s!("Нажми меня"),
            WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON,
            50, 50, 100, 30,
            hwnd,
            HMENU(1),
            instance,
            None,
        );

        let mut msg = MSG::default();
        while GetMessageA(&mut msg, None, 0, 0).into() {
            TranslateMessage(&msg);
            DispatchMessageA(&msg);
        }
        Ok(())
    }
}

extern "system" fn wnd_proc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
    unsafe {
        match msg {
            WM_COMMAND => {
                if wparam.0 == 1 {
                    println!("Кнопка нажата!");
                }
                LRESULT(0)
            }
            WM_DESTROY => {
                PostQuitMessage(0);
                LRESULT(0)
            }
            _ => DefWindowProcA(hwnd, msg, wparam, lparam),
        }
    }
}

Зависимости (Cargo.toml):

//toml
[dependencies]
windows = { version = "0.52", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging", "Win32_System_LibraryLoader"] }

Что здесь происходит?

  1. Регистрируем класс окна (WNDCLASSA) с функцией обработки сообщений (wnd_proc).
  2. Создаём окно с помощью CreateWindowExA.
  3. Добавляем кнопку как дочерний элемент окна.
  4. Запускаем цикл сообщений для обработки событий (нажатие кнопки, закрытие окна).

Плюсы:

Минусы:

Совместимость с Wine

Интересный момент: приложение на Windows API, скомпилированное как .exe, часто запускается на Linux через Wine без изменений, если использует стандартные функции (например, CreateWindowExA, GetMessageA). Wine транслирует вызовы Win32 в POSIX/Linux API, так что базовый GUI (окно, кнопки) будет работать. Однако:


3. Минимализм с winit и softbuffer

Если вы хотите чуть больше абстракции, но не готовые фреймворки, можно использовать winit (для окон и событий) и softbuffer (для рендеринга пикселей).

Как это работает?

Пример: Окно с зелёным фоном

use winit::{
    event::{Event, WindowEvent},
    event_loop::{ControlFlow, EventLoop},
    window::WindowBuilder,
};
use softbuffer::GraphicsContext;

fn main() {
    let event_loop = EventLoop::new().unwrap();
    let window = WindowBuilder::new()
        .with_title("Простое окно на Rust")
        .with_inner_size(winit::dpi::LogicalSize::new(800, 600))
        .build(&event_loop)
        .unwrap();

    let mut graphics_context = unsafe { GraphicsContext::new(&window, &window) }.unwrap();

    event_loop.run(move |event, _, control_flow| {
        *control_flow = ControlFlow::Wait;

        match event {
            Event::WindowEvent {
                event: WindowEvent::CloseRequested,
                ..
            } => *control_flow = ControlFlow::Exit,
            Event::RedrawRequested(_) => {
                let (width, height) = window.inner_size().into();
                let buffer = vec![0xFF00FF00; (width * height) as usize]; // Зелёный фон
                graphics_context.set_buffer(&buffer, width as u16, height as u16);
            }
            _ => (),
        }
    });
}

Зависимости:

//toml
[dependencies]
winit = "0.29"
softbuffer = "0.4"

Что здесь происходит?

  1. Создаём окно через WindowBuilder.
  2. Инициализируем GraphicsContext для рендеринга.
  3. В цикле событий заполняем буфер пикселей зелёным цветом и отображаем его.

Плюсы:

Минусы:


4. OpenGL: Всё с нуля!

Ты сам рисуешь интерфейс с помощью OpenGL!

Пример кода:

Cargo.toml

[dependencies]
winit = "0.29"
glow = "0.13"

main.rs

use winit::event::{Event, WindowEvent};
use winit::event_loop::{ControlFlow, EventLoop};
use glow::HasContext;

fn main() {
    let event_loop = EventLoop::new().unwrap();
    let window = winit::window::WindowBuilder::new()
        .with_title("Моё приложение")
        .build(&event_loop)
        .unwrap();
    let gl_context = unsafe { glow::Context::from_loader_function(|s| window.get_proc_address(s) as *const _) };
    let gl = &gl_context;

    event_loop.run(move |event, _, control_flow| {
        *control_flow = ControlFlow::Wait;
        match event {
            Event::WindowEvent { event: WindowEvent::CloseRequested, .. } => *control_flow = ControlFlow::Exit,
            Event::RedrawRequested(_) => {
                unsafe {
                    gl.clear_color(0.0, 0.5, 0.0, 1.0); // Зелёный фон
                    gl.clear(glow::COLOR_BUFFER_BIT);
                }
                window.request_redraw();
            }
            _ => {}
        }
    }).unwrap();
}

Подводные камни: Сложность шейдеров, кроссплатформенность.


5. Высокоуровневый подход: iced

Для более удобной разработки можно использовать iced — кроссплатформенный GUI-фреймворк в стиле Elm, с декларативным синтаксисом.

Как это работает?

Пример: Окно с счётчиком

use iced::widget::{button, column, text, Button, Column};
use iced::{Element, Sandbox, Settings};

#[derive(Default)]
struct CounterApp {
    counter: i32,
}

#[derive(Debug, Clone)]
enum Message {
    Increment,
}

impl Sandbox for CounterApp {
    type Message = Message;

    fn new() -> Self {
        Self::default()
    }

    fn title(&self) -> String {
        "Счётчик".into()
    }

    fn update(&mut self, message: Message) {
        match message {
            Message::Increment => self.counter += 1,
        }
    }

    fn view(&self) -> Element {
        column![
            text(format!("Счётчик: {}", self.counter)),
            button("Нажми").on_press(Message::Increment),
        ]
        .into()
    }
}

fn main() -> iced::Result {
    CounterApp::run(Settings::default())
}

Зависимости:

[dependencies]
iced = "0.12"

Что здесь происходит?

  1. Определяем состояние приложения (CounterApp) и сообщения (Message).
  2. Описываем интерфейс через column! с текстом и кнопкой.
  3. Обрабатываем нажатие кнопки в update.

Плюсы:

Минусы:


6. Интеграция Rust с другими технологиями: FFI и Flutter

Иногда GUI требует комбинации Rust с другими инструментами. Например, RustDesk использует Flutter для интерфейса, а Rust — для бэкенда.

Что такое FFI?

FFI (Foreign Function Interface) — это механизм, позволяющий вызывать функции одного языка из другого. В Rust он часто используется для связи с C-совместимыми библиотеками.

Пример: Rust + Dart (Flutter)

Rust:

#[no_mangle]
pub extern "C" fn add_numbers(a: i32, b: i32) -> i32 {
    a + b
}

Dart:

import 'dart:ffi';
final dylib = DynamicLibrary.open('libmath.so');
typedef AddNumbersFunc = Int32 Function(Int32, Int32);
typedef AddNumbersDart = int Function(int, int);
final addNumbers = dylib.lookupFunction('add_numbers');
print(addNumbers(5, 3)); // 8

Как это связано с GUI?

Плюсы:

Минусы:


7. Electron: Rust + JavaScript для современного GUI

Ещё один популярный подход к созданию GUI — использование Electron, фреймворка, который позволяет строить кроссплатформенные приложения с помощью HTML, CSS и JavaScript, упакованные в Chromium. Discord, например, построен на Electron, как и Revolt — открытая альтернатива Discord. Rust можно интегрировать с Electron, чтобы совместить производительность Rust с мощью веб-технологий.

Как это работает?

Способы интеграции

  1. FFI через Neon:
  2. Отдельный процесс:

Пример: Rust + Electron через Neon

Rust (файл lib.rs):

use neon::prelude::*;

fn hello(mut cx: FunctionContext) -> JsResult {
    Ok(cx.string("Привет из Rust!"))
}

#[neon::main]
fn main(mut cx: ModuleContext) -> NeonResult<()> {
    cx.export_function("hello", hello)?;
    Ok(())
}
Cargo.toml:
[package]
name = "rust-electron"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
neon = "0.10"

JavaScript (Electron, файл main.js):

const { app, BrowserWindow } = require('electron');
const path = require('path');
const hello = require('./native/index.node').hello;

function createWindow() {
    const win = new BrowserWindow({
        width: 800,
        height: 600,
        webPreferences: {
            nodeIntegration: true,
            contextIsolation: false,
        },
    });

    win.loadFile('index.html');
    console.log(hello()); // Вывод: Привет из Rust!
}

app.whenReady().then(createWindow);

HTML (файл index.html):

<!DOCTYPE html>
<html>
<body>
    <h1>Rust + Electron</h1>
    <p>Проверь консоль разработчика!</p>
</body>
</html>
package.json
{
    "name": "rust-electron",
    "version": "1.0.0",
    "main": "main.js",
    "scripts": {
        "start": "electron .",
        "build": "cargo build --release && cp target/release/rust_electron.node native/index.node"
    },
    "dependencies": {
        "electron": "^23.0.0"
    }
}

Как собрать?

  1. Установите Node.js и Electron: npm install electron.
  2. Скомпилируйте Rust-модуль: cargo build --release.
  3. Скопируйте .node-файл в папку native: cp target/release/rust_electron.node native/index.node.
  4. Запустите приложение: npm start.

Что здесь происходит?

Плюсы:

Минусы:

Discord и Revolt


8. Реальный пример: Сетка 3x3 для RTSP-потоков

Давайте применим эти подходы к задаче: отобразить 9 RTSP-потоков (H.264/H.265) с IP-камер в сетке 3x3.

Требования

Инструменты

Решение с iced

use ffmpeg_next::{codec, decoder, format, media, software::scaling, util::frame::video::Video};
use iced::{
    widget::{column, image, row, Column, Image},
    Element, Length, Sandbox, Settings,
};
use std::sync::mpsc::{channel, Receiver};

#[derive(Default)]
struct VideoGrid {
    frames: Vec>,
    receiver: Option>>>,
}

#[derive(Debug, Clone)]
enum Message {
    FrameReceived,
}

impl Sandbox for VideoGrid {
    type Message = Message;

    fn new() -> Self {
        let (sender, receiver) = channel();
        spawn_video_threads(sender);
        Self {
            frames: vec![vec![]; 9],
            receiver: Some(receiver),
        }
    }

    fn title(&self) -> String {
        "RTSP Grid".into()
    }

    fn update(&mut self, message: Message) {
        if let Message::FrameReceived = message {
            if let Some(receiver) = &self.receiver {
                if let Ok(frames) = receiver.try_recv() {
                    self.frames = frames;
                }
            }
        }
    }

    fn view(&self) -> Element {
        let grid = (0..3).fold(Column::new().spacing(5), |col, i| {
            let row = (0..3).fold(Row::new().spacing(5), |row, j| {
                let idx = i * 3 + j;
                let frame = self.frames.get(idx).unwrap_or(&vec![]);
                row.push(Image::new(image::Handle::from_pixels(1920, 1080, frame.clone())))
            });
            col.push(row)
        });
        grid.into()
    }
}

fn spawn_video_threads(sender: std::sync::mpsc::Sender>>) {
    std::thread::spawn(move || {
        ffmpeg_next::init().unwrap();
        let urls = vec!["rtsp://user:pass@camera:554/stream"; 9];
        let mut streams = urls
            .iter()
            .map(|url| {
                let mut ictx = format::input(&url).unwrap();
                let stream = ictx.streams().best(media::Type::Video).unwrap();
                let context = codec::context::Context::from_parameters(stream.parameters()).unwrap();
                let mut decoder = context.decoder().video().unwrap();
                let mut scaler = scaling::Context::get(
                    decoder.format(),
                    decoder.width(),
                    decoder.height(),
                    format::Pixel::RGB24,
                    1920,
                    1080,
                    scaling::Flags::BILINEAR,
                )
                .unwrap();
                (ictx, stream.index(), decoder, scaler)
            })
            .collect::>();

        loop {
            let mut frames = vec![vec![]; 9];
            for (i, (ictx, stream_idx, decoder, scaler)) in streams.iter_mut().enumerate() {
                for (stream, packet) in ictx.packets() {
                    if stream.index() == *stream_idx {
                        decoder.send_packet(&packet).unwrap();
                        let mut decoded = Video::empty();
                        if decoder.receive_frame(&mut decoded).is_ok() {
                            let mut rgb_frame = Video::empty();
                            scaler.run(&decoded, &mut rgb_frame).unwrap();
                            frames[i] = rgb_frame.data(0).to_vec();
                        }
                        break;
                    }
                }
            }
            sender.send(frames).unwrap();
            std::thread::sleep(std::time::Duration::from_millis(33)); // ~30 FPS
        }
    });
}

fn main() -> iced::Result {
    VideoGrid::run(Settings::default())
}

Зависимости .toml:

[dependencies]
iced = { version = "0.12", features = ["image"] }
ffmpeg-next = "6.1"

Проблемы с Full HD и 30 FPS

Решение с winit + softbuffer

use winit::{
    event::{Event, WindowEvent},
    event_loop::{ControlFlow, EventLoop},
    window::WindowBuilder,
};
use softbuffer::GraphicsContext;
use ffmpeg_next::{format, media, codec, decoder, software::scaling, util::frame::video::Video};

fn main() {
    let event_loop = EventLoop::new().unwrap();
    let window = WindowBuilder::new()
        .with_title("RTSP Grid 3x3")
        .with_inner_size(winit::dpi::LogicalSize::new(1920 * 3, 1080 * 3))
        .build(&event_loop)
        .unwrap();

    let mut graphics_context = unsafe { GraphicsContext::new(&window, &window) }.unwrap();
    let (sender, receiver) = std::sync::mpsc::channel();
    spawn_video_threads(sender);

    event_loop.run(move |event, _, control_flow| {
        *control_flow = ControlFlow::Poll;

        match event {
            Event::WindowEvent {
                event: WindowEvent::CloseRequested,
                ..
            } => *control_flow = ControlFlow::Exit,
            Event::RedrawRequested(_) => {
                if let Ok(frames) = receiver.try_recv() {
                    let width = 1920 * 3;
                    let height = 1080 * 3;
                    let mut buffer = vec![0; (width * height) as usize];
                    for i in 0..3 {
                        for j in 0..3 {
                            let idx = i * 3 + j;
                            if let Some(frame) = frames.get(idx) {
                                for y in 0..1080 {
                                    for x in 0..1920 {
                                        let src_idx = (y * 1920 + x) * 3;
                                        let dst_idx = ((i * 1080 + y) * width + (j * 1920 + x)) as usize;
                                        if src_idx + 2 < frame.len() {
                                            let r = frame[src_idx];
                                            let g = frame[src_idx + 1];
                                            let b = frame[src_idx + 2];
                                            buffer[dst_idx] = (r as u32) << 16 | (g as u32) << 8 | b as u32;
                                        }
                                    }
                                }
                            }
                        }
                    }
                    graphics_context.set_buffer(&buffer, width as u16, height as u16);
                }
            }
            _ => (),
        }
    });
}

fn spawn_video_threads(sender: std::sync::mpsc::Sender>>) {
    std::thread::spawn(move || {
        ffmpeg_next::init().unwrap();
        let urls = vec!["rtsp://user:pass@camera:554/stream"; 9];
        let mut streams = urls
            .iter()
            .map(|url| {
                let mut ictx = format::input(&url).unwrap();
                let stream = ictx.streams().best(media::Type::Video).unwrap();
                let context = codec::context::Context::from_parameters(stream.parameters()).unwrap();
                let mut decoder = context.decoder().video().unwrap();
                let mut scaler = scaling::Context::get(
                    decoder.format(),
                    decoder.width(),
                    decoder.height(),
                    format::Pixel::RGB24,
                    1920,
                    1080,
                    scaling::Flags::BILINEAR,
                )
                .unwrap();
                (ictx, stream.index(), decoder, scaler)
            })
            .collect::>();

        loop {
            let mut frames = vec![vec![]; 9];
            for (i, (ictx, stream_idx, decoder, scaler)) in streams.iter_mut().enumerate() {
                for (stream, packet) in ictx.packets() {
                    if stream.index() == *stream_idx {
                        decoder.send_packet(&packet).unwrap();
                        let mut decoded = Video::empty();
                        if decoder.receive_frame(&mut decoded).is_ok() {
                            let mut rgb_frame = Video::empty();
                            scaler.run(&decoded, &mut rgb_frame).unwrap();
                            frames[i] = rgb_frame.data(0).to_vec();
                        }
                        break;
                    }
                }
            }
            sender.send(frames).unwrap();
            std::thread::sleep(std::time::Duration::from_millis(33)); // ~30 FPS
        }
    });
}

Зависимости .toml:

[dependencies]
winit = "0.29"
softbuffer = "0.4"
ffmpeg-next = "6.1"

Что здесь происходит?

  1. Создаём окно размером 5760x3240 (1920x1080 × 3).
  2. В отдельном потоке декодируем 9 RTSP-потоков с помощью ffmpeg-next.
  3. Формируем единый буфер пикселей, размещая кадры в сетке 3x3.
  4. Отображаем буфер через softbuffer.

Преимущества:

Проблемы с Full HD и 30 FPS:

Решение с sdl2

Для стабильных 30 FPS при Full HD лучше использовать sdl2:

use sdl2::pixels::PixelFormatEnum;
use sdl2::rect::Rect;
use sdl2::render::{Canvas, Texture};
use ffmpeg_next::{format, media, codec, decoder, software::scaling, util::frame::video::Video};

fn main() {
    let sdl_context = sdl2::init().unwrap();
    let video_subsystem = sdl_context.video().unwrap();
    let window = video_subsystem
        .window("RTSP Grid 3x3", 1920 * 3, 1080 * 3)
        .build()
        .unwrap();

    let mut canvas = window.into_canvas().build().unwrap();
    let texture_creator = canvas.texture_creator();
    let mut textures: Vec = (0..9)
        .map(|_| {
            texture_creator
                .create_texture_streaming(PixelFormatEnum::RGB24, 1920, 1080)
                .unwrap()
        })
        .collect();

    let (sender, receiver) = std::sync::mpsc::channel();
    spawn_video_threads(sender);

    let mut event_pump = sdl_context.event_pump().unwrap();
    'running: loop {
        for event in event_pump.poll_iter() {
            if let sdl2::event::Event::Quit { .. } = event {
                break 'running;
            }
        }

        if let Ok(frames) = receiver.try_recv() {
            for (i, frame) in frames.iter().enumerate() {
                textures[i].update(None, frame, 1920 * 3).unwrap();
            }
            canvas.clear();
            for i in 0..3 {
                for j in 0..3 {
                    let idx = i * 3 + j;
                    canvas
                        .copy(
                            &textures[idx],
                            None,
                            Rect::new(j as i32 * 1920, i as i32 * 1080, 1920, 1080),
                        )
                        .unwrap();
                }
            }
            canvas.present();
        }
        std::thread::sleep(std::time::Duration::from_millis(33));
    }
}

// Функция spawn_video_threads аналогична

Зависимости .toml:

[dependencies]
sdl2 = "0.35"
ffmpeg-next = "6.1"

Почему sdl2 лучше?


Что выбрать?

Для новичка я бы посоветовал начать с iced — это баланс между простотой и возможностями. Попробуйте сделать окно с кнопкой, потом добавьте сетку изображений, а затем подключите RTSP через ffmpeg-next. Если нужна производительность видео — переходите на sdl2 или winit + softbuffer. Для сложного UI вроде Discord рассмотрите Electron с neon. А если хочется хардкора — освойте Windows API!