Some checks failed
CI / Windows build (push) Has been cancelled
Wire finger drawing on PDF without breaking pinch; auto-hide page scrubber and fix bounce; share sticky tools with resize and per-page remember; side-button select; separate pen slots with colors; rnote pressure shapes plus tip-velocity width and lower stroke latency. Co-authored-by: Cursor <cursoragent@cursor.com>
391 lines
14 KiB
Dart
391 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
||
|
||
import '../input/pen_config.dart';
|
||
import '../input/pressure_curve.dart';
|
||
|
||
String _shapeNameForGamma(double gamma) {
|
||
if ((gamma - 0.6).abs() < 0.05) return 'soft';
|
||
if ((gamma - 1.0).abs() < 0.05) return 'linear';
|
||
if ((gamma - 2.0).abs() < 0.05) return 'quadratic';
|
||
if ((gamma - 3.0).abs() < 0.05) return 'cubic';
|
||
if ((gamma - 0.5).abs() < 0.05) return 'sqrt';
|
||
return 'soft';
|
||
}
|
||
|
||
PressureCurve _curveForName(String name) => switch (name) {
|
||
'linear' => PressureCurve.shaped(PressureCurveShape.linear),
|
||
'quadratic' => PressureCurve.shaped(PressureCurveShape.quadratic),
|
||
'cubic' => PressureCurve.shaped(PressureCurveShape.cubic),
|
||
'sqrt' => PressureCurve.shaped(PressureCurveShape.sqrt),
|
||
'logarithmic' => PressureCurve.shaped(PressureCurveShape.soft), // gamma proxy
|
||
_ => PressureCurve.shaped(PressureCurveShape.soft),
|
||
};
|
||
|
||
/// Shows a Material You modal bottom sheet for configuring pen input.
|
||
///
|
||
/// Changes are applied and persisted immediately via [controller] setters,
|
||
/// so the sheet reflects the live configuration at all times.
|
||
Future<void> showPenSettingsSheet(
|
||
BuildContext context,
|
||
PenConfigController controller,
|
||
) {
|
||
return showModalBottomSheet<void>(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||
),
|
||
builder: (context) => _PenSettingsSheet(controller: controller),
|
||
);
|
||
}
|
||
|
||
class _PenSettingsSheet extends StatelessWidget {
|
||
const _PenSettingsSheet({required this.controller});
|
||
|
||
final PenConfigController controller;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ListenableBuilder(
|
||
listenable: controller,
|
||
builder: (context, _) {
|
||
final config = controller.value;
|
||
final colorScheme = Theme.of(context).colorScheme;
|
||
|
||
return DraggableScrollableSheet(
|
||
expand: false,
|
||
initialChildSize: 0.7,
|
||
minChildSize: 0.4,
|
||
maxChildSize: 0.95,
|
||
builder: (context, scrollController) {
|
||
return ListView(
|
||
controller: scrollController,
|
||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
|
||
children: [
|
||
// Drag handle
|
||
Center(
|
||
child: Container(
|
||
width: 32,
|
||
height: 4,
|
||
margin: const EdgeInsets.symmetric(vertical: 12),
|
||
decoration: BoxDecoration(
|
||
color: colorScheme.onSurfaceVariant.withAlpha(80),
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
),
|
||
Text(
|
||
'Pen Settings',
|
||
style: Theme.of(context).textTheme.titleLarge,
|
||
textAlign: TextAlign.center,
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
// ── Button Actions ────────────────────────────────────────
|
||
_SectionHeader(
|
||
title: 'Button Actions',
|
||
icon: Icons.touch_app,
|
||
colorScheme: colorScheme,
|
||
),
|
||
const SizedBox(height: 8),
|
||
_LabeledRow(
|
||
label: 'Side Button',
|
||
child: _ActionDropdown(
|
||
value: config.sideButton,
|
||
onChanged: controller.setSideButton,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
_LabeledRow(
|
||
label: 'Eraser End',
|
||
child: _ActionDropdown(
|
||
value: config.eraserEnd,
|
||
onChanged: controller.setEraserEnd,
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
// ── Pressure ──────────────────────────────────────────────
|
||
_SectionHeader(
|
||
title: 'Pressure',
|
||
icon: Icons.compress,
|
||
colorScheme: colorScheme,
|
||
),
|
||
_SliderTile(
|
||
label: 'Pressure Sensitivity',
|
||
value: config.pressureSensitivity,
|
||
min: 0.0,
|
||
max: 1.0,
|
||
divisions: 20,
|
||
formatValue: (v) => v.toStringAsFixed(2),
|
||
onChanged: controller.setPressureSensitivity,
|
||
),
|
||
_SliderTile(
|
||
label: 'Pressure Gamma',
|
||
value: config.pressureGamma,
|
||
min: 0.3,
|
||
max: 3.0,
|
||
divisions: 27,
|
||
formatValue: (v) => v.toStringAsFixed(2),
|
||
onChanged: controller.setPressureGamma,
|
||
),
|
||
_LabeledRow(
|
||
label: 'Curve Preset (rnote)',
|
||
child: DropdownMenu<String>(
|
||
initialSelection: _shapeNameForGamma(config.pressureGamma),
|
||
onSelected: (name) {
|
||
if (name == null) return;
|
||
final shaped = _curveForName(name);
|
||
controller.setPressureGamma(shaped.gamma);
|
||
},
|
||
dropdownMenuEntries: const [
|
||
DropdownMenuEntry(value: 'soft', label: 'Soft (γ≈0.6)'),
|
||
DropdownMenuEntry(value: 'linear', label: 'Linear'),
|
||
DropdownMenuEntry(
|
||
value: 'quadratic', label: 'Quadratic / Pow2'),
|
||
DropdownMenuEntry(value: 'cubic', label: 'Cubic'),
|
||
DropdownMenuEntry(value: 'sqrt', label: 'Sqrt (pencil)'),
|
||
],
|
||
),
|
||
),
|
||
const Padding(
|
||
padding: EdgeInsets.only(left: 8, bottom: 8),
|
||
child: Text(
|
||
'笔刷自带曲线优先(钢笔=二次/Pow2,铅笔=平方根)。全局 gamma 作后备。',
|
||
style: TextStyle(fontSize: 12),
|
||
),
|
||
),
|
||
|
||
// ── Input ─────────────────────────────────────────────────
|
||
_SectionHeader(
|
||
title: 'Input',
|
||
icon: Icons.pan_tool_alt,
|
||
colorScheme: colorScheme,
|
||
),
|
||
_SliderTile(
|
||
label: 'Palm Rejection',
|
||
value: config.palmRejectionMs,
|
||
min: 0,
|
||
max: 500,
|
||
divisions: 50,
|
||
formatValue: (v) => '${v.round()} ms',
|
||
onChanged: controller.setPalmRejectionMs,
|
||
),
|
||
SwitchListTile(
|
||
title: const Text('Finger Drawing'),
|
||
subtitle: const Text(
|
||
'Allow touch strokes when no pen is detected',
|
||
),
|
||
value: config.fingerDrawing,
|
||
onChanged: controller.setFingerDrawing,
|
||
contentPadding: EdgeInsets.zero,
|
||
),
|
||
|
||
// ── Stroke Widths ─────────────────────────────────────────
|
||
_SectionHeader(
|
||
title: 'Stroke Widths',
|
||
icon: Icons.line_weight,
|
||
colorScheme: colorScheme,
|
||
),
|
||
_SliderTile(
|
||
label: 'Pen Width',
|
||
value: config.penWidth,
|
||
min: 0.001,
|
||
max: 0.02,
|
||
divisions: 19,
|
||
formatValue: (v) => v.toStringAsFixed(4),
|
||
onChanged: controller.setPenWidth,
|
||
),
|
||
_SliderTile(
|
||
label: 'Highlighter Width',
|
||
value: config.highlighterWidth,
|
||
min: 0.005,
|
||
max: 0.06,
|
||
divisions: 22,
|
||
formatValue: (v) => v.toStringAsFixed(4),
|
||
onChanged: controller.setHighlighterWidth,
|
||
),
|
||
|
||
// ── Eraser ────────────────────────────────────────────────
|
||
_SectionHeader(
|
||
title: 'Eraser',
|
||
icon: Icons.cleaning_services_outlined,
|
||
colorScheme: colorScheme,
|
||
),
|
||
_SliderTile(
|
||
label: 'Eraser Size',
|
||
value: config.eraserRadius,
|
||
min: 0.005,
|
||
max: 0.1,
|
||
divisions: 19,
|
||
formatValue: (v) => v.toStringAsFixed(3),
|
||
onChanged: controller.setEraserRadius,
|
||
),
|
||
SwitchListTile(
|
||
title: const Text('Stroke Eraser'),
|
||
subtitle: const Text(
|
||
'Erase a whole stroke on contact (off: erase by segment)',
|
||
),
|
||
value: config.eraserWholeStroke,
|
||
onChanged: controller.setEraserWholeStroke,
|
||
contentPadding: EdgeInsets.zero,
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Shared section header ────────────────────────────────────────────────────
|
||
|
||
class _SectionHeader extends StatelessWidget {
|
||
const _SectionHeader({
|
||
required this.title,
|
||
required this.icon,
|
||
required this.colorScheme,
|
||
});
|
||
|
||
final String title;
|
||
final IconData icon;
|
||
final ColorScheme colorScheme;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||
child: Row(
|
||
children: [
|
||
Icon(icon, size: 18, color: colorScheme.primary),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
title,
|
||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||
color: colorScheme.primary,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(child: Divider(color: colorScheme.outlineVariant)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Label + widget row ───────────────────────────────────────────────────────
|
||
|
||
class _LabeledRow extends StatelessWidget {
|
||
const _LabeledRow({required this.label, required this.child});
|
||
|
||
final String label;
|
||
final Widget child;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Row(
|
||
children: [
|
||
SizedBox(
|
||
width: 120,
|
||
child: Text(
|
||
label,
|
||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||
),
|
||
),
|
||
Expanded(child: child),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Action dropdown ──────────────────────────────────────────────────────────
|
||
|
||
class _ActionDropdown extends StatelessWidget {
|
||
const _ActionDropdown({required this.value, required this.onChanged});
|
||
|
||
final PenButtonAction value;
|
||
final ValueChanged<PenButtonAction> onChanged;
|
||
|
||
static String _label(PenButtonAction action) => switch (action) {
|
||
PenButtonAction.none => '无',
|
||
PenButtonAction.eraser => '橡皮',
|
||
PenButtonAction.undo => '撤销',
|
||
PenButtonAction.toggleTool => '切换工具',
|
||
PenButtonAction.pan => '平移',
|
||
PenButtonAction.select => '选择(笔迹)',
|
||
PenButtonAction.selectText => '选择文本',
|
||
};
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return DropdownMenu<PenButtonAction>(
|
||
initialSelection: value,
|
||
expandedInsets: EdgeInsets.zero,
|
||
onSelected: (action) {
|
||
if (action != null) onChanged(action);
|
||
},
|
||
dropdownMenuEntries: PenButtonAction.values
|
||
.map(
|
||
(a) => DropdownMenuEntry(value: a, label: _label(a)),
|
||
)
|
||
.toList(),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Slider with live value label ─────────────────────────────────────────────
|
||
|
||
class _SliderTile extends StatelessWidget {
|
||
const _SliderTile({
|
||
required this.label,
|
||
required this.value,
|
||
required this.min,
|
||
required this.max,
|
||
required this.divisions,
|
||
required this.formatValue,
|
||
required this.onChanged,
|
||
});
|
||
|
||
final String label;
|
||
final double value;
|
||
final double min;
|
||
final double max;
|
||
final int divisions;
|
||
final String Function(double) formatValue;
|
||
final ValueChanged<double> onChanged;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(label, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||
Text(
|
||
formatValue(value),
|
||
style: TextStyle(
|
||
fontFamily: 'monospace',
|
||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||
fontSize: 13,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
Slider(
|
||
value: value.clamp(min, max),
|
||
min: min,
|
||
max: max,
|
||
divisions: divisions,
|
||
label: formatValue(value),
|
||
onChanged: onChanged,
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|