winit
и softbuffer
iced
Rust — мощный системный язык программирования, который привлекает разработчиков своей производительностью и безопасностью памяти. Однако, когда речь заходит о создании графических интерфейсов (GUI), новички могут столкнуться с вопросом: "С чего начать?". Экосистема Rust для GUI пока не так развита, как у C++ (Qt) или Python (Tkinter), но она активно растёт, предлагая как низкоуровневые инструменты, так и высокоуровневые фреймворки. В этой статье мы разберём, как можно создавать GUI на Rust — от работы с нативным Windows API до использования современных библиотек вроде iced
, с примерами кода, плюсами и минусами каждого подхода. Мы также затронем реальные задачи, такие как отображение RTSP-потоков с IP-камер в сетке 3x3, чтобы показать, как применять эти инструменты на практике.
GUI-приложения — это визуальный способ взаимодействия с пользователем, будь то простое окно с кнопкой или сложный интерфейс вроде Discord или RustDesk (программа для удалённого доступа). Rust подходит для таких задач, потому что:
Но есть и вызовы: экосистема GUI в Rust молодая, и выбор инструментов может сбивать с толку. Давайте разберём основные подходы.
Если вы хотите максимальный контроль и работаете только с 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"] }
WNDCLASSA
) с функцией обработки сообщений (wnd_proc
).CreateWindowExA
..exe
, можно запустить на Linux через Wine, слой совместимости, который эмулирует Windows API.unsafe
блоках.Интересный момент: приложение на Windows API, скомпилированное как .exe
, часто запускается на Linux через Wine без изменений, если использует стандартные функции (например, CreateWindowExA
, GetMessageA
). Wine транслирует вызовы Win32 в POSIX/Linux API, так что базовый GUI (окно, кнопки) будет работать. Однако:
winit
и softbuffer
Если вы хотите чуть больше абстракции, но не готовые фреймворки, можно использовать winit
(для окон и событий) и softbuffer
(для рендеринга пикселей).
winit
: Кроссплатформенная библиотека для создания окон и обработки событий. На Windows она использует Windows API под капотом, но скрывает детали.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"
WindowBuilder
.GraphicsContext
для рендеринга.Ты сам рисуешь интерфейс с помощью 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();
}
Подводные камни: Сложность шейдеров, кроссплатформенность.
iced
iced
— кроссплатформенный GUI-фреймворк в стиле Elm, с декларативным синтаксисом.
iced
предоставляет виджеты (Button
, Text
, Column
) и рендерит их через wgpu
(графический бэкенд, использующий DirectX 12 на Windows, Vulkan на Linux).winit
управляет окнами и событиями, а wgpu
занимается рендерингом.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"
CounterApp
) и сообщения (Message).column!
с текстом и кнопкой.update
.wgpu
.Иногда GUI требует комбинации Rust с другими инструментами. Например, RustDesk использует Flutter для интерфейса, а Rust — для бэкенда.
FFI (Foreign Function Interface) — это механизм, позволяющий вызывать функции одного языка из другого. В Rust он часто используется для связи с C-совместимыми библиотеками.
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 — использование Electron, фреймворка, который позволяет строить кроссплатформенные приложения с помощью HTML, CSS и JavaScript, упакованные в Chromium. Discord, например, построен на Electron, как и Revolt — открытая альтернатива Discord. Rust можно интегрировать с Electron, чтобы совместить производительность Rust с мощью веб-технологий.
child_process
).neon
позволяет писать Node.js-модули на Rust, которые можно вызывать из JavaScript..node
), который загружается в Electron..exe
или бинарник для Linux/macOS).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"
}
}
npm install electron
.cargo build --release
..node
-файл в папку native
: cp target/release/rust_electron.node native/index.node
.npm start
.hello
экспортируется как Node.js-модуль через neon
.iced
или Windows API, из-за веб-движка.neon
или процессов требует дополнительных шагов.neon
.ffmpeg-next
: Для RTSP и декодирования (H.264/H.265 → RGB).iced
, winit
+ softbuffer
, или sdl2
.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"
iced
может тормозить из-за копирования буферов и рендеринга через wgpu
. На слабом железе FPS упадёт, на мощном — нужна оптимизация (например, уменьшение разрешения до 640x360).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"
ffmpeg-next
.softbuffer
.winit
.iced
.softbuffer
проще в плане обновления буфера, чем iced
с его Image
. На мощном железе это работает лучше, но всё ещё требует оптимизации для слабых систем.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
лучше?winit
+ softbuffer
: Минимализм с кроссплатформенностью. Подходит для кастомного рендеринга (например, видео), но без виджетов.iced
: Удобство и современный стиль. Идеально для прототипов (например, клон Discord), но для видео нужны доработки.sdl2
: Лучший выбор для видео (RTSP, игры) с хорошей производительностью.Для новичка я бы посоветовал начать с iced
— это баланс между простотой и возможностями. Попробуйте сделать окно с кнопкой, потом добавьте сетку изображений, а затем подключите RTSP через ffmpeg-next
. Если нужна производительность видео — переходите на sdl2
или winit
+ softbuffer
. Для сложного UI вроде Discord рассмотрите Electron с neon
. А если хочется хардкора — освойте Windows API!