diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 64631c6c5..53a0483f3 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -79,6 +79,7 @@ const String kWindowEventOpenMonitorSession = "open_monitor_session"; const String kOptionViewStyle = "view_style"; const String kOptionScrollStyle = "scroll_style"; +const String kOptionEdgeScrollEdgeThickness = "edge-scroll-edge-thickness"; const String kOptionImageQuality = "image_quality"; const String kOptionOpenNewConnInTabs = "enable-open-new-connections-in-tabs"; const String kOptionTextureRender = "use-texture-render"; diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index f2f38460c..e436753c5 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -11,6 +11,7 @@ import 'package:flutter_hbb/common/widgets/setting_widgets.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart'; import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart'; +import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart'; import 'package:flutter_hbb/mobile/widgets/dialog.dart'; import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/printer_model.dart'; @@ -1738,22 +1739,39 @@ class _DisplayState extends State<_Display> { } final groupValue = bind.mainGetUserDefaultOption(key: kOptionScrollStyle); + + onEdgeScrollEdgeThicknessChanged(double value) async { + await bind.mainSetUserDefaultOption( + key: kOptionEdgeScrollEdgeThickness, value: value.round().toString()); + setState(() {}); + } + return _Card(title: 'Default Scroll Style', children: [ _Radio(context, value: kRemoteScrollStyleAuto, groupValue: groupValue, label: 'ScrollAuto', onChanged: isOptFixed ? null : onChanged), - _Radio(context, - value: kRemoteScrollStyleEdge, - groupValue: groupValue, - label: 'ScrollEdge', - onChanged: isOptFixed ? null : onChanged), _Radio(context, value: kRemoteScrollStyleBar, groupValue: groupValue, label: 'Scrollbar', onChanged: isOptFixed ? null : onChanged), + _Radio(context, + value: kRemoteScrollStyleEdge, + groupValue: groupValue, + label: 'ScrollEdge', + onChanged: isOptFixed ? null : onChanged), + Offstage( + offstage: groupValue != kRemoteScrollStyleEdge, + child: EdgeThicknessControl( + value: double.tryParse(bind.mainGetUserDefaultOption( + key: kOptionEdgeScrollEdgeThickness)) ?? + 100.0, + onChanged: isOptionFixed(kOptionEdgeScrollEdgeThickness) + ? null + : onEdgeScrollEdgeThicknessChanged, + )), ]); } diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 072f4ddd3..e48c8548a 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -511,7 +511,7 @@ class _MonitorMenu extends StatelessWidget { menuStyle: MenuStyle( padding: MaterialStatePropertyAll(EdgeInsets.symmetric(horizontal: 6))), - menuChildrenGetter: () => [buildMonitorSubmenuWidget(context)]); + menuChildrenGetter: (_) => [buildMonitorSubmenuWidget(context)]); } Widget buildMultiMonitorMenu(BuildContext context) { @@ -722,7 +722,7 @@ class _ControlMenu extends StatelessWidget { color: _ToolbarTheme.blueColor, hoverColor: _ToolbarTheme.hoverBlueColor, ffi: ffi, - menuChildrenGetter: () => toolbarControls(context, id, ffi).map((e) { + menuChildrenGetter: (_) => toolbarControls(context, id, ffi).map((e) { if (e.divider) { return Divider(); } else { @@ -933,12 +933,13 @@ class _DisplayMenuState extends State<_DisplayMenu> { @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; _screenAdjustor.updateScreen(); - menuChildrenGetter() { + menuChildrenGetter(_IconSubmenuButtonState state) { final menuChildren = [ _screenAdjustor.adjustWindow(context), viewStyle(customPercent: _customPercent), - scrollStyle(), + scrollStyle(state, colorScheme), imageQuality(), codec(), if (ffi.connType == ConnType.defaultConn) @@ -1013,14 +1014,14 @@ class _DisplayMenuState extends State<_DisplayMenu> { return Column(children: [ ...v.map((e) { final isCustom = e.value == kRemoteViewStyleCustom; - final child = isCustom - ? Text(translate('Scale custom')) - : e.child; + final child = + isCustom ? Text(translate('Scale custom')) : e.child; // Whether the current selection is already custom final bool isGroupCustomSelected = e.groupValue == kRemoteViewStyleCustom; // Keep menu open when switching INTO custom so the slider is visible immediately - final bool keepOpenForThisItem = isCustom && !isGroupCustomSelected; + final bool keepOpenForThisItem = + isCustom && !isGroupCustomSelected; return RdoMenuButton( value: e.value, groupValue: e.groupValue, @@ -1039,7 +1040,8 @@ class _DisplayMenuState extends State<_DisplayMenu> { }).toList(), // Only show a divider when custom is NOT selected if (!isCustomSelected) Divider(), - _customControlsIfCustomSelected(onChanged: (v) => customPercent.value = v), + _customControlsIfCustomSelected( + onChanged: (v) => customPercent.value = v), ]); }); } @@ -1054,12 +1056,14 @@ class _DisplayMenuState extends State<_DisplayMenu> { duration: Duration(milliseconds: 220), switchInCurve: Curves.easeOut, switchOutCurve: Curves.easeIn, - child: isCustom ? _CustomScaleMenuControls(ffi: ffi, onChanged: onChanged) : SizedBox.shrink(), + child: isCustom + ? _CustomScaleMenuControls(ffi: ffi, onChanged: onChanged) + : SizedBox.shrink(), ); }); } - scrollStyle() { + scrollStyle(_IconSubmenuButtonState state, ColorScheme colorScheme) { return futureBuilder(future: () async { final viewStyle = await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? ''; @@ -1067,16 +1071,34 @@ class _DisplayMenuState extends State<_DisplayMenu> { viewStyle == kRemoteViewStyleCustom; final scrollStyle = await bind.sessionGetScrollStyle(sessionId: ffi.sessionId) ?? ''; - return {'visible': visible, 'scrollStyle': scrollStyle}; + final edgeScrollEdgeThickness = await bind + .sessionGetEdgeScrollEdgeThickness(sessionId: ffi.sessionId); + return { + 'visible': visible, + 'scrollStyle': scrollStyle, + 'edgeScrollEdgeThickness': edgeScrollEdgeThickness, + }; }(), hasData: (data) { final visible = data['visible'] as bool; if (!visible) return Offstage(); final groupValue = data['scrollStyle'] as String; - onChange(String? value) async { + final edgeScrollEdgeThickness = data['edgeScrollEdgeThickness'] as int; + + onChangeScrollStyle(String? value) async { if (value == null) return; await bind.sessionSetScrollStyle( sessionId: ffi.sessionId, value: value); widget.ffi.canvasModel.updateScrollStyle(); + state.setState(() {}); + } + + onChangeEdgeScrollEdgeThickness(double? value) async { + if (value == null) return; + final newThickness = value.round(); + await bind.sessionSetEdgeScrollEdgeThickness( + sessionId: ffi.sessionId, value: newThickness); + widget.ffi.canvasModel.updateEdgeScrollEdgeThickness(newThickness); + state.setState(() {}); } return Obx(() => Column(children: [ @@ -1085,17 +1107,9 @@ class _DisplayMenuState extends State<_DisplayMenu> { value: kRemoteScrollStyleAuto, groupValue: groupValue, onChanged: widget.ffi.canvasModel.imageOverflow.value - ? (value) => onChange(value) - : null, - ffi: widget.ffi, - ), - RdoMenuButton( - child: Text(translate('ScrollEdge')), - value: kRemoteScrollStyleEdge, - groupValue: groupValue, - onChanged: widget.ffi.canvasModel.imageOverflow.value - ? (value) => onChange(value) + ? (value) => onChangeScrollStyle(value) : null, + closeOnActivate: groupValue != kRemoteScrollStyleEdge, ffi: widget.ffi, ), RdoMenuButton( @@ -1103,10 +1117,28 @@ class _DisplayMenuState extends State<_DisplayMenu> { value: kRemoteScrollStyleBar, groupValue: groupValue, onChanged: widget.ffi.canvasModel.imageOverflow.value - ? (value) => onChange(value) + ? (value) => onChangeScrollStyle(value) + : null, + closeOnActivate: groupValue != kRemoteScrollStyleEdge, + ffi: widget.ffi, + ), + RdoMenuButton( + child: Text(translate('ScrollEdge')), + value: kRemoteScrollStyleEdge, + groupValue: groupValue, + closeOnActivate: false, + onChanged: widget.ffi.canvasModel.imageOverflow.value + ? (value) => onChangeScrollStyle(value) : null, ffi: widget.ffi, ), + Offstage( + offstage: groupValue != kRemoteScrollStyleEdge, + child: EdgeThicknessControl( + value: edgeScrollEdgeThickness.toDouble(), + onChanged: onChangeEdgeScrollEdgeThickness, + colorScheme: colorScheme, + )), Divider(), ])); }); @@ -1193,13 +1225,16 @@ class _DisplayMenuState extends State<_DisplayMenu> { class _CustomScaleMenuControls extends StatefulWidget { final FFI ffi; final ValueChanged? onChanged; - const _CustomScaleMenuControls({Key? key, required this.ffi, this.onChanged}) : super(key: key); + const _CustomScaleMenuControls({Key? key, required this.ffi, this.onChanged}) + : super(key: key); @override - State<_CustomScaleMenuControls> createState() => _CustomScaleMenuControlsState(); + State<_CustomScaleMenuControls> createState() => + _CustomScaleMenuControlsState(); } -class _CustomScaleMenuControlsState extends CustomScaleControls<_CustomScaleMenuControls> { +class _CustomScaleMenuControlsState + extends CustomScaleControls<_CustomScaleMenuControls> { @override FFI get ffi => widget.ffi; @@ -1235,7 +1270,9 @@ class _CustomScaleMenuControlsState extends CustomScaleControls<_CustomScaleMenu max: 1.0, // Use a wide range of divisions (calculated as (CustomScaleControls.maxPercent - CustomScaleControls.minPercent)) to provide ~1% precision increments. // This allows users to set precise scale values. Lower values would require more fine-tuning via the +/- buttons, which is undesirable for big ranges. - divisions: (CustomScaleControls.maxPercent - CustomScaleControls.minPercent).round(), + divisions: + (CustomScaleControls.maxPercent - CustomScaleControls.minPercent) + .round(), onChanged: onSliderChanged, ), ), @@ -1281,6 +1318,7 @@ class _RectValueThumbShape extends SliderComponentShape { final double width; final double height; final double radius; + final String unit; // Optional mapper to compute display value from normalized position [0,1] // If null, falls back to linear interpolation between min and max. final int Function(double normalized)? displayValueForNormalized; @@ -1292,6 +1330,7 @@ class _RectValueThumbShape extends SliderComponentShape { required this.height, required this.radius, this.displayValueForNormalized, + this.unit = '%', }); @override @@ -1332,12 +1371,12 @@ class _RectValueThumbShape extends SliderComponentShape { final Paint paint = Paint()..color = fillColor; canvas.drawRRect(rrect, paint); - // Compute displayed percent from normalized slider value. - final int percent = displayValueForNormalized != null + // Compute displayed value from normalized slider value. + final int displayValue = displayValueForNormalized != null ? displayValueForNormalized!(value) : (min + value * (max - min)).round(); final TextSpan span = TextSpan( - text: '$percent%', + text: '$displayValue$unit', style: const TextStyle( color: Colors.white, fontSize: 12, @@ -1350,7 +1389,8 @@ class _RectValueThumbShape extends SliderComponentShape { textDirection: textDirection, ); tp.layout(maxWidth: width - 4); - tp.paint(canvas, Offset(center.dx - tp.width / 2, center.dy - tp.height / 2)); + tp.paint( + canvas, Offset(center.dx - tp.width / 2, center.dy - tp.height / 2)); } } @@ -1696,7 +1736,7 @@ class _KeyboardMenu extends StatelessWidget { ffi: ffi, color: _ToolbarTheme.blueColor, hoverColor: _ToolbarTheme.hoverBlueColor, - menuChildrenGetter: () => [ + menuChildrenGetter: (_) => [ keyboardMode(), localKeyboardType(), inputSource(), @@ -1961,7 +2001,7 @@ class _ChatMenuState extends State<_ChatMenu> { ffi: widget.ffi, color: _ToolbarTheme.blueColor, hoverColor: _ToolbarTheme.hoverBlueColor, - menuChildrenGetter: () => [textChat(), voiceCall()]); + menuChildrenGetter: (_) => [textChat(), voiceCall()]); } } @@ -2017,7 +2057,7 @@ class _VoiceCallMenu extends StatelessWidget { @override Widget build(BuildContext context) { - menuChildrenGetter() { + menuChildrenGetter(_IconSubmenuButtonState state) { final audioInput = AudioInput( builder: (devices, currentDevice, setDevice) { return Column( @@ -2217,7 +2257,7 @@ class _IconSubmenuButton extends StatefulWidget { final Widget? icon; final Color color; final Color hoverColor; - final List Function() menuChildrenGetter; + final List Function(_IconSubmenuButtonState state) menuChildrenGetter; final MenuStyle? menuStyle; final FFI? ffi; final double? width; @@ -2242,6 +2282,11 @@ class _IconSubmenuButton extends StatefulWidget { class _IconSubmenuButtonState extends State<_IconSubmenuButton> { bool hover = false; + @override // discard @protected + void setState(VoidCallback fn) { + super.setState(fn); + } + @override Widget build(BuildContext context) { assert(widget.svg != null || widget.icon != null); @@ -2274,7 +2319,7 @@ class _IconSubmenuButtonState extends State<_IconSubmenuButton> { ), child: icon))), menuChildren: widget - .menuChildrenGetter() + .menuChildrenGetter(this) .map((e) => _buildPointerTrackWidget(e, widget.ffi)) .toList())); return MenuBar(children: [ @@ -2637,3 +2682,56 @@ Widget _buildPointerTrackWidget(Widget child, FFI? ffi) { ), ); } + +class EdgeThicknessControl extends StatelessWidget { + final double value; + final ValueChanged? onChanged; + final ColorScheme? colorScheme; + + const EdgeThicknessControl({ + Key? key, + required this.value, + this.onChanged, + this.colorScheme, + }) : super(key: key); + + static const double kMin = 20; + static const double kMax = 150; + + @override + Widget build(BuildContext context) { + final colorScheme = this.colorScheme ?? Theme.of(context).colorScheme; + + final slider = SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: colorScheme.primary, + thumbColor: colorScheme.primary, + overlayColor: colorScheme.primary.withOpacity(0.1), + showValueIndicator: ShowValueIndicator.never, + thumbShape: _RectValueThumbShape( + min: EdgeThicknessControl.kMin, + max: EdgeThicknessControl.kMax, + width: 52, + height: 24, + radius: 4, + unit: 'px', + ), + ), + child: Semantics( + value: value.toInt().toString(), + child: Slider( + value: value, + min: EdgeThicknessControl.kMin, + max: EdgeThicknessControl.kMax, + divisions: + (EdgeThicknessControl.kMax - EdgeThicknessControl.kMin).round(), + semanticFormatterCallback: (double newValue) => + "${newValue.round()}px", + onChanged: onChanged, + ), + ), + ); + + return slider; + } +} diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 8e45b69e7..8153c16d2 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -1667,6 +1667,7 @@ class ImageModel with ChangeNotifier { if (isDesktop || isWebDesktop) { await parent.target?.canvasModel.updateViewStyle(); await parent.target?.canvasModel.updateScrollStyle(); + await parent.target?.canvasModel.initializeEdgeScrollEdgeThickness(); } if (parent.target != null) { await initializeCursorAndCanvas(parent.target!); @@ -1914,6 +1915,8 @@ class CanvasModel with ChangeNotifier { // scroll offset y percent double _scrollY = 0.0; ScrollStyle _scrollStyle = ScrollStyle.scrollauto; + // edge scroll mode: trigger scrolling when the cursor is close to the edge of the view + int _edgeScrollEdgeThickness = 100; // tracks whether edge scroll should be active, prevents spurious // scrolling when the cursor enters the view from outside EdgeScrollState _edgeScrollState = EdgeScrollState.inactive; @@ -2090,11 +2093,11 @@ class CanvasModel with ChangeNotifier { }); } - updateScrollStyle() async { + Future updateScrollStyle() async { final style = await bind.sessionGetScrollStyle(sessionId: sessionId); _scrollStyle = style != null - ? ScrollStyle.fromString(style!) + ? ScrollStyle.fromString(style) : ScrollStyle.scrollauto; if (_scrollStyle != ScrollStyle.scrollauto) { @@ -2104,7 +2107,20 @@ class CanvasModel with ChangeNotifier { notifyListeners(); } - update(double x, double y, double scale) { + Future initializeEdgeScrollEdgeThickness() async { + final savedValue = await bind.sessionGetEdgeScrollEdgeThickness(sessionId: sessionId); + + if (savedValue != null) { + _edgeScrollEdgeThickness = savedValue; + } + } + + void updateEdgeScrollEdgeThickness(int newThickness) { + _edgeScrollEdgeThickness = newThickness; + notifyListeners(); + } + + void update(double x, double y, double scale) { _x = x; _y = y; _scale = scale; @@ -2224,9 +2240,6 @@ class CanvasModel with ChangeNotifier { return; } - // Trigger scrolling when the cursor is close to an edge - const double edgeThickness = 100; - if (_edgeScrollState == EdgeScrollState.armed) { // Edge scroll is armed to become active once the cursor // is observed within the rectangle interior to the @@ -2235,7 +2248,7 @@ class CanvasModel with ChangeNotifier { // doesn't happen yet. final clientArea = Rect.fromLTWH(0, 0, size.width, size.height); - final innerZone = clientArea.deflate(edgeThickness); + final innerZone = clientArea.deflate(_edgeScrollEdgeThickness.toDouble()); if (innerZone.contains(Offset(x, y))) { _edgeScrollState = EdgeScrollState.active; @@ -2248,16 +2261,16 @@ class CanvasModel with ChangeNotifier { var dxOffset = 0.0; var dyOffset = 0.0; - if (x < edgeThickness) { - dxOffset = x - edgeThickness; - } else if (x >= size.width - edgeThickness) { - dxOffset = x - (size.width - edgeThickness); + if (x < _edgeScrollEdgeThickness) { + dxOffset = x - _edgeScrollEdgeThickness; + } else if (x >= size.width - _edgeScrollEdgeThickness) { + dxOffset = x - (size.width - _edgeScrollEdgeThickness); } - if (y < edgeThickness) { - dyOffset = y - edgeThickness; - } else if (y >= size.height - edgeThickness) { - dyOffset = y - (size.height - edgeThickness); + if (y < _edgeScrollEdgeThickness) { + dyOffset = y - _edgeScrollEdgeThickness; + } else if (y >= size.height - _edgeScrollEdgeThickness) { + dyOffset = y - (size.height - _edgeScrollEdgeThickness); } var encroachment = Vector2(dxOffset, dyOffset); @@ -3580,6 +3593,7 @@ class FFI { dialogManager.dismissAll(); await canvasModel.updateViewStyle(); await canvasModel.updateScrollStyle(); + await canvasModel.initializeEdgeScrollEdgeThickness(); for (final cb in imageModel.callbacksOnFirstImage) { cb(id); } diff --git a/libs/hbb_common b/libs/hbb_common index a4053b929..9b53baeff 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit a4053b929b14059b1bd116900de8a103d9d838ae +Subproject commit 9b53baeffeedd0a2933ec5cc8c8e426eaecf804f diff --git a/src/client.rs b/src/client.rs index 422fce600..a7b681ee1 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1976,13 +1976,24 @@ impl LoginConfigHandler { /// /// # Arguments /// - /// * `value` - The view style to be saved. + /// * `value` - The scroll style to be saved. pub fn save_scroll_style(&mut self, value: String) { let mut config = self.load_config(); config.scroll_style = value; self.save_config(config); } + /// Save edge scroll edge thickness to the current config. + /// + /// # Arguments + /// + /// * `value` - The edge thickness to be saved. + pub fn save_edge_scroll_edge_thickness(&mut self, value: i32) { + let mut config = self.load_config(); + config.edge_scroll_edge_thickness = value; + self.save_config(config); + } + /// Set a ui config of flutter for handler's [`PeerConfig`]. /// /// # Arguments diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index bce4ab67e..15ffd52b8 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -273,7 +273,10 @@ pub fn session_take_screenshot(session_id: SessionID, display: usize) { } } -pub fn session_handle_screenshot(#[allow(unused_variables)] session_id: SessionID, action: String) -> String { +pub fn session_handle_screenshot( + #[allow(unused_variables)] session_id: SessionID, + action: String, +) -> String { crate::client::screenshot::handle_screenshot(action) } @@ -393,6 +396,20 @@ pub fn session_set_scroll_style(session_id: SessionID, value: String) { } } +pub fn session_get_edge_scroll_edge_thickness(session_id: SessionID) -> Option { + if let Some(session) = sessions::get_session_by_session_id(&session_id) { + Some(session.get_edge_scroll_edge_thickness()) + } else { + None + } +} + +pub fn session_set_edge_scroll_edge_thickness(session_id: SessionID, value: i32) { + if let Some(session) = sessions::get_session_by_session_id(&session_id) { + session.save_edge_scroll_edge_thickness(value); + } +} + pub fn session_get_image_quality(session_id: SessionID) -> Option { if let Some(session) = sessions::get_session_by_session_id(&session_id) { Some(session.get_image_quality()) diff --git a/src/ui_session_interface.rs b/src/ui_session_interface.rs index 93c041348..9c1b7d946 100644 --- a/src/ui_session_interface.rs +++ b/src/ui_session_interface.rs @@ -238,6 +238,10 @@ impl Session { self.lc.read().unwrap().scroll_style.clone() } + pub fn get_edge_scroll_edge_thickness(&self) -> i32 { + self.lc.read().unwrap().edge_scroll_edge_thickness + } + pub fn get_image_quality(&self) -> String { self.lc.read().unwrap().image_quality.clone() } @@ -350,6 +354,10 @@ impl Session { self.lc.write().unwrap().save_scroll_style(value); } + pub fn save_edge_scroll_edge_thickness(&self, value: i32) { + self.lc.write().unwrap().save_edge_scroll_edge_thickness(value); + } + pub fn save_flutter_option(&self, k: String, v: String) { self.lc.write().unwrap().save_ui_flutter(k, v); }