Files
BadNote/lib/editor/ui/pen_settings_page.dart
Akiba So f31dd0fb52
Some checks failed
CI / Windows build (push) Has been cancelled
fix: PDF finger ink, chrome UX, OneNote pens, and pen physics
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>
2026-08-06 16:01:01 +08:00

391 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
),
],
);
}
}