feat: cursor, linux (#12822)

* feat: cursor, linux

Signed-off-by: fufesou <linlong1266@gmail.com>

* refact: cursor, text, white background

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
fufesou
2025-09-06 12:11:43 +08:00
committed by GitHub
parent f933f46283
commit 6c949a9602
14 changed files with 1153 additions and 209 deletions

View File

@@ -575,7 +575,7 @@ pub fn core_main() -> Option<Vec<String>> {
}
return None;
} else if args[0] == "--whiteboard" {
#[cfg(any(target_os = "windows", target_os = "macos"))]
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
crate::whiteboard::run();
}

View File

@@ -289,7 +289,7 @@ pub enum Data {
#[cfg(target_os = "windows")]
PortForwardSessionCount(Option<usize>),
SocksWs(Option<Box<(Option<config::Socks5Server>, String)>>),
#[cfg(any(target_os = "windows", target_os = "macos"))]
#[cfg(not(any(target_os = "android", target_os = "ios")))]
Whiteboard((String, crate::whiteboard::CustomEvent)),
}

View File

@@ -55,7 +55,7 @@ pub mod plugin;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
mod tray;
#[cfg(any(target_os = "windows", target_os = "macos"))]
#[cfg(not(any(target_os = "android", target_os = "ios")))]
mod whiteboard;
#[cfg(not(any(target_os = "android", target_os = "ios")))]

View File

@@ -3699,24 +3699,35 @@ impl Connection {
self.update_terminal_persistence(q == BoolOption::Yes).await;
}
}
#[cfg(any(target_os = "windows", target_os = "macos"))]
#[cfg(not(any(target_os = "android", target_os = "ios")))]
if let Ok(q) = o.show_my_cursor.enum_value() {
if q != BoolOption::NotSet {
use crate::whiteboard;
self.show_my_cursor = q == BoolOption::Yes;
#[cfg(target_os = "windows")]
let is_win10_or_greater = crate::platform::windows::is_win_10_or_greater();
let is_lower_win10 = !crate::platform::windows::is_win_10_or_greater();
#[cfg(not(target_os = "windows"))]
let is_win10_or_greater = false;
let is_lower_win10 = false;
#[cfg(target_os = "linux")]
let is_wayland = !crate::platform::linux::is_x11();
#[cfg(not(target_os = "linux"))]
let is_wayland = false;
let not_support_msg = if is_lower_win10 {
"Windows 10 or greater is required."
} else if is_wayland {
"This feature is not supported on Wayland, please switch to X11."
} else {
""
};
if q == BoolOption::Yes {
if !cfg!(target_os = "windows") || is_win10_or_greater {
if not_support_msg.is_empty() {
whiteboard::register_whiteboard(whiteboard::get_key_cursor(self.inner.id));
} else {
let mut msg_out = Message::new();
let res = MessageBox {
msgtype: "nook-nocancel-hasclose".to_owned(),
title: "Show my cursor".to_owned(),
text: "Windows 10 or greater is required.".to_owned(),
text: not_support_msg.to_owned(),
link: "".to_owned(),
..Default::default()
};
@@ -3724,7 +3735,7 @@ impl Connection {
self.send(msg_out).await;
}
} else {
if !cfg!(target_os = "windows") || is_win10_or_greater {
if not_support_msg.is_empty() {
whiteboard::unregister_whiteboard(whiteboard::get_key_cursor(
self.inner.id,
));
@@ -4884,7 +4895,7 @@ mod raii {
scrap::wayland::pipewire::try_close_session();
}
Self::check_wake_lock();
#[cfg(any(target_os = "windows", target_os = "macos"))]
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
use crate::whiteboard;
whiteboard::unregister_whiteboard(whiteboard::get_key_cursor(self.0));

View File

@@ -2,7 +2,7 @@
use super::rdp_input::client::{RdpInputKeyboard, RdpInputMouse};
use super::*;
use crate::input::*;
#[cfg(any(target_os = "windows", target_os = "macos"))]
#[cfg(not(any(target_os = "android", target_os = "ios")))]
use crate::whiteboard;
#[cfg(target_os = "macos")]
use dispatch::Queue;
@@ -1000,7 +1000,7 @@ pub fn handle_mouse_(
if simulate {
handle_mouse_simulation_(evt, conn);
}
#[cfg(any(target_os = "windows", target_os = "macos"))]
#[cfg(not(any(target_os = "android", target_os = "ios")))]
if _show_cursor {
handle_mouse_show_cursor_(evt, conn, _username, _argb);
}
@@ -1149,7 +1149,7 @@ pub fn handle_mouse_simulation_(evt: &MouseEvent, conn: i32) {
}
}
#[cfg(any(target_os = "windows", target_os = "macos"))]
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub fn handle_mouse_show_cursor_(evt: &MouseEvent, conn: i32, username: String, argb: u32) {
let buttons = evt.mask >> 3;
let evt_type = evt.mask & 0x7;

426
src/whiteboard/linux.rs Normal file
View File

@@ -0,0 +1,426 @@
use super::{
server::{Ripple, EVENT_PROXY},
win_linux::{create_font_face, draw_text},
Cursor, CustomEvent,
};
use hbb_common::{bail, log, tokio::sync::mpsc::unbounded_channel, ResultType};
use softbuffer::{Context, Surface};
use std::{
collections::HashMap,
ffi::{c_int, c_short, c_ulong, c_ushort},
num::NonZeroU32,
sync::Arc,
time::Instant,
};
use tiny_skia::{Color, FillRule, Paint, PathBuilder, PixmapMut, Stroke, Transform};
use ttf_parser::Face;
use winit::raw_window_handle::{
DisplayHandle, HasDisplayHandle, HasWindowHandle, RawDisplayHandle, RawWindowHandle,
};
use winit::{
application::ApplicationHandler,
dpi::{PhysicalPosition, PhysicalSize},
event::WindowEvent,
event_loop::{ActiveEventLoop, EventLoop},
platform::x11::{WindowAttributesExtX11, WindowType},
window::{Window, WindowId, WindowLevel},
};
enum _XDisplay {}
type Display = _XDisplay;
type XID = c_ulong;
type XserverRegion = XID;
#[derive(Debug, Clone, Copy, PartialEq)]
#[repr(C)]
pub struct XRectangle {
pub x: c_short,
pub y: c_short,
pub width: c_ushort,
pub height: c_ushort,
}
#[link(name = "Xfixes")]
extern "C" {
fn XFixesCreateRegion(
dpy: *mut Display,
rectangles: *mut XRectangle,
nrectangles: c_int,
) -> XserverRegion;
fn XFixesDestroyRegion(dpy: *mut Display, region: XserverRegion) -> ();
fn XFixesSetWindowShapeRegion(
dpy: *mut Display,
win: XID,
shape_kind: c_int,
x_off: c_int,
y_off: c_int,
region: XserverRegion,
) -> ();
}
const SHAPE_INPUT: std::ffi::c_int = 2;
pub fn run() {
let event_loop = match EventLoop::<(String, CustomEvent)>::with_user_event().build() {
Ok(el) => el,
Err(e) => {
log::error!("Failed to create event loop: {}", e);
return;
}
};
let event_loop_proxy = event_loop.create_proxy();
EVENT_PROXY.write().unwrap().replace(event_loop_proxy);
let (tx_exit, rx_exit) = unbounded_channel();
std::thread::spawn(move || {
super::server::start_ipc(rx_exit);
});
let mut app = match WhiteboardApplication::new(&event_loop) {
Ok(app) => app,
Err(e) => {
log::error!("Failed to create whiteboard application: {}", e);
tx_exit.send(()).ok();
return;
}
};
if let Err(e) = event_loop.run_app(&mut app) {
log::error!("Failed to run app: {}", e);
tx_exit.send(()).ok();
return;
}
}
struct WindowState {
window: Arc<Window>,
// NOTE: This surface must be dropped before the `Window`.
surface: Surface<DisplayHandle<'static>, Arc<Window>>,
ripples: Vec<Ripple>,
last_cursors: HashMap<String, Cursor>,
}
struct WhiteboardApplication {
windows: Vec<WindowState>,
// Drawing context.
//
// With OpenGL it could be EGLDisplay.
context: Option<Context<DisplayHandle<'static>>>,
face: Option<Face<'static>>,
close_requested: bool,
}
impl WhiteboardApplication {
fn new<T>(event_loop: &EventLoop<T>) -> ResultType<Self> {
// https://github.com/rust-windowing/winit/blob/f6893a4390dfe6118ce4b33458d458fd3efd3025/examples/window.rs#L91
// SAFETY: we drop the context right before the event loop is stopped, thus making it safe.
let context = match Context::new(unsafe {
std::mem::transmute::<DisplayHandle<'_>, DisplayHandle<'static>>(
event_loop.display_handle()?,
)
}) {
Ok(ctx) => Some(ctx),
Err(e) => {
bail!("Failed to create context: {}", e);
}
};
let face = match create_font_face() {
Ok(face) => Some(face),
Err(err) => {
log::error!("Failed to create font face: {}", err);
None
}
};
Ok(Self {
windows: Vec::new(),
context,
face,
close_requested: false,
})
}
}
impl ApplicationHandler<(String, CustomEvent)> for WhiteboardApplication {
fn user_event(&mut self, _event_loop: &ActiveEventLoop, (k, evt): (String, CustomEvent)) {
match evt {
CustomEvent::Cursor(cursor) => {
if let Some(state) = self.windows.first_mut() {
if cursor.btns != 0 {
state.ripples.push(Ripple {
x: cursor.x,
y: cursor.y,
start_time: Instant::now(),
});
}
state.last_cursors.insert(k, cursor);
state.window.request_redraw();
}
}
CustomEvent::Exit => {
self.close_requested = true;
}
_ => {}
}
}
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let (x, y, w, h) = match super::server::get_displays_rect() {
Ok(r) => r,
Err(err) => {
log::error!("Failed to get displays rect: {}", err);
self.close_requested = true;
return;
}
};
let window_attributes = Window::default_attributes()
.with_title("RustDesk whiteboard")
.with_inner_size(PhysicalSize::new(w, h))
.with_position(PhysicalPosition::new(x, y))
.with_decorations(false)
.with_transparent(true)
.with_window_level(WindowLevel::AlwaysOnTop)
.with_x11_window_type(vec![WindowType::Dock])
.with_override_redirect(true);
let window = match event_loop.create_window(window_attributes) {
Ok(window) => Arc::new(window),
Err(e) => {
log::error!("Failed to create window: {}", e);
self.close_requested = true;
return;
}
};
let display = match window.display_handle() {
Ok(d) => d,
Err(e) => {
log::error!("Failed to get display handle: {}", e);
self.close_requested = true;
return;
}
};
let rwh = match window.window_handle() {
Ok(w) => w,
Err(e) => {
log::error!("Failed to get window handle: {}", e);
self.close_requested = true;
return;
}
};
// Both the following block and `window.set_cursor_hittest(false)` in `draw()` are necessary to ensure cursor events are properly passed through the window.
// These issues may be related to winit X11 handling.
// https://github.com/rust-windowing/winit/issues/3509
// https://github.com/rust-windowing/winit/issues/4120
// If either block is removed, cursor events may not be passed through as expected.
// If you update winit, please revisit this workaround.
match (rwh.as_raw(), display.as_raw()) {
(RawWindowHandle::Xlib(xlib_window), RawDisplayHandle::Xlib(xlib_display)) => {
unsafe {
let xwindow = xlib_window.window;
if let Some(display_ptr) = xlib_display.display {
let xdisplay = display_ptr.as_ptr() as *mut Display;
// Mouse event passthrough
let empty_region = XFixesCreateRegion(xdisplay, std::ptr::null_mut(), 0);
if empty_region == 0 {
log::error!("XFixesCreateRegion failed: returned null region");
} else {
XFixesSetWindowShapeRegion(
xdisplay,
xwindow,
SHAPE_INPUT,
0,
0,
empty_region,
);
XFixesDestroyRegion(xdisplay, empty_region);
}
}
}
}
_ => {
log::error!("Unsupported windowing system for shape extension");
self.close_requested = true;
return;
}
}
let Some(ctx) = self.context.as_ref() else {
// unreachable
self.close_requested = true;
return;
};
let surface = match Surface::new(ctx, window.clone()) {
Ok(s) => s,
Err(e) => {
log::error!("Failed to create surface: {}", e);
self.close_requested = true;
return;
}
};
let state = WindowState {
window,
surface,
ripples: Vec::new(),
last_cursors: HashMap::new(),
};
self.windows.push(state);
}
fn window_event(
&mut self,
_event_loop: &ActiveEventLoop,
window_id: WindowId,
event: WindowEvent,
) {
match event {
WindowEvent::CloseRequested => {
self.close_requested = true;
}
WindowEvent::RedrawRequested => {
let Some(state) = self.windows.iter_mut().find(|w| w.window.id() == window_id)
else {
log::error!("No window found for id: {:?}", window_id);
return;
};
if let Err(err) = state.draw(&self.face) {
log::error!("Failed to draw window: {}", err);
}
}
_ => (),
}
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
if !self.close_requested {
for state in self.windows.iter() {
state.window.request_redraw();
}
} else {
event_loop.exit();
}
}
fn exiting(&mut self, _event_loop: &ActiveEventLoop) {
// We must drop the context here.
self.context = None;
}
}
impl WindowState {
fn draw(&mut self, face: &Option<Face<'static>>) -> ResultType<()> {
let (width, height) = {
let size = self.window.inner_size();
(size.width, size.height)
};
let (Some(width), Some(height)) = (NonZeroU32::new(width), NonZeroU32::new(height)) else {
bail!("Invalid window size, {width}x{height}")
};
if let Err(e) = self.surface.resize(width, height) {
bail!("Failed to resize surface: {}", e);
}
let mut buffer = match self.surface.buffer_mut() {
Ok(buf) => buf,
Err(e) => {
bail!("Failed to get buffer: {}", e);
}
};
let Some(mut pixmap) = PixmapMut::from_bytes(
bytemuck::cast_slice_mut(&mut buffer),
width.get(),
height.get(),
) else {
bail!("Failed to create pixmap from buffer");
};
pixmap.fill(Color::TRANSPARENT);
Ripple::retain_active(&mut self.ripples);
for ripple in &self.ripples {
let (radius, alpha) = ripple.get_radius_alpha();
let mut ripple_paint = Paint::default();
// Note: The real color is bgra here.
ripple_paint.set_color_rgba8(64, 64, 255, (alpha * 128.0) as u8);
ripple_paint.anti_alias = true;
let mut ripple_pb = PathBuilder::new();
ripple_pb.push_circle(ripple.x, ripple.y, radius);
if let Some(path) = ripple_pb.finish() {
pixmap.fill_path(
&path,
&ripple_paint,
FillRule::Winding,
Transform::identity(),
None,
);
}
}
for cursor in self.last_cursors.values() {
let (x, y) = (cursor.x, cursor.y);
let size = 1.5f32;
let mut pb = PathBuilder::new();
pb.move_to(x, y);
pb.line_to(x, y + 16.0 * size);
pb.line_to(x + 4.0 * size, y + 13.0 * size);
pb.line_to(x + 7.0 * size, y + 20.0 * size);
pb.line_to(x + 9.0 * size, y + 19.0 * size);
pb.line_to(x + 6.0 * size, y + 12.0 * size);
pb.line_to(x + 11.0 * size, y + 12.0 * size);
pb.close();
if let Some(path) = pb.finish() {
let mut arrow_paint = Paint::default();
let rgba = super::argb_to_rgba(cursor.argb);
arrow_paint.set_color_rgba8(rgba.2, rgba.1, rgba.0, rgba.3);
arrow_paint.anti_alias = true;
pixmap.fill_path(
&path,
&arrow_paint,
FillRule::Winding,
Transform::identity(),
None,
);
let mut black_paint = Paint::default();
black_paint.set_color_rgba8(0, 0, 0, 255);
black_paint.anti_alias = true;
let mut stroke = Stroke::default();
stroke.width = 1.0f32;
pixmap.stroke_path(&path, &black_paint, &stroke, Transform::identity(), None);
face.as_ref().map(|face| {
draw_text(
&mut pixmap,
face,
&cursor.text,
x + 24.0 * size,
y + 24.0 * size,
&arrow_paint,
14.0f32,
);
});
}
}
self.window.pre_present_notify();
if let Err(e) = buffer.present() {
log::error!("Failed to present buffer: {}", e);
}
self.window.set_cursor_hittest(false).ok();
Ok(())
}
}

View File

@@ -1,9 +1,12 @@
use super::{server::EVENT_PROXY, Cursor, CustomEvent};
use super::{server::EVENT_PROXY, Cursor, CustomEvent, Ripple};
use core_graphics::context::CGContextRef;
use foreign_types::ForeignTypeRef;
use hbb_common::{bail, log, ResultType};
use objc::{class, msg_send, runtime::Object, sel, sel_impl};
use piet::{kurbo::BezPath, FontFamily, RenderContext, Text, TextLayoutBuilder};
use piet::{
kurbo::{BezPath, Point},
FontFamily, RenderContext, Text, TextLayout, TextLayoutBuilder,
};
use piet_coregraphics::{CoreGraphicsContext, CoreGraphicsTextLayout};
use std::{collections::HashMap, sync::Arc, time::Instant};
use tao::{
@@ -27,12 +30,6 @@ struct WindowState {
display_origin: (f64, f64),
}
struct Ripple {
x: f64,
y: f64,
start_time: Instant,
}
struct CursorInfo {
window_id: WindowId,
text_key: (String, u32),
@@ -144,23 +141,14 @@ fn draw_cursors(
context.clear(None, piet::Color::TRANSPARENT);
if let Some(ripples) = window_ripples.get_mut(&window_id) {
let ripple_duration = std::time::Duration::from_millis(500);
ripples.retain_mut(|ripple| {
let elapsed = ripple.start_time.elapsed();
let progress =
elapsed.as_secs_f64() / ripple_duration.as_secs_f64();
let radius = 25.0 * progress;
let alpha = 1.0 - progress;
if alpha > 0.0 {
let color = piet::Color::rgba(1.0, 0.5, 0.5, alpha);
let circle =
piet::kurbo::Circle::new((ripple.x, ripple.y), radius);
context.stroke(circle, &color, 2.0);
true
} else {
false
}
});
Ripple::retain_active(ripples);
for ripple in ripples.iter() {
let (radius, alpha) = ripple.get_radius_alpha();
let color = piet::Color::rgba(1.0, 0.25, 0.25, alpha * 0.5);
let circle =
piet::kurbo::Circle::new((ripple.x, ripple.y), radius);
context.stroke(circle, &color, 2.0);
}
}
for info in last_cursors.values() {
@@ -181,26 +169,34 @@ fn draw_cursors(
pb.line_to((x + 6.0 * size, y + 12.0 * size));
pb.line_to((x + 11.0 * size, y + 12.0 * size));
let color = piet::Color::rgba8(
(cursor.argb >> 16 & 0xFF) as u8,
(cursor.argb >> 8 & 0xFF) as u8,
(cursor.argb & 0xFF) as u8,
(cursor.argb >> 24 & 0xFF) as u8,
);
let rgba = super::argb_to_rgba(cursor.argb);
let color = piet::Color::rgba8(rgba.0, rgba.1, rgba.2, rgba.3);
context.fill(pb, &color);
let pos =
(x + CURSOR_TEXT_OFFSET * size, y + CURSOR_TEXT_OFFSET * size);
let get_rounded_rect = |layout: &CoreGraphicsTextLayout| {
let text_pos = Point::new(pos.0, pos.1);
let padded_bounds = (layout.image_bounds()
+ text_pos.to_vec2())
.inflate(3.0, 3.0);
padded_bounds.to_rounded_rect(5.0)
};
if let Some(layout) = map_cursor_text.get(&info.text_key) {
context.fill(get_rounded_rect(layout), &piet::Color::WHITE);
context.draw_text(layout, pos);
} else {
let text = context.text();
let color = piet::Color::rgba8(0, 0, 0, 255);
if let Ok(layout) = text
.new_text_layout(cursor.text.clone())
.font(FontFamily::SYSTEM_UI, CURSOR_TEXT_FONT_SIZE)
.text_color(color)
.build()
{
context
.fill(get_rounded_rect(&layout), &piet::Color::WHITE);
context.draw_text(&layout, pos);
map_cursor_text.insert(info.text_key.clone(), layout);
}

View File

@@ -5,8 +5,12 @@ mod server;
#[cfg(target_os = "windows")]
mod windows;
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(any(target_os = "windows", target_os = "linux"))]
mod win_linux;
#[cfg(target_os = "windows")]
use windows::create_event_loop;

View File

@@ -1,29 +1,43 @@
use super::{create_event_loop, CustomEvent};
use super::CustomEvent;
use crate::ipc::{new_listener, Connection, Data};
#[cfg(any(target_os = "windows", target_os = "macos"))]
use hbb_common::tokio::sync::mpsc::unbounded_channel;
#[cfg(any(target_os = "windows", target_os = "linux"))]
use hbb_common::ResultType;
use hbb_common::{
allow_err, log,
tokio::{
self,
sync::mpsc::{unbounded_channel, UnboundedReceiver},
},
tokio::{self, sync::mpsc::UnboundedReceiver},
};
use lazy_static::lazy_static;
use std::sync::RwLock;
use std::time::{Duration, Instant};
#[cfg(any(target_os = "windows", target_os = "macos"))]
use tao::event_loop::EventLoopProxy;
#[cfg(target_os = "linux")]
use winit::event_loop::EventLoopProxy;
lazy_static! {
pub(super) static ref EVENT_PROXY: RwLock<Option<EventLoopProxy<(String, CustomEvent)>>> =
RwLock::new(None);
}
const RIPPLE_DURATION: Duration = Duration::from_millis(500);
#[cfg(target_os = "macos")]
type RippleFloat = f64;
#[cfg(any(target_os = "windows", target_os = "linux"))]
type RippleFloat = f32;
#[cfg(target_os = "linux")]
pub use super::linux::run;
#[cfg(any(target_os = "windows", target_os = "macos"))]
pub fn run() {
let (tx_exit, rx_exit) = unbounded_channel();
std::thread::spawn(move || {
start_ipc(rx_exit);
});
if let Err(e) = create_event_loop() {
if let Err(e) = super::create_event_loop() {
log::error!("Failed to create event loop: {}", e);
tx_exit.send(()).ok();
return;
@@ -31,7 +45,7 @@ pub fn run() {
}
#[tokio::main(flavor = "current_thread")]
async fn start_ipc(mut rx_exit: UnboundedReceiver<()>) {
pub(super) async fn start_ipc(mut rx_exit: UnboundedReceiver<()>) {
match new_listener("_whiteboard").await {
Ok(mut incoming) => loop {
tokio::select! {
@@ -82,9 +96,7 @@ async fn handle_new_stream(mut conn: Connection) {
});
}
}
_ => {
}
_ => {}
}
}
Ok(None) => {
@@ -120,3 +132,40 @@ pub(super) fn get_displays_rect() -> ResultType<(i32, i32, u32, u32)> {
let (w, h) = ((max_x - min_x) as u32, (max_y - min_y) as u32);
Ok((x, y, w, h))
}
#[inline]
pub(super) fn argb_to_rgba(argb: u32) -> (u8, u8, u8, u8) {
(
(argb >> 16 & 0xFF) as u8,
(argb >> 8 & 0xFF) as u8,
(argb & 0xFF) as u8,
(argb >> 24 & 0xFF) as u8,
)
}
pub(super) struct Ripple {
pub x: RippleFloat,
pub y: RippleFloat,
pub start_time: Instant,
}
impl Ripple {
#[inline]
pub fn retain_active(ripples: &mut Vec<Ripple>) {
ripples.retain(|r| r.start_time.elapsed() < RIPPLE_DURATION);
}
pub fn get_radius_alpha(&self) -> (RippleFloat, RippleFloat) {
let elapsed = self.start_time.elapsed();
#[cfg(target_os = "macos")]
let progress = (elapsed.as_secs_f64() / RIPPLE_DURATION.as_secs_f64()).min(1.0);
#[cfg(any(target_os = "windows", target_os = "linux"))]
let progress = (elapsed.as_secs_f32() / RIPPLE_DURATION.as_secs_f32()).min(1.0);
#[cfg(target_os = "macos")]
let radius = 25.0 * progress;
#[cfg(any(target_os = "windows", target_os = "linux"))]
let radius = 45.0 * progress;
let alpha = 1.0 - progress;
(radius, alpha)
}
}

180
src/whiteboard/win_linux.rs Normal file
View File

@@ -0,0 +1,180 @@
use hbb_common::{bail, ResultType};
use tiny_skia::{FillRule, Paint, PathBuilder, PixmapMut, Point, Rect, Transform};
use ttf_parser::Face;
// A helper struct to bridge `ttf-parser` and `tiny-skia`.
struct PathBuilderWrapper<'a> {
path_builder: &'a mut PathBuilder,
transform: Transform,
}
impl ttf_parser::OutlineBuilder for PathBuilderWrapper<'_> {
fn move_to(&mut self, x: f32, y: f32) {
let mut pt = Point::from_xy(x, y);
self.transform.map_point(&mut pt);
self.path_builder.move_to(pt.x, pt.y);
}
fn line_to(&mut self, x: f32, y: f32) {
let mut pt = Point::from_xy(x, y);
self.transform.map_point(&mut pt);
self.path_builder.line_to(pt.x, pt.y);
}
fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
let mut pt1 = Point::from_xy(x1, y1);
self.transform.map_point(&mut pt1);
let mut pt = Point::from_xy(x, y);
self.transform.map_point(&mut pt);
self.path_builder.quad_to(pt1.x, pt1.y, pt.x, pt.y);
}
fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
let mut pt1 = Point::from_xy(x1, y1);
self.transform.map_point(&mut pt1);
let mut pt2 = Point::from_xy(x2, y2);
self.transform.map_point(&mut pt2);
let mut pt = Point::from_xy(x, y);
self.transform.map_point(&mut pt);
self.path_builder
.cubic_to(pt1.x, pt1.y, pt2.x, pt2.y, pt.x, pt.y);
}
fn close(&mut self) {
self.path_builder.close();
}
}
// Draws a string of text with the white background rectangle onto the pixmap.
pub(super) fn draw_text(
pixmap: &mut PixmapMut,
face: &Face,
text: &str,
x: f32,
y: f32,
paint: &Paint,
font_size: f32,
) {
let units_per_em = face.units_per_em() as f32;
let scale = font_size / units_per_em;
// --- 1. Calculate text dimensions for the background ---
let mut total_width = 0.0;
for ch in text.chars() {
let glyph_id = face.glyph_index(ch).unwrap_or_default();
if let Some(h_advance) = face.glyph_hor_advance(glyph_id) {
total_width += h_advance as f32 * scale;
}
}
// Use font metrics for a consistent background height.
let font_height = (face.ascender() - face.descender()) as f32 * scale;
let ascent = face.ascender() as f32 * scale;
// Add some padding around the text
let padding = 3.0;
let mut bg_filled = false;
// --- 2. Draw the white background rectangle ---
if let Some(bg_rect) = Rect::from_xywh(
x - padding,
y - ascent - padding,
total_width + 2.0 * padding,
font_height + 2.0 * padding,
) {
// Corner radius
let radius = 5.0;
let path = {
let mut pb = PathBuilder::new();
let r_x = bg_rect.x();
let r_y = bg_rect.y();
let r_w = bg_rect.width();
let r_h = bg_rect.height();
pb.move_to(r_x + radius, r_y);
pb.line_to(r_x + r_w - radius, r_y);
pb.quad_to(r_x + r_w, r_y, r_x + r_w, r_y + radius);
pb.line_to(r_x + r_w, r_y + r_h - radius);
pb.quad_to(r_x + r_w, r_y + r_h, r_x + r_w - radius, r_y + r_h);
pb.line_to(r_x + radius, r_y + r_h);
pb.quad_to(r_x, r_y + r_h, r_x, r_y + r_h - radius);
pb.line_to(r_x, r_y + radius);
pb.quad_to(r_x, r_y, r_x + radius, r_y);
pb.close();
pb.finish()
};
if let Some(path) = path {
let mut bg_paint = Paint::default();
bg_paint.set_color_rgba8(255, 255, 255, 255);
bg_paint.anti_alias = true;
pixmap.fill_path(
&path,
&bg_paint,
FillRule::Winding,
Transform::identity(),
None,
);
bg_filled = true;
}
}
// --- 3. Draw the text ---
let transform = Transform::from_translate(x, y).pre_scale(scale, -scale);
let mut path_builder = PathBuilder::new();
let mut current_x = 0.0;
for ch in text.chars() {
let glyph_id = face.glyph_index(ch).unwrap_or_default();
let mut builder = PathBuilderWrapper {
path_builder: &mut path_builder,
transform: transform.post_translate(current_x, 0.0),
};
face.outline_glyph(glyph_id, &mut builder);
if let Some(h_advance) = face.glyph_hor_advance(glyph_id) {
current_x += h_advance as f32 * scale;
}
}
if let Some(path) = path_builder.finish() {
if bg_filled {
let mut text_paint = Paint::default();
text_paint.set_color_rgba8(0, 0, 0, 255);
text_paint.anti_alias = true;
pixmap.fill_path(
&path,
&text_paint,
FillRule::Winding,
Transform::identity(),
None,
);
} else {
pixmap.fill_path(&path, paint, FillRule::Winding, Transform::identity(), None);
}
}
}
pub(super) fn create_font_face() -> ResultType<Face<'static>> {
let mut font_db = fontdb::Database::new();
font_db.load_system_fonts();
let query = fontdb::Query {
families: &[fontdb::Family::Monospace],
..fontdb::Query::default()
};
let Some(font_id) = font_db.query(&query) else {
bail!("No monospace font found!");
};
let Some((font_source, face_index)) = font_db.face_source(font_id) else {
bail!("No face found for font!");
};
// Load the font data into a static slice to satisfy `ttf-parser`'s lifetime requirements.
// We use `Box::leak` to leak the memory, which is acceptable here since the font data
// is needed for the entire lifetime of the application.
let font_data: &'static [u8] = Box::leak(match font_source {
fontdb::Source::File(path) => std::fs::read(path)?.into_boxed_slice(),
fontdb::Source::Binary(data) => data.as_ref().as_ref().to_vec().into_boxed_slice(),
fontdb::Source::SharedFile(path, _) => std::fs::read(path)?.into_boxed_slice(),
});
let face = Face::parse(font_data, face_index)?;
Ok(face)
}

View File

@@ -1,121 +1,19 @@
use super::{server::EVENT_PROXY, Cursor, CustomEvent};
use hbb_common::{anyhow::anyhow, bail, log, ResultType};
use super::{
server::{Ripple, EVENT_PROXY},
win_linux::{create_font_face, draw_text},
Cursor, CustomEvent,
};
use hbb_common::{anyhow::anyhow, log, ResultType};
use softbuffer::{Context, Surface};
use std::{collections::HashMap, num::NonZeroU32, sync::Arc, time::Instant};
#[cfg(target_os = "linux")]
use tao::platform::unix::WindowBuilderExtUnix;
#[cfg(target_os = "windows")]
use tao::platform::windows::WindowBuilderExtWindows;
use tao::{
dpi::{PhysicalPosition, PhysicalSize},
event::{Event, WindowEvent},
event_loop::{ControlFlow, EventLoopBuilder},
platform::windows::WindowBuilderExtWindows,
window::WindowBuilder,
};
use tiny_skia::{Color, FillRule, Paint, PathBuilder, PixmapMut, Point, Stroke, Transform};
use ttf_parser::Face;
// A helper struct to bridge `ttf-parser` and `tiny-skia`.
struct PathBuilderWrapper<'a> {
path_builder: &'a mut PathBuilder,
transform: Transform,
}
impl ttf_parser::OutlineBuilder for PathBuilderWrapper<'_> {
fn move_to(&mut self, x: f32, y: f32) {
let mut pt = Point::from_xy(x, y);
self.transform.map_point(&mut pt);
self.path_builder.move_to(pt.x, pt.y);
}
fn line_to(&mut self, x: f32, y: f32) {
let mut pt = Point::from_xy(x, y);
self.transform.map_point(&mut pt);
self.path_builder.line_to(pt.x, pt.y);
}
fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
let mut pt1 = Point::from_xy(x1, y1);
self.transform.map_point(&mut pt1);
let mut pt = Point::from_xy(x, y);
self.transform.map_point(&mut pt);
self.path_builder.quad_to(pt1.x, pt1.y, pt.x, pt.y);
}
fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
let mut pt1 = Point::from_xy(x1, y1);
self.transform.map_point(&mut pt1);
let mut pt2 = Point::from_xy(x2, y2);
self.transform.map_point(&mut pt2);
let mut pt = Point::from_xy(x, y);
self.transform.map_point(&mut pt);
self.path_builder
.cubic_to(pt1.x, pt1.y, pt2.x, pt2.y, pt.x, pt.y);
}
fn close(&mut self) {
self.path_builder.close();
}
}
// Draws a string of text onto the pixmap.
fn draw_text(
pixmap: &mut PixmapMut,
face: &Face,
text: &str,
x: f32,
y: f32,
paint: &Paint,
font_size: f32,
) {
let units_per_em = face.units_per_em() as f32;
let scale = font_size / units_per_em;
let transform = Transform::from_translate(x, y).pre_scale(scale, -scale);
let mut path_builder = PathBuilder::new();
let mut current_x = 0.0;
for ch in text.chars() {
let glyph_id = face.glyph_index(ch).unwrap_or_default();
let mut builder = PathBuilderWrapper {
path_builder: &mut path_builder,
transform: transform.post_translate(current_x, 0.0),
};
face.outline_glyph(glyph_id, &mut builder);
if let Some(h_advance) = face.glyph_hor_advance(glyph_id) {
current_x += h_advance as f32 * scale;
}
}
if let Some(path) = path_builder.finish() {
pixmap.fill_path(&path, paint, FillRule::Winding, Transform::identity(), None);
}
}
fn create_font_face() -> ResultType<Face<'static>> {
let mut font_db = fontdb::Database::new();
font_db.load_system_fonts();
let query = fontdb::Query {
families: &[fontdb::Family::Monospace],
..fontdb::Query::default()
};
let Some(font_id) = font_db.query(&query) else {
bail!("No monospace font found!");
};
let Some((font_source, face_index)) = font_db.face_source(font_id) else {
bail!("No face found for font!");
};
let font_data: &'static [u8] = Box::leak(match font_source {
fontdb::Source::File(path) => std::fs::read(path)?.into_boxed_slice(),
fontdb::Source::Binary(data) => data.as_ref().as_ref().to_vec().into_boxed_slice(),
fontdb::Source::SharedFile(path, _) => std::fs::read(path)?.into_boxed_slice(),
});
let face = Face::parse(font_data, face_index)?;
Ok(face)
}
use tiny_skia::{Color, FillRule, Paint, PathBuilder, PixmapMut, Stroke, Transform};
pub(super) fn create_event_loop() -> ResultType<()> {
let face = match create_font_face() {
@@ -171,11 +69,6 @@ pub(super) fn create_event_loop() -> ResultType<()> {
}),
};
struct Ripple {
x: f32,
y: f32,
start_time: Instant,
}
let mut ripples: Vec<Ripple> = Vec::new();
let mut last_cursors: HashMap<String, Cursor> = HashMap::new();
let mut resized = final_size.is_none();
@@ -230,23 +123,17 @@ pub(super) fn create_event_loop() -> ResultType<()> {
};
pixmap.fill(Color::TRANSPARENT);
let ripple_duration = std::time::Duration::from_millis(500);
ripples.retain(|r| r.start_time.elapsed() < ripple_duration);
Ripple::retain_active(&mut ripples);
for ripple in &ripples {
let elapsed = ripple.start_time.elapsed();
let progress = elapsed.as_secs_f32() / ripple_duration.as_secs_f32();
let radius = 45.0 * progress;
let alpha = 1.0 - progress;
let (radius, alpha) = ripple.get_radius_alpha();
let mut ripple_paint = Paint::default();
// Note: The real color is bgra here.
ripple_paint.set_color_rgba8(128, 128, 255, (alpha * 128.0) as u8);
ripple_paint.set_color_rgba8(64, 64, 255, (alpha * 128.0) as u8);
ripple_paint.anti_alias = true;
let mut ripple_pb = PathBuilder::new();
let (rx, ry) = (ripple.x as f64, ripple.y as f64);
ripple_pb.push_circle(rx as f32, ry as f32, radius as f32);
ripple_pb.push_circle(ripple.x, ripple.y, radius);
if let Some(path) = ripple_pb.finish() {
pixmap.fill_path(
&path,
@@ -259,9 +146,8 @@ pub(super) fn create_event_loop() -> ResultType<()> {
}
for cursor in last_cursors.values() {
let (x, y) = (cursor.x as f64, cursor.y as f64);
let (x, y) = (x as f32, y as f32);
let size = 1.5 as f32;
let (x, y) = (cursor.x, cursor.y);
let size = 1.5f32;
let mut pb = PathBuilder::new();
pb.move_to(x, y);
@@ -274,14 +160,10 @@ pub(super) fn create_event_loop() -> ResultType<()> {
pb.close();
if let Some(path) = pb.finish() {
let rgba = super::argb_to_rgba(cursor.argb);
let mut arrow_paint = Paint::default();
// Note: The real color is bgra here.
arrow_paint.set_color_rgba8(
(cursor.argb & 0xFF) as u8,
(cursor.argb >> 8 & 0xFF) as u8,
(cursor.argb >> 16 & 0xFF) as u8,
(cursor.argb >> 24 & 0xFF) as u8,
);
arrow_paint.set_color_rgba8(rgba.2, rgba.1, rgba.0, rgba.3);
arrow_paint.anti_alias = true;
pixmap.fill_path(
&path,
@@ -295,7 +177,7 @@ pub(super) fn create_event_loop() -> ResultType<()> {
black_paint.set_color_rgba8(0, 0, 0, 255);
black_paint.anti_alias = true;
let mut stroke = Stroke::default();
stroke.width = 1.0 as f32;
stroke.width = 1.0f32;
pixmap.stroke_path(
&path,
&black_paint,
@@ -312,7 +194,7 @@ pub(super) fn create_event_loop() -> ResultType<()> {
x + 24.0 * size,
y + 24.0 * size,
&arrow_paint,
24.0 as f32,
14.0f32,
);
});
}