edge scroll thickness adjustment (#13445)

Signed-off-by: 21pages <sunboeasy@gmail.com>
This commit is contained in:
21pages
2025-11-07 01:15:13 +08:00
committed by GitHub
parent 268534d5e7
commit e029d00cfa
8 changed files with 227 additions and 60 deletions

View File

@@ -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";

View File

@@ -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,
)),
]);
}

View File

@@ -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 = <Widget>[
_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<String>(
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<String>(
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<String>(
@@ -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<String>(
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<int>? 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<Widget> Function() menuChildrenGetter;
final List<Widget> 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<double>? 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;
}
}

View File

@@ -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<void> 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<void> 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);
}

View File

@@ -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

View File

@@ -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<i32> {
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<String> {
if let Some(session) = sessions::get_session_by_session_id(&session_id) {
Some(session.get_image_quality())

View File

@@ -238,6 +238,10 @@ impl<T: InvokeUiSession> Session<T> {
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<T: InvokeUiSession> Session<T> {
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);
}