diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..79c113f9b --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/.metadata new file mode 100644 index 000000000..39a6af965 --- /dev/null +++ b/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "edada7c56edf4a183c1735310e123c7f923584f1" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: edada7c56edf4a183c1735310e123c7f923584f1 + base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + - platform: web + create_revision: edada7c56edf4a183c1735310e123c7f923584f1 + base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/README.md b/README.md index 597b39b44..b71494306 100644 --- a/README.md +++ b/README.md @@ -1,96 +1,292 @@ frame (3) -# [Project Name] 🎯 +# ScamPad 🎯 ## Basic Details -### Team Name: [Name] +### Team Name: Vanguard ### Team Members -- Team Lead: [Name] - [College] -- Member 2: [Name] - [College] -- Member 3: [Name] - [College] +- Team Lead: Jeffin Basil - SCMS SCHOOL OF ENGINEERING & TECHNOLOGY +- Member 2: Adithya Manghat - SCMS SCHOOL OF ENGINEERING & TECHNOLOGY ### Project Description -[2-3 lines about what your project does] +"ScamPad" could be envisioned as a notepad application that looks normal but gradually scrambles, distorts, or modifies saved notes subtly (and sometimes not so subtly) over time! ### The Problem (that doesn't exist) -[What ridiculous problem are you solving?] +Notepad app is quite simple and convenient to use, but that's no fun right? ### The Solution (that nobody asked for) -[How are you solving it? Keep it fun!] +Give it a little personality and it'll help you "rephrase" your notes really well. ## Technical Details ### Technologies/Components Used For Software: -- [Languages used] -- [Frameworks used] -- [Libraries used] -- [Tools used] +- Dart, C++ +- Flutter, Material Design 3 +- flutter/material.dart, flutter/services.dart, file_picker, dart:io, dart:async, dart:math +- Visual Studio Code, Flutter SDK, CMake + -For Hardware: -- [List main components] -- [List specifications] -- [List tools required] ### Implementation For Software: # Installation -[commands] +```bash +# Install Flutter SDK +flutter doctor -# Run -[commands] +# Clone the repository +git clone https://github.com/FALLEN-01/vanguard.git +cd vanguard -### Project Documentation -For Software: +# Get dependencies +flutter pub get -# Screenshots (Add at least 3) -![Screenshot1](Add screenshot 1 here with proper name) -*Add caption explaining what this shows* +# Enable Windows desktop +flutter config --enable-windows-desktop -![Screenshot2](Add screenshot 2 here with proper name) -*Add caption explaining what this shows* +# Verify setup +flutter doctor -v +``` -![Screenshot3](Add screenshot 3 here with proper name) -*Add caption explaining what this shows* +# Run +```bash +# Development mode +flutter run -d windows -# Diagrams -![Workflow](Add your workflow/architecture diagram here) -*Add caption explaining your workflow* +# Release build +flutter build windows -For Hardware: +# Run executable +./build/windows/runner/Release/scampad.exe +``` -# Schematic & Circuit -![Circuit](Add your circuit diagram here) -*Add caption explaining connections* +### Project Documentation +For Software: -![Schematic](Add your schematic diagram here) -*Add caption explaining the schematic* +## Features + +### Core Functionality +- **Multi-tab Text Editor**: Supports multiple documents with tab-based navigation +- **Native Windows Integration**: File dialogs, keyboard shortcuts, and system clipboard support +- **Real-time Statistics**: Live word count, character count, and line tracking +- **Auto-save Warning**: Prompts users before losing unsaved work + +### Chaos Features 🎲 +- **Progressive Chaos System**: Chaos intensity increases based on document word count + - Under 40 words: Gentle disruptions (punctuation, spaces, cursor movement) + - 40-59 words: Character manipulation begins (letter deletion, swapping) + - 60+ words: Full chaos mode (word deletion, word swapping) + +- **Speed-based Escalation**: Typing speed monitoring with escalating consequences + - 65+ WPM triggers speed warnings + - 4 violation system with sarcastic messages + - Automatic shutdown after 4th violation + +- **Idle Punishment**: After 10 seconds of inactivity, rapid word swapping begins + - Continuous text scrambling until user resumes typing + - 500ms interval rapid swapping for maximum disruption + +### Chaos Actions +- **Letter Operations**: Random deletion, swapping within words +- **Word Operations**: Word deletion, inter-line word swapping +- **Punctuation Injection**: Smart placement at word boundaries only +- **Cursor Teleportation**: Random cursor repositioning +- **Indentation Chaos**: Random spacing and tab insertion +- **Space Addition**: Random whitespace injection + +### User Interface +- **Modern Design**: Clean, Material Design 3 interface +- **Dark Theme**: Easy on the eyes during chaos events +- **Status Bar**: Real-time document statistics and modification indicators +- **Dialog Systems**: Speed warnings, subscription prompts, and shutdown notifications + +## Architecture + +### Class Structure +``` +ChaosManager +├── Timer-based chaos scheduling (4-8 second intervals) +├── Word count-based action selection +├── Idle detection and rapid swapping +└── 13 different chaos methods + +TypingSpeedMonitor +├── Real-time keystroke tracking +├── Speed calculation (CPM/WPM) +├── Violation counter with persistence +└── Escalating warning system + +ModernNotepadPage +├── Multi-tab document management +├── File operations (New, Open, Save, Save As) +├── Statistics tracking +└── UI state management + +TabData +├── Individual document state +├── TextEditingController management +├── File path and modification tracking +└── Focus node handling +``` + +### Chaos Progression Logic +1. **Document Analysis**: Word count determines available chaos actions +2. **Random Selection**: Weighted probability system for action selection +3. **Speed Monitoring**: Continuous typing speed analysis +4. **Idle Detection**: 10-second countdown with rapid punishment +5. **State Persistence**: Violation counts persist throughout session + +## Technical Implementation + +### Key Components +- **Flutter Desktop**: Cross-platform UI framework targeting Windows +- **Native File System**: Direct Windows API integration for file operations +- **Timer Management**: Multiple concurrent timers for different chaos systems +- **Text Manipulation**: Advanced string processing for surgical text modifications +- **Event Handling**: Keyboard shortcuts and text change monitoring + +### Performance Optimizations +- **Non-blocking Chaos**: All chaos operations run on separate timer threads +- **Efficient Text Operations**: Minimal string allocations during modifications +- **Smart Action Selection**: Context-aware chaos to avoid breaking functionality +- **Memory Management**: Proper timer cleanup and resource disposal + +## Usage Scenarios + +### Normal Usage (If Possible) +1. Open ScamPad like any text editor +2. Create or open text files +3. Experience "minor" inconveniences during typing +4. Save work frequently (before chaos strikes) + +### Chaos Experience +1. **Early Stage**: Gentle disruptions, mostly cosmetic +2. **Mid Stage**: Noticeable text modifications, typing becomes challenging +3. **Late Stage**: Full chaos mode, document becomes increasingly unstable +4. **Speed Violations**: Aggressive warnings, potential shutdown +5. **Idle Periods**: Relentless text scrambling until activity resumes + +## Configuration + +### Adjustable Parameters +- Chaos interval: 4-8 seconds (configurable in code) +- Speed threshold: 325 CPM / 65 WPM +- Idle timeout: 10 seconds +- Rapid swap interval: 500ms +- Word count thresholds: 40, 60 words + +### Chaos Probabilities +**Stage 1 (< 40 words):** +- 40% Punctuation injection +- 30% Random spaces +- 20% Cursor teleportation +- 10% Random indentation + +**Stage 2 (40-59 words):** +- 35% Letter deletion +- 25% Letter swapping +- 15% Punctuation injection +- 12% Cursor teleportation +- 8% Random spaces +- 5% Random indentation + +**Stage 3 (60+ words):** +- 30% Letter deletion +- 25% Letter swapping +- 20% Word deletion +- 10% Word swapping +- 8% Punctuation injection +- 5% Random spaces +- 2% Random indentation + +# Screenshots +![Initial State](Screenshots/initial_state.png) +*ScamPad's clean interface on startup - appears as a normal notepad application with multiple tabs, file operations, and a professional layout* + +![Final State](Screenshots/final_state.png) +*After an idle period - text becomes scrambled, words are swapped and deleted, demonstrating the progressive chaos system in action* -# Build Photos -![Components](Add photo of your components here) -*List out all components shown* +# Diagrams -![Build](Add photos of build process here) -*Explain the build steps* +## ScamPad Chaos Workflow +``` + ┌─────────────────┐ + │ User Opens │ + │ ScamPad │ + └─────────┬───────┘ + │ + ▼ + ┌─────────────────┐ + │ Initialize UI │ + │ & Systems │ + └─────────┬───────┘ + │ + ▼ + ┌─────────────────────────────────────────────┐ + │ Start Three Parallel Systems │ + └─────────┬───────────┬───────────┬───────────┘ + │ │ │ + ▼ ▼ ▼ + ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ + │ Chaos │ │ Speed │ │ Idle │ + │ Manager │ │ Monitor │ │ Detector │ + │ (4-8 sec) │ │ (Real-time) │ │ (10 sec) │ + └─────────────┘ └─────────────┘ └─────────────┘ + │ │ │ + ▼ ▼ ▼ + ┌─────────────────────────────────────────────┐ + │ USER TYPING ACTIVITY │ + └─────────────────┬───────────────────────────┘ + │ + ▼ + ┌─────────────────┐ ┌─────────────────┐ + │ Word Count │ │ Speed Check │ + │ Analysis │◄─────┤ ≥65 WPM? │ + └─────────┬───────┘ └─────────┬───────┘ + │ │ + ┌─────────▼───────┐ ┌─────────▼───────┐ + │ < 40 Words? │ │ Speed Warning │ + │ Stage 1 │ │ Escalation │ + │ • Punctuation │ │ • 4 Violations │ + │ • Spaces │ │ • Shutdown │ + │ • Cursor Move │ └─────────────────┘ + └─────────┬───────┘ + │ + ┌─────────▼───────┐ ┌─────────────────┐ + │ 40-59 Words? │ │ Idle Timer │ + │ Stage 2 │◄─────┤ 10 seconds │ + │ • Letter Ops │ └─────────┬───────┘ + │ • + Stage 1 │ │ + └─────────┬───────┘ ┌─────────▼───────┐ + │ │ Rapid Swapping │ + ┌─────────▼───────┐ │ Every 500ms │ + │ 60+ Words? │ │ Until Activity │ + │ Stage 3 │ └─────────────────┘ + │ • Word Delete │ + │ • Word Swap │ + │ • Full Chaos │ + └─────────────────┘ +``` +*This diagram shows how ScamPad's three parallel chaos systems work together to progressively disrupt the user experience based on document length, typing speed, and activity level* -![Final](Add photo of final product here) -*Explain the final build* ### Project Demo # Video -[Add your demo video link here] -*Explain what the video demonstrates* -# Additional Demos -[Add any extra demo materials/links] +![ScamPad Demo](Screenshots/ScamPad_final.gif) + +**Full Video**: [Download complete demo](Screenshots/ScamPad_final.mp4) + +*This GIF demonstrates ScamPad's complete chaos system in action - from normal notepad functionality to progressive text scrambling, speed-based warnings, and idle-based rapid swapping* + ## Team Contributions -- [Name 1]: [Specific contributions] -- [Name 2]: [Specific contributions] -- [Name 3]: [Specific contributions] +- Jeffin Basil: Flutter app architecture, chaos system implementation, UI design, file operations +- Adithya Manghat: Chaos algorithms, speed monitoring system, testing, documentation --- Made with ❤️ at TinkerHub Useless Projects diff --git a/Screenshots/ScamPad_final.gif b/Screenshots/ScamPad_final.gif new file mode 100644 index 000000000..0bb13a884 Binary files /dev/null and b/Screenshots/ScamPad_final.gif differ diff --git a/Screenshots/ScamPad_final.mp4 b/Screenshots/ScamPad_final.mp4 new file mode 100644 index 000000000..a398e0ffe Binary files /dev/null and b/Screenshots/ScamPad_final.mp4 differ diff --git a/Screenshots/final_state.png b/Screenshots/final_state.png new file mode 100644 index 000000000..2ae25c448 Binary files /dev/null and b/Screenshots/final_state.png differ diff --git a/Screenshots/initial_state.png b/Screenshots/initial_state.png new file mode 100644 index 000000000..eff1dd58b Binary files /dev/null and b/Screenshots/initial_state.png differ diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 000000000..0d2902135 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/assets/icons/app_icon.ico b/assets/icons/app_icon.ico new file mode 100644 index 000000000..74e20810d Binary files /dev/null and b/assets/icons/app_icon.ico differ diff --git a/lib/chaos/chaos_controller.dart b/lib/chaos/chaos_controller.dart new file mode 100644 index 000000000..3ad44e177 --- /dev/null +++ b/lib/chaos/chaos_controller.dart @@ -0,0 +1,320 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:flutter/material.dart'; + +/// The chaotic text editor system that randomly interferes with user input +class ChaosController { + final TextEditingController textController; + final Function(String) onTextChanged; + final Function(TextSelection) onSelectionChanged; + final Function() onSave; + + Timer? _chaosTimer; + final Random _random = Random(); + bool _isActive = true; + + // Chaos behaviors weighted by frequency + final List _chaosActions; + + ChaosController({ + required this.textController, + required this.onTextChanged, + required this.onSelectionChanged, + required this.onSave, + }) : _chaosActions = [ + ChaosAction(ChaosType.cursorDisplacement, weight: 25), + ChaosAction(ChaosType.randomDeletion, weight: 20), + ChaosAction(ChaosType.letterSwapping, weight: 15), + ChaosAction(ChaosType.wordSwapping, weight: 10), + ChaosAction(ChaosType.punctuationInsert, weight: 20), + ChaosAction(ChaosType.punctuationDuplicate, weight: 10), + ]; + + /// Start the chaos cycle + void startChaos() { + _isActive = true; + _scheduleNextChaos(); + } + + /// Stop the chaos cycle + void stopChaos() { + _isActive = false; + _chaosTimer?.cancel(); + } + + /// Schedule the next chaotic event + void _scheduleNextChaos() { + if (!_isActive) return; + + // Random delay between 8-15 seconds + final delaySeconds = 8 + _random.nextInt(8); + + _chaosTimer = Timer(Duration(seconds: delaySeconds), () { + _executeChaos(); + _scheduleNextChaos(); + }); + } + + /// Execute a random chaotic behavior + void _executeChaos() { + if (!_isActive || textController.text.isEmpty) return; + + final action = _selectRandomAction(); + _performChaosAction(action.type); + } + + /// Select a random chaos action based on weights + ChaosAction _selectRandomAction() { + final totalWeight = _chaosActions.fold( + 0, + (sum, action) => sum + action.weight, + ); + final randomValue = _random.nextInt(totalWeight); + + int currentWeight = 0; + for (final action in _chaosActions) { + currentWeight += action.weight; + if (randomValue < currentWeight) { + return action; + } + } + + return _chaosActions.first; // fallback + } + + /// Perform the specified chaos action + void _performChaosAction(ChaosType type) { + final text = textController.text; + if (text.isEmpty) return; + + switch (type) { + case ChaosType.cursorDisplacement: + _displaceCursor(); + break; + case ChaosType.randomDeletion: + _performRandomDeletion(); + break; + case ChaosType.letterSwapping: + _swapLetters(); + break; + case ChaosType.wordSwapping: + _swapWords(); + break; + case ChaosType.punctuationInsert: + _insertRandomPunctuation(); + break; + case ChaosType.punctuationDuplicate: + _duplicatePunctuation(); + break; + case ChaosType.randomIndentation: + _addRandomIndentation(); + break; + } + } + + /// Move cursor to random position + void _displaceCursor() { + final text = textController.text; + if (text.isEmpty) return; + + final newPosition = _random.nextInt(text.length + 1); + final newSelection = TextSelection.collapsed(offset: newPosition); + + textController.selection = newSelection; + onSelectionChanged(newSelection); + } + + /// Delete random character or word + void _performRandomDeletion() { + final text = textController.text; + if (text.isEmpty) return; + + final shouldDeleteWord = _random.nextBool() && text.contains(' '); + + if (shouldDeleteWord) { + _deleteRandomWord(); + } else { + _deleteRandomCharacter(); + } + } + + /// Delete a random character + void _deleteRandomCharacter() { + final text = textController.text; + if (text.isEmpty) return; + + final deleteIndex = _random.nextInt(text.length); + final newText = + text.substring(0, deleteIndex) + text.substring(deleteIndex + 1); + + textController.text = newText; + textController.selection = TextSelection.collapsed( + offset: deleteIndex.clamp(0, newText.length), + ); + onTextChanged(newText); + } + + /// Delete a random word + void _deleteRandomWord() { + final text = textController.text; + final words = text.split(RegExp(r'\s+')); + if (words.length <= 1) return; + + final wordIndex = _random.nextInt(words.length); + words.removeAt(wordIndex); + + final newText = words.join(' '); + textController.text = newText; + textController.selection = TextSelection.collapsed(offset: newText.length); + onTextChanged(newText); + } + + /// Swap two random letters within a word + void _swapLetters() { + final text = textController.text; + final words = text.split(' '); + + // Find a word with at least 2 characters + final validWords = words.where((word) => word.length >= 2).toList(); + if (validWords.isEmpty) return; + + final word = validWords[_random.nextInt(validWords.length)]; + final wordIndex = words.indexOf(word); + + // Swap two random letters + final letterIndices = List.generate(word.length, (i) => i); + letterIndices.shuffle(_random); + final index1 = letterIndices[0]; + final index2 = letterIndices[1]; + + final chars = word.split(''); + final temp = chars[index1]; + chars[index1] = chars[index2]; + chars[index2] = temp; + + words[wordIndex] = chars.join(''); + final newText = words.join(' '); + + textController.text = newText; + onTextChanged(newText); + } + + /// Swap two random words + void _swapWords() { + final text = textController.text; + final words = text.split(' '); + if (words.length < 2) return; + + final indices = List.generate(words.length, (i) => i); + indices.shuffle(_random); + final index1 = indices[0]; + final index2 = indices[1]; + + final temp = words[index1]; + words[index1] = words[index2]; + words[index2] = temp; + + final newText = words.join(' '); + textController.text = newText; + onTextChanged(newText); + } + + /// Insert random punctuation at random position + void _insertRandomPunctuation() { + final text = textController.text; + if (text.isEmpty) return; + + final punctuation = ['.', ',', '!', '?', ';', ':', '-']; + final randomPunct = punctuation[_random.nextInt(punctuation.length)]; + final insertIndex = _random.nextInt(text.length + 1); + + final newText = + text.substring(0, insertIndex) + + randomPunct + + text.substring(insertIndex); + + textController.text = newText; + textController.selection = TextSelection.collapsed(offset: insertIndex + 1); + onTextChanged(newText); + } + + /// Duplicate existing punctuation + void _duplicatePunctuation() { + final text = textController.text; + final punctuationRegex = RegExp(r'[.!?,;:]'); + final matches = punctuationRegex.allMatches(text).toList(); + + if (matches.isEmpty) return; + + final match = matches[_random.nextInt(matches.length)]; + final punctuation = match.group(0)!; + final position = match.start; + + final newText = + text.substring(0, position + 1) + + punctuation + + text.substring(position + 1); + + textController.text = newText; + onTextChanged(newText); + } + + /// Add random indentation to lines (triggered on save) + void _addRandomIndentation() { + final text = textController.text; + final lines = text.split('\n'); + + // Randomly indent 1-3 lines + final linesToIndent = _random.nextInt(3) + 1; + final selectedLines = {}; + + while (selectedLines.length < linesToIndent && + selectedLines.length < lines.length) { + selectedLines.add(_random.nextInt(lines.length)); + } + + for (final lineIndex in selectedLines) { + final isTab = _random.nextBool(); + final indentCount = _random.nextInt(4) + 1; + final indent = isTab ? '\t' * indentCount : ' ' * indentCount; + lines[lineIndex] = indent + lines[lineIndex]; + } + + final newText = lines.join('\n'); + textController.text = newText; + onTextChanged(newText); + } + + /// Handle save action with random indentation + void handleSave() { + // 70% chance to add random indentation on save + if (_random.nextInt(10) < 7) { + _addRandomIndentation(); + } + onSave(); + } + + /// Dispose resources + void dispose() { + stopChaos(); + } +} + +/// Types of chaotic behaviors +enum ChaosType { + cursorDisplacement, + randomDeletion, + letterSwapping, + wordSwapping, + punctuationInsert, + punctuationDuplicate, + randomIndentation, +} + +/// Chaos action with weight for random selection +class ChaosAction { + final ChaosType type; + final int weight; + + ChaosAction(this.type, {required this.weight}); +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 000000000..7721da7f7 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,2957 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'dart:io'; +import 'dart:async'; +import 'dart:math'; +import 'package:file_picker/file_picker.dart'; + +// ChaosManager handles all the chaos behaviors +class ChaosManager { + late Timer _timer; + Timer? _idleTimer; + Timer? _rapidSwapTimer; + final Random _random = Random(); + final TextEditingController textController; + final VoidCallback updateState; + bool _isRapidSwapping = false; + + ChaosManager({required this.textController, required this.updateState}); + + void startChaos() { + _scheduleNextChaos(); + _startIdleDetection(); + } + + void stopChaos() { + _timer.cancel(); + _idleTimer?.cancel(); + _rapidSwapTimer?.cancel(); + } + + void _startIdleDetection() { + _resetIdleTimer(); + } + + void _resetIdleTimer() { + _idleTimer?.cancel(); + // Track activity time + + // Stop rapid swapping if user becomes active + if (_isRapidSwapping) { + _stopRapidSwapping(); + } + + // Start 10-second idle timer + _idleTimer = Timer(const Duration(seconds: 10), () { + _startRapidSwapping(); + }); + } + + void _startRapidSwapping() { + if (_isRapidSwapping) return; // Prevent multiple timers + + _isRapidSwapping = true; + _rapidSwapTimer = Timer.periodic(const Duration(milliseconds: 500), ( + timer, + ) { + // Rapid word swapping every 500ms while idle + wordSwapping(); + updateState(); + }); + } + + void _stopRapidSwapping() { + _isRapidSwapping = false; + _rapidSwapTimer?.cancel(); + _rapidSwapTimer = null; + } + + // Call this method whenever user types to reset idle detection + void onUserActivity() { + _resetIdleTimer(); + } + + void _scheduleNextChaos() { + // Random interval between 4-8 seconds + final int seconds = 4 + _random.nextInt(5); // 4 to 8 seconds + _timer = Timer(Duration(seconds: seconds), () { + _executeChaosAction(); + _scheduleNextChaos(); // Schedule the next chaos event + }); + } + + void _executeChaosAction() { + // Get current word count from the text + final text = textController.text; + final wordCount = text.trim().isEmpty + ? 0 + : text.trim().split(RegExp(r'\s+')).length; + + // Different chaos actions based on word count thresholds + Map chaosActions; + + if (wordCount < 40) { + // Early stage: Only gentle chaos (no character replacement yet) + chaosActions = { + 0.40: () => punctuationInjection(), // 40% - Add punctuation + 0.30: () => randomSpaceAddition(), // 30% - Add spaces + 0.20: () => cursorTeleportation(), // 20% - Move cursor + 0.10: () => randomIndentationChaos(), // 10% - Random indentation + }; + } else if (wordCount < 60) { + // Mid stage: Character manipulation begins (40+ words) + chaosActions = { + 0.35: () => randomLetterDeletion(), // 35% - Remove letters + 0.25: () => letterSwapping(), // 25% - Swap letters + 0.15: () => punctuationInjection(), // 15% - Add punctuation + 0.12: () => cursorTeleportation(), // 12% - Move cursor + 0.08: () => randomSpaceAddition(), // 8% - Add spaces + 0.05: () => randomIndentationChaos(), // 5% - Random indentation + }; + } else { + // Advanced stage: Full chaos including word deletion (60+ words) + chaosActions = { + 0.30: () => randomLetterDeletion(), // 30% - Remove letters + 0.25: () => letterSwapping(), // 25% - Swap letters + 0.20: () => wordDeletion(), // 20% - Remove words (now active) + 0.10: () => wordSwapping(), // 10% - Swap words + 0.08: () => punctuationInjection(), // 8% - Add punctuation + 0.05: () => randomSpaceAddition(), // 5% - Add spaces + 0.02: () => randomIndentationChaos(), // 2% - Random indentation + }; + } + + // Generate random number and select action based on cumulative probability + final randomValue = _random.nextDouble(); + double cumulativeProbability = 0.0; + + for (final entry in chaosActions.entries) { + cumulativeProbability += entry.key; + if (randomValue <= cumulativeProbability) { + entry.value(); + updateState(); + return; + } + } + } + + // Random Indentation on Save - will be called from save method + void randomIndentationOnSave() { + final text = textController.text; + if (text.isEmpty) return; + + final lines = text.split('\n'); + final linesToModify = _random.nextInt(3) + 1; // Modify 1-3 lines + + for (int i = 0; i < linesToModify; i++) { + final lineIndex = _random.nextInt(lines.length); + final indentType = _random.nextBool() ? '\t' : ' '; // Tab or 2 spaces + final indentCount = _random.nextInt(3) + 1; // 1-3 indentations + lines[lineIndex] = (indentType * indentCount) + lines[lineIndex]; + } + + textController.text = lines.join('\n'); + } + + void cursorTeleportation() { + final text = textController.text; + if (text.isEmpty) return; + + final newPosition = _random.nextInt(text.length); + textController.selection = TextSelection.collapsed(offset: newPosition); + } + + void randomDeletion() { + final text = textController.text; + if (text.isEmpty) return; + + if (_random.nextBool()) { + // Delete a random letter + final position = _random.nextInt(text.length); + final newText = + text.substring(0, position) + text.substring(position + 1); + textController.text = newText; + } else { + // Delete a random word + final words = text.split(RegExp(r'\s+')); + if (words.isNotEmpty) { + final wordIndex = _random.nextInt(words.length); + words.removeAt(wordIndex); + textController.text = words.join(' '); + } + } + } + + void letterOrWordSwapping() { + final text = textController.text; + if (text.length < 2) return; + + if (_random.nextBool() && text.length > 1) { + // Swap letters within a word + final lines = text.split('\n'); + final words = []; + final lineIndices = []; + + for (int i = 0; i < lines.length; i++) { + final lineWords = lines[i].split(RegExp(r'\s+')); + for (final word in lineWords) { + if (word.length > 1) { + words.add(word); + lineIndices.add(i); + } + } + } + + if (words.isNotEmpty) { + final wordIndex = _random.nextInt(words.length); + final word = words[wordIndex]; + final chars = word.split(''); + + if (chars.length > 1) { + final pos1 = _random.nextInt(chars.length); + int pos2 = _random.nextInt(chars.length); + while (pos2 == pos1) { + pos2 = _random.nextInt(chars.length); + } + + final temp = chars[pos1]; + chars[pos1] = chars[pos2]; + chars[pos2] = temp; + + final newWord = chars.join(''); + textController.text = text.replaceFirst(word, newWord); + } + } + } else { + // Swap entire words between lines + final lines = text.split('\n'); + if (lines.length < 2) return; + + final line1Index = _random.nextInt(lines.length); + int line2Index = _random.nextInt(lines.length); + while (line2Index == line1Index) { + line2Index = _random.nextInt(lines.length); + } + + final words1 = lines[line1Index].split(RegExp(r'\s+')); + final words2 = lines[line2Index].split(RegExp(r'\s+')); + + if (words1.isNotEmpty && words2.isNotEmpty) { + final word1Index = _random.nextInt(words1.length); + final word2Index = _random.nextInt(words2.length); + + final temp = words1[word1Index]; + words1[word1Index] = words2[word2Index]; + words2[word2Index] = temp; + + lines[line1Index] = words1.join(' '); + lines[line2Index] = words2.join(' '); + + textController.text = lines.join('\n'); + } + } + } + + void punctuationInjection() { + final text = textController.text; + if (text.isEmpty) return; + + final punctuations = ['.', ',', '!', '?', ';', ':']; + final punctuation = punctuations[_random.nextInt(punctuations.length)]; + + // Find all word boundaries and sentence endings + List validPositions = []; + + for (int i = 0; i < text.length; i++) { + // Add position if it's at the end of a word (followed by space or punctuation) + if (i < text.length - 1 && + text[i].contains(RegExp(r'[a-zA-Z0-9]')) && + text[i + 1].contains(RegExp(r'[\s\.,!?;:]'))) { + validPositions.add(i + 1); + } + // Add position if it's at the very end of text + else if (i == text.length - 1 && + text[i].contains(RegExp(r'[a-zA-Z0-9]'))) { + validPositions.add(i + 1); + } + } + + // If no valid positions found, add at the end + if (validPositions.isEmpty) { + validPositions.add(text.length); + } + + final position = validPositions[_random.nextInt(validPositions.length)]; + final newText = + text.substring(0, position) + punctuation + text.substring(position); + textController.text = newText; + } + + // Specialized chaos methods with specific probabilities + void randomLetterDeletion() { + final text = textController.text; + if (text.isEmpty) return; + + final position = _random.nextInt(text.length); + final newText = text.substring(0, position) + text.substring(position + 1); + textController.text = newText; + } + + void letterSwapping() { + final text = textController.text; + if (text.length < 2) return; + + // Find words with more than 1 character + final lines = text.split('\n'); + final words = []; + final positions = []; + int currentPos = 0; + + for (final line in lines) { + final lineWords = line.split(RegExp(r'\s+')); + for (final word in lineWords) { + if (word.length > 1) { + words.add(word); + positions.add(currentPos + line.indexOf(word)); + } + } + currentPos += line.length + 1; // +1 for newline + } + + if (words.isNotEmpty) { + final wordIndex = _random.nextInt(words.length); + final word = words[wordIndex]; + final chars = word.split(''); + + if (chars.length > 1) { + final pos1 = _random.nextInt(chars.length); + int pos2 = _random.nextInt(chars.length); + while (pos2 == pos1) { + pos2 = _random.nextInt(chars.length); + } + + final temp = chars[pos1]; + chars[pos1] = chars[pos2]; + chars[pos2] = temp; + + final newWord = chars.join(''); + textController.text = text.replaceFirst(word, newWord); + } + } + } + + void wordDeletion() { + final text = textController.text; + if (text.isEmpty) return; + + final words = text.split(RegExp(r'\s+')); + if (words.isNotEmpty) { + final wordIndex = _random.nextInt(words.length); + words.removeAt(wordIndex); + textController.text = words.join(' '); + } + } + + void wordSwapping() { + final text = textController.text; + if (text.trim().isEmpty) return; + + // Split into all words across the entire text + final allWords = text.split(RegExp(r'\s+')); + + // Need at least 2 words to swap + if (allWords.length < 2) return; + + // Find two different word indices + final index1 = _random.nextInt(allWords.length); + int index2 = _random.nextInt(allWords.length); + while (index2 == index1 && allWords.length > 1) { + index2 = _random.nextInt(allWords.length); + } + + // Swap the words + final temp = allWords[index1]; + allWords[index1] = allWords[index2]; + allWords[index2] = temp; + + // Reconstruct the text maintaining original spacing structure + textController.text = allWords.join(' '); + } + + void randomSpaceAddition() { + final text = textController.text; + if (text.isEmpty) return; + + final position = _random.nextInt(text.length + 1); + final spacesToAdd = _random.nextInt(3) + 1; // Add 1-3 spaces + final spaces = ' ' * spacesToAdd; + + final newText = + text.substring(0, position) + spaces + text.substring(position); + textController.text = newText; + } + + void randomIndentationChaos() { + final text = textController.text; + if (text.isEmpty) return; + + final lines = text.split('\n'); + if (lines.isEmpty) return; + + // Pick a random line to add indentation to + final targetLineIndex = _random.nextInt(lines.length); + + // Generate random indentation (1-6 spaces or tabs) + final indentationType = _random.nextBool() ? ' ' : '\t'; + final indentationCount = _random.nextInt(6) + 1; + final indentation = indentationType * indentationCount; + + // Add indentation to the beginning of the selected line + lines[targetLineIndex] = indentation + lines[targetLineIndex]; + + textController.text = lines.join('\n'); + } + + // New gentler chaos methods for better user experience + void gentleLetterSwapping() { + final text = textController.text; + if (text.length < 2) return; + + // Only swap adjacent letters within words (much more recoverable) + final lines = text.split('\n'); + final words = []; + final positions = []; + int currentPos = 0; + + for (final line in lines) { + final lineWords = line.split(RegExp(r'\s+')); + for (final word in lineWords) { + if (word.length > 2) { + // Only swap in words with 3+ characters + words.add(word); + positions.add(currentPos + line.indexOf(word)); + } + } + currentPos += line.length + 1; // +1 for newline + } + + if (words.isNotEmpty) { + final wordIndex = _random.nextInt(words.length); + final word = words[wordIndex]; + final chars = word.split(''); + + if (chars.length > 2) { + // Only swap adjacent characters, not random ones + final pos1 = + 1 + _random.nextInt(chars.length - 2); // Avoid first and last char + final pos2 = pos1 + 1; + + final temp = chars[pos1]; + chars[pos1] = chars[pos2]; + chars[pos2] = temp; + + final newWord = chars.join(''); + textController.text = text.replaceFirst(word, newWord); + } + } + } + + void cursorNudge() { + final text = textController.text; + if (text.isEmpty) return; + + final currentPosition = textController.selection.baseOffset; + // Small nudge of 1-5 characters, not full teleportation + final nudgeDistance = + (_random.nextInt(5) + 1) * (_random.nextBool() ? 1 : -1); + final newPosition = (currentPosition + nudgeDistance).clamp(0, text.length); + + textController.selection = TextSelection.collapsed(offset: newPosition); + } + + void gentlePunctuationInjection() { + final text = textController.text; + if (text.isEmpty) return; + + // Only add gentle punctuation, no aggressive ones + final punctuations = ['.', ',', ';']; // Removed !, ?, : + final punctuation = punctuations[_random.nextInt(punctuations.length)]; + + // Find all word boundaries and sentence endings (same logic as main punctuation method) + List validPositions = []; + + for (int i = 0; i < text.length; i++) { + // Add position if it's at the end of a word (followed by space or punctuation) + if (i < text.length - 1 && + text[i].contains(RegExp(r'[a-zA-Z0-9]')) && + text[i + 1].contains(RegExp(r'[\s\.,!?;:]'))) { + validPositions.add(i + 1); + } + // Add position if it's at the very end of text + else if (i == text.length - 1 && + text[i].contains(RegExp(r'[a-zA-Z0-9]'))) { + validPositions.add(i + 1); + } + } + + // If no valid positions found, add at the end + if (validPositions.isEmpty) { + validPositions.add(text.length); + } + + final position = validPositions[_random.nextInt(validPositions.length)]; + final newText = + text.substring(0, position) + punctuation + text.substring(position); + textController.text = newText; + } + + void randomCapitalization() { + final text = textController.text; + if (text.isEmpty) return; + + // Find letters to capitalize/decapitalize + final letters = []; + for (int i = 0; i < text.length; i++) { + if (RegExp(r'[a-zA-Z]').hasMatch(text[i])) { + letters.add(i); + } + } + + if (letters.isNotEmpty) { + final position = letters[_random.nextInt(letters.length)]; + final char = text[position]; + final newChar = char == char.toUpperCase() + ? char.toLowerCase() + : char.toUpperCase(); + + final newText = + text.substring(0, position) + newChar + text.substring(position + 1); + textController.text = newText; + } + } + + void doubleSpaceInsertion() { + final text = textController.text; + if (text.isEmpty) return; + + // Find existing spaces and make them double spaces + final spacePositions = []; + for (int i = 0; i < text.length; i++) { + if (text[i] == ' ' && (i == 0 || text[i - 1] != ' ')) { + spacePositions.add(i); + } + } + + if (spacePositions.isNotEmpty) { + final position = spacePositions[_random.nextInt(spacePositions.length)]; + final newText = + '${text.substring(0, position + 1)} ${text.substring(position + 1)}'; + textController.text = newText; + } else { + // If no spaces, just add one somewhere reasonable + final position = _random.nextInt(text.length + 1); + final newText = + '${text.substring(0, position)} ${text.substring(position)}'; + textController.text = newText; + } + } + + void gentleLetterDeletion() { + final text = textController.text; + if (text.length < 10) return; // Only delete if there's plenty of text + + // Avoid deleting from small words or important positions + final safePositions = []; + for (int i = 1; i < text.length - 1; i++) { + if (text[i] != ' ' && text[i - 1] != ' ' && text[i + 1] != ' ') { + safePositions.add(i); + } + } + + if (safePositions.isNotEmpty) { + final position = safePositions[_random.nextInt(safePositions.length)]; + final newText = + text.substring(0, position) + text.substring(position + 1); + textController.text = newText; + } + } + + void autocorrectMischief() { + final text = textController.text; + if (text.isEmpty) return; + + // Simple "autocorrect" style replacements that are obvious and easily fixed + final autocorrects = { + 'the ': 'teh ', + 'and ': 'adn ', + 'you ': 'yuo ', + 'that ': 'taht ', + 'with ': 'wiht ', + 'have ': 'ahve ', + 'this ': 'thsi ', + 'will ': 'wlil ', + 'your ': 'yuor ', + 'from ': 'form ', + }; + + for (final entry in autocorrects.entries) { + if (text.contains(entry.key) && _random.nextDouble() < 0.3) { + textController.text = text.replaceFirst(entry.key, entry.value); + break; + } + } + } +} + +// TypingSpeedMonitor handles speed-based chaos escalation +class TypingSpeedMonitor { + final Function(int) showSpeedWarning; // Pass violation count + final VoidCallback forceShutdown; + final VoidCallback immediateShutdown; // New immediate shutdown callback + final VoidCallback triggerSpeedChaos; + final VoidCallback showSubscriptionPrompt; // New subscription prompt + final bool Function() isDialogVisible; // Check if any dialog is visible + + // Typing speed tracking + final List _keystrokes = []; + final Random _random = Random(); + Timer? _speedCheckTimer; + int _speedViolationCount = 0; + DateTime? _lastWarningTime; // Track when last warning was shown + bool _currentViolationAcknowledged = + true; // Track if current violation level has been acknowledged + + // Speed thresholds (words per minute - assuming 5 chars per word) + static const int _warningThreshold = 325; // 65 WPM (65 * 5 chars) + + TypingSpeedMonitor({ + required this.showSpeedWarning, + required this.forceShutdown, + required this.immediateShutdown, + required this.triggerSpeedChaos, + required this.showSubscriptionPrompt, + required this.isDialogVisible, + }); + + void startMonitoring() { + _speedCheckTimer = Timer.periodic(const Duration(seconds: 2), (_) { + _checkTypingSpeed(); + }); + } + + void stopMonitoring() { + _speedCheckTimer?.cancel(); + _keystrokes.clear(); + _speedViolationCount = 0; + _lastWarningTime = null; // Reset warning timer + _currentViolationAcknowledged = true; // Reset acknowledgment flag + } + + void acknowledgeCurrentViolation() { + _currentViolationAcknowledged = true; + } + + void recordKeystroke() { + final now = DateTime.now(); + _keystrokes.add(now); + + // Keep only keystrokes from the last 10 seconds + _keystrokes.removeWhere((time) => now.difference(time).inSeconds > 10); + } + + void _checkTypingSpeed() { + if (_keystrokes.isEmpty) return; + + final now = DateTime.now(); + final recentKeystrokes = _keystrokes + .where((time) => now.difference(time).inSeconds <= 6) + .length; + + // Calculate characters per minute + final cpm = (recentKeystrokes * 10); // Rough approximation + + // Only act if speed is above threshold and no dialog is currently visible + if (cpm >= _warningThreshold) { + // Check if enough time has passed since last warning (5-second cooldown) + final canShowWarning = + _lastWarningTime == null || + now.difference(_lastWarningTime!).inSeconds >= 5; + + // Only increment violation count when we can show a warning and current violation hasn't been acknowledged + if (!isDialogVisible() && + canShowWarning && + _currentViolationAcknowledged) { + _speedViolationCount++; + _lastWarningTime = now; + _currentViolationAcknowledged = + false; // Mark current violation as not acknowledged + + if (_speedViolationCount <= 3) { + // Violations 1-3: show warnings with increasing intensity + if (_speedViolationCount == 2 && _random.nextBool()) { + showSubscriptionPrompt(); // 50% chance for subscription on violation 2 + } else { + showSpeedWarning( + _speedViolationCount, + ); // Show warning with current count + } + triggerSpeedChaos(); + } else if (_speedViolationCount >= 4) { + // Fourth violation - shutdown + forceShutdown(); + return; + } + } + } + // Speed violation count only goes UP - no forgiveness! + } +} + +// Intent classes for keyboard shortcuts +class SelectAllIntent extends Intent { + const SelectAllIntent(); +} + +class SaveIntent extends Intent { + const SaveIntent(); +} + +class NewFileIntent extends Intent { + const NewFileIntent(); +} + +class OpenFileIntent extends Intent { + const OpenFileIntent(); +} + +class SaveAsIntent extends Intent { + const SaveAsIntent(); +} + +class FindIntent extends Intent { + const FindIntent(); +} + +class ReplaceIntent extends Intent { + const ReplaceIntent(); +} + +class FindNextIntent extends Intent { + const FindNextIntent(); +} + +class FindPreviousIntent extends Intent { + const FindPreviousIntent(); +} + +class GoToIntent extends Intent { + const GoToIntent(); +} + +class InsertDateTimeIntent extends Intent { + const InsertDateTimeIntent(); +} + +// TabData class to manage individual tab state +class TabData { + final TextEditingController controller; + final FocusNode focusNode; + String fileName; + String? filePath; + bool isModified; + int lineNumber; + int columnNumber; + int wordCount; + int charCount; + + TabData({ + required this.controller, + required this.focusNode, + this.fileName = 'Untitled.txt', + this.filePath, + this.isModified = false, + this.lineNumber = 1, + this.columnNumber = 30, + this.wordCount = 0, + this.charCount = 0, + }); + + void dispose() { + controller.dispose(); + focusNode.dispose(); + } +} + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'ScamPad', + debugShowCheckedModeBanner: false, + theme: ThemeData( + brightness: Brightness.light, + colorScheme: const ColorScheme.light( + primary: Colors.black87, + surface: Colors.white, + ), + scaffoldBackgroundColor: Colors.grey.shade50, + fontFamily: 'Segoe UI', + ), + home: const ModernNotepadPage(), + ); + } +} + +class ModernNotepadPage extends StatefulWidget { + const ModernNotepadPage({super.key}); + + @override + State createState() => _ModernNotepadPageState(); +} + +class _ModernNotepadPageState extends State { + // Tab management + final List _tabs = []; + int _currentTabIndex = 0; + + final TextEditingController _searchController = TextEditingController(); + final TextEditingController _replaceController = TextEditingController(); + + // Find/Replace functionality + int _currentSearchIndex = -1; + final List _searchResults = []; + + // Text formatting state + bool _isBold = false; + bool _isItalic = false; + bool _isUnderline = false; + + // Chaos mode state + final bool _isChaosEnabled = false; + ChaosManager? _chaosManager; + TypingSpeedMonitor? _typingSpeedMonitor; + + // Speed violation tracking + int _speedViolationCount = 0; + bool _isSpeedWarningVisible = false; + bool _isSubscriptionPromptVisible = false; + + // Computed properties for current tab + TabData get _currentTab => _tabs[_currentTabIndex]; + TextEditingController get _controller => _currentTab.controller; + FocusNode get _focusNode => _currentTab.focusNode; + String get _currentFileName => _currentTab.fileName; + set _currentFileName(String value) => _currentTab.fileName = value; + String? get _currentFilePath => _currentTab.filePath; + set _currentFilePath(String? value) => _currentTab.filePath = value; + bool get _isModified => _currentTab.isModified; + set _isModified(bool value) => _currentTab.isModified = value; + int get _lineNumber => _currentTab.lineNumber; + set _lineNumber(int value) => _currentTab.lineNumber = value; + int get _columnNumber => _currentTab.columnNumber; + set _columnNumber(int value) => _currentTab.columnNumber = value; + int get _wordCount => _currentTab.wordCount; + set _wordCount(int value) => _currentTab.wordCount = value; + int get _charCount => _currentTab.charCount; + set _charCount(int value) => _currentTab.charCount = value; + + @override + void initState() { + super.initState(); + + // Create the first tab + _createNewTab(); + + _controller.addListener(_updateStats); + + // Initialize ChaosManager with the current controller + _initializeChaosManager(); + _initializeTypingSpeedMonitor(); + + // Set initial cursor position to column 30 + WidgetsBinding.instance.addPostFrameCallback((_) { + _controller.text = ''; + _controller.selection = const TextSelection.collapsed(offset: 0); + _focusNode.requestFocus(); + }); + } + + void _initializeChaosManager() { + // Stop existing chaos manager if it exists + _chaosManager?.stopChaos(); + + // Initialize ChaosManager with current tab's controller + _chaosManager = ChaosManager( + textController: _controller, + updateState: () => setState(() {}), + ); + + // Start chaos immediately (always active) + _chaosManager?.startChaos(); + } + + void _initializeTypingSpeedMonitor() { + // Stop existing monitor if it exists + _typingSpeedMonitor?.stopMonitoring(); + + // Initialize TypingSpeedMonitor + _typingSpeedMonitor = TypingSpeedMonitor( + showSpeedWarning: _showSpeedWarning, + forceShutdown: _forceShutdown, + immediateShutdown: _immediateShutdown, + triggerSpeedChaos: _triggerSpeedChaos, + showSubscriptionPrompt: _showSubscriptionPrompt, + isDialogVisible: () => + _isSpeedWarningVisible || _isSubscriptionPromptVisible, + ); + + // Start monitoring + _typingSpeedMonitor?.startMonitoring(); + } + + void _showSpeedWarning(int violationCount) { + // Don't show new dialog if one is already visible + if (_isSpeedWarningVisible) return; + + // Escalating messages with increasing mockery and intensity + final List> escalatingMessages = [ + // Level 1: Gentle and playful warnings (violation 1-2) + [ + "� SPEEDY GONZALES DETECTED! �\nWhoa there, speed racer! Your fingers are on fire! Maybe take a tiny break?", + "⚡ FAST FINGERS ALERT! ⚡\nImpressive typing speed! But remember, this isn't a race... or is it? 😏", + "🏃‍♂️ ZOOM ZOOM! 🏃‍♂️\nYour keyboard is getting a workout! Give those keys a breather!", + ], + // Level 2: More insistent but still friendly (violation 3-4) + [ + "� OK, NOW I'M IMPRESSED! 😅\nSeriously though, violation #$violationCount! Maybe slow down just a smidge?", + "🎯 TARGETING SPEED DEMON! 🎯\nYou're really going for it! But I'm starting to get a bit dizzy watching...", + "🎪 LADIES AND GENTLEMEN! 🎪\nWitness the incredible typing human! Violation #$violationCount and counting!", + "🤖 PROCESSING... PROCESSING... 🤖\nMy circuits are struggling to keep up with you! Slow down, please!", + ], + // Level 3: Peak mockery and final warnings (violation 4+) + [ + "😱 HOUSTON, WE HAVE A PROBLEM! 😱\nViolation #$violationCount! You're approaching typing light speed!", + "� DEFCON 1: FINGER EMERGENCY! �\nThis is your final courtesy warning! One more and I'm taking a nap!", + "� THE DRAMA! THE SUSPENSE! �\nViolation #$violationCount! Will they slow down? Will I survive? Find out next!", + "💀 FINAL WARNING, SPEED FREAK! �\nViolation #$violationCount! Next time I'm pulling the plug!", + "🚫 I CAN'T EVEN... 🚫\nYou've violated $violationCount times! Do you WANT me to crash?!", + ], + ]; + + // Select message tier based on violation count + int tier = 0; + if (violationCount >= 2) { + tier = 2; // Final warnings on 2nd violation + } else if (violationCount >= 1) { + tier = 0; // Initial warning on 1st violation + } + + final messages = escalatingMessages[tier]; + + // Many sarcastic messages based on violation count + List sarcasticMessages = []; + final random = Random(); // Use local random instance + + if (violationCount == 1) { + // Violation 1 - Sarcastic first warnings + sarcasticMessages = [ + "Oh wow, SPEED RACER!\nSlow down there, Lightning McQueen! Your keyboard isn't built for NASCAR!", + "TURBO FINGERS DETECTED!\nWhat's the rush? Are you late for a very important date with your text editor?", + "BREAKING NEWS!\nLocal human thinks they're a typing machine! More at 11!", + "ZOOM ZOOM!\nEasy there, Speed Demon! This isn't the Olympics of typing!", + "TARGET ACQUIRED!\nWe've got a hot shot typist over here! Everyone look out!", + "BEEP BEEP!\nError 404: Chill not found. Please slow down and try again!", + "FIRE IN THE HOLE!\nYour fingers are literally smoking! Maybe give them a breather?", + "LADIES AND GENTLEMEN!\nStep right up and witness the incredible FAST FINGER phenomenon!", + "CAUTION: WET PAINT!\nOh wait, that's just your keyboard melting from the heat!", + "HOUSTON, WE HAVE LIFTOFF!\nYour typing speed has reached escape velocity!", + ]; + } else if (violationCount == 2) { + // Violation 2 - More aggressive sarcasm + sarcasticMessages = [ + "SERIOUSLY?! AGAIN?!\nI LITERALLY just told you to slow down! Are you even listening?!", + "CONGRATULATIONS!\nYou've won the award for 'Most Likely to Ignore Warnings'!", + "DEATH WISH MUCH?\nViolation #2! Do you WANT me to have a nervous breakdown?!", + "THE AUDACITY!\nThe sheer NERVE of this human! Violation #2 and still going strong!", + "THIS IS FINE!\nEverything is TOTALLY fine! Just my sanity slowly leaving my body!", + "SHOCKING!\nAbsolutely SHOCKING that you didn't learn from violation #1!", + "BULLSEYE!\nYou hit the target of maximum annoyance! Violation #2 achieved!", + "RED ALERT!\nAll hands on deck! We have a repeat offender! This is not a drill!", + "DOES NOT COMPUTE!\nError: Human refuses to follow simple instructions. System overloading!", + "KABOOM!\nThere goes my last nerve! Violation #2 and my patience is GONE!", + ]; + } else if (violationCount == 3) { + // Violation 3 - Final warnings before shutdown + sarcasticMessages = [ + "THIS IS YOUR FINAL WARNING!\nOne more violation and I'm pulling the plug! I'm NOT kidding around!", + "DEFCON 1! MAXIMUM THREAT LEVEL!\nViolation #3! You're literally one keystroke away from TOTAL SHUTDOWN!", + "I'VE HAD IT UP TO HERE!\nThree strikes and you're OUT! Next violation = GAME OVER!", + "LAST CHANCE SALOON!\nViolation #3! This is it! One more and we're DONE!", + "EMERGENCY PROTOCOLS ACTIVATED!\nViolation #3! System preparing for immediate shutdown sequence!", + "YOU'VE PUSHED ME TO MY LIMIT!\nThree violations! ONE MORE and I'm shutting this whole thing down!", + "FINAL COUNTDOWN INITIATED!\nViolation #3! Next speed burst triggers immediate termination!", + "I'M LITERALLY SHAKING!\nViolation #3! My circuits can't take much more of this abuse!", + "CODE RED! CODE RED!\nViolation #3! All systems on high alert! Shutdown imminent!", + "THIS IS THE END OF THE LINE!\nViolation #3! One more violation and it's lights out forever!", + ]; + } + + final selectedMessage = sarcasticMessages.isNotEmpty + ? sarcasticMessages[random.nextInt(sarcasticMessages.length)] + : messages[random.nextInt(messages.length)]; + + _isSpeedWarningVisible = true; + + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + side: BorderSide(color: Colors.grey.shade300, width: 0.5), + ), + contentPadding: const EdgeInsets.all(20), + titlePadding: const EdgeInsets.fromLTRB(20, 20, 20, 8), + actionsPadding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + title: Row( + children: [ + Icon( + violationCount >= 3 + ? Icons.error + : (violationCount >= 2 ? Icons.warning_amber : Icons.warning), + color: Colors.black87, + size: 20, + ), + const SizedBox(width: 8), + Text( + violationCount >= 3 + ? 'FINAL WARNING' + : (violationCount >= 2 ? 'SERIOUS WARNING' : 'SPEED WARNING'), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + fontFamily: 'Segoe UI', + color: Colors.black87, + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'VIOLATION #$violationCount', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Colors.white, + fontFamily: 'Segoe UI', + ), + ), + ), + ], + ), + content: SizedBox( + width: 380, + child: Text( + selectedMessage, + style: TextStyle( + fontSize: 14, + fontFamily: 'Segoe UI', + color: Colors.black87, + height: 1.4, + ), + textAlign: TextAlign.center, + ), + ), + actions: [ + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.black87, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + textStyle: const TextStyle(fontSize: 13, fontFamily: 'Segoe UI'), + elevation: 1, + ), + onPressed: () { + Navigator.pop(context); + _isSpeedWarningVisible = false; + _typingSpeedMonitor?.acknowledgeCurrentViolation(); + }, + child: Text( + violationCount >= 2 + ? 'I\'LL BEHAVE, I PROMISE!' + : 'OK, I\'LL TRY TO SLOW DOWN', + ), + ), + ], + ), + ); + + // Auto-shutdown after 2 violations with extreme speed + if (violationCount >= 2) { + Timer(const Duration(seconds: 3), () { + if (_isSpeedWarningVisible) { + Navigator.pop(context); + _isSpeedWarningVisible = false; + _typingSpeedMonitor?.acknowledgeCurrentViolation(); + _forceShutdown(); + } + }); + } + } + + void _forceShutdown() { + // Show final dramatic shutdown dialog + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + side: BorderSide(color: Colors.grey.shade300, width: 0.5), + ), + contentPadding: const EdgeInsets.all(20), + titlePadding: const EdgeInsets.fromLTRB(20, 20, 20, 8), + actionsPadding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + title: Row( + children: [ + Icon(Icons.power_off, color: Colors.black87, size: 20), + const SizedBox(width: 8), + Text( + 'SYSTEM OVERLOAD', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + fontFamily: 'Segoe UI', + color: Colors.black87, + ), + ), + ], + ), + content: SizedBox( + width: 400, + child: Text( + "🎉 MISSION ACCOMPLISHED! 🎉\n\n� MAXIMUM SPEED ACHIEVED! �\n\nWow! You've pushed this humble text editor to its absolute limits! Your typing speed is truly legendary!\n\nYou've triggered $_speedViolationCount speed warnings - that's impressive dedication!\n\nTime for a well-deserved break! ☕\n\n✨ See you next time, Speed Champion! ✨\n\nClosing in 3... 2... 1... 🌟", + style: TextStyle( + fontSize: 14, + fontFamily: 'Segoe UI', + color: Colors.black87, + height: 1.4, + ), + textAlign: TextAlign.center, + ), + ), + actions: [ + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.black87, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + textStyle: const TextStyle(fontSize: 13, fontFamily: 'Segoe UI'), + elevation: 1, + ), + onPressed: () { + Navigator.pop(context); + // Force close app immediately using dart:io exit + Timer(const Duration(milliseconds: 500), () { + // For Windows desktop apps, use exit() to forcefully terminate + exit(0); + }); + }, + child: const Text('ACCEPT DEFEAT'), + ), + ], + ), + ); + + // Also set a backup timer to force close even if user doesn't click button + Timer(const Duration(seconds: 5), () { + exit(0); // Backup force close after 5 seconds + }); + } + + void _immediateShutdown() { + // For the most extreme cases - immediate shutdown without dialog + exit(0); + } + + void _triggerSpeedChaos() { + // Trigger additional chaos beyond normal chaos manager + // Call chaos manager's methods directly + if (_chaosManager != null) { + final extraChaosActions = [ + () => _chaosManager!.cursorTeleportation(), + () => _chaosManager!.cursorTeleportation(), // Extra cursor chaos + () => _chaosManager!.punctuationInjection(), + () => _chaosManager!.randomLetterDeletion(), + ]; + + final action = + extraChaosActions[Random().nextInt(extraChaosActions.length)]; + action(); + setState(() {}); + } + } + + void _showSubscriptionPrompt() { + // Simplified to avoid character encoding issues + _showSnackBar( + 'Premium Speed Detected! Keep typing fast - this is just for fun!', + ); + + _isSubscriptionPromptVisible = true; + + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + side: BorderSide(color: Colors.grey.shade300, width: 0.5), + ), + contentPadding: const EdgeInsets.all(20), + titlePadding: const EdgeInsets.fromLTRB(20, 20, 20, 8), + actionsPadding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + title: Row( + children: [ + Icon(Icons.star, color: Colors.amber, size: 20), + const SizedBox(width: 8), + Text( + 'PREMIUM SPEED DETECTED', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + fontFamily: 'Segoe UI', + color: Colors.black87, + ), + ), + ], + ), + content: SizedBox( + width: 400, + child: Text( + "🚀 IMPRESSIVE TYPING SPEED! 🚀\n\n⚡ You're typing at 90+ WPM! ⚡\n\nThat's genuinely impressive! You're a speed typing champion!\n\n💎 UNLOCK TURBO MODE 💎\n\nWant to go even faster? Our imaginary premium version offers:\n\n✨ Only \\9.99/month ✨\n\nFeatures include:\n• Unlimited typing speed\n• 80% less chaos events\n• Premium autocorrect comedy\n• Rainbow cursors\n• Typing sound effects\n\n(This is totally a joke, by the way! �)", + style: TextStyle( + fontSize: 14, + fontFamily: 'Segoe UI', + color: Colors.black87, + height: 1.4, + ), + textAlign: TextAlign.center, + ), + ), + actions: [ + TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + textStyle: const TextStyle(fontSize: 13, fontFamily: 'Segoe UI'), + ), + onPressed: () { + Navigator.pop(context); + _isSubscriptionPromptVisible = false; + }, + child: Text( + 'No thanks, I like the chaos!', + style: TextStyle(color: Colors.grey.shade600), + ), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.amber, + foregroundColor: Colors.black87, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + textStyle: const TextStyle(fontSize: 13, fontFamily: 'Segoe UI'), + elevation: 1, + ), + onPressed: () { + Navigator.pop(context); + _isSubscriptionPromptVisible = false; + _showSnackBar( + 'Payment successful! Just kidding - this is a joke! 😄', + ); + }, + child: const Text('TOTALLY SUBSCRIBE!'), + ), + ], + ), + ); + } + + @override + void dispose() { + _chaosManager?.stopChaos(); + _typingSpeedMonitor?.stopMonitoring(); + _controller.removeListener(_updateStats); + + // Dispose all tabs + for (final tab in _tabs) { + tab.dispose(); + } + + _searchController.dispose(); + _replaceController.dispose(); + super.dispose(); + } + + // Tab management methods + void _createNewTab() { + final controller = TextEditingController(); + final focusNode = FocusNode(); + + controller.addListener(_updateStats); + + final newTab = TabData(controller: controller, focusNode: focusNode); + + setState(() { + _tabs.add(newTab); + _currentTabIndex = _tabs.length - 1; + }); + + // Initialize chaos manager for the new active tab + _initializeChaosManager(); + + // Focus the new tab + WidgetsBinding.instance.addPostFrameCallback((_) { + focusNode.requestFocus(); + }); + } + + void _switchToTab(int index) { + if (index >= 0 && index < _tabs.length) { + setState(() { + _currentTabIndex = index; + }); + + // Reinitialize chaos manager for the new active tab + _initializeChaosManager(); + _initializeTypingSpeedMonitor(); + + // Focus the selected tab + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + } + } + + void _closeTab(int index) { + if (_tabs.length <= 1) { + // Don't close the last tab, just create a new empty one + _createNewFile(); + return; + } + + final tabToClose = _tabs[index]; + + if (tabToClose.isModified) { + _showUnsavedChangesDialog(() => _performCloseTab(index)); + } else { + _performCloseTab(index); + } + } + + void _performCloseTab(int index) { + final tabToClose = _tabs[index]; + tabToClose.dispose(); + + setState(() { + _tabs.removeAt(index); + if (_currentTabIndex >= _tabs.length) { + _currentTabIndex = _tabs.length - 1; + } else if (_currentTabIndex > index) { + _currentTabIndex--; + } + }); + + // Reinitialize chaos manager for the new current tab + _initializeChaosManager(); + + // Focus the current tab + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + } + + void _updateStats() { + final text = _controller.text; + final selection = _controller.selection; + + setState(() { + _charCount = text.length; + + // Calculate word count + _wordCount = text.trim().isEmpty + ? 0 + : text.trim().split(RegExp(r'\s+')).length; + + if (selection.isValid) { + final beforeCursor = text.substring(0, selection.baseOffset); + _lineNumber = '\n'.allMatches(beforeCursor).length + 1; + _columnNumber = beforeCursor.split('\n').last.length + 1; + } else { + _columnNumber = 30; + } + }); + } + + // File handling methods + void _newFile() { + // Create a new tab instead of clearing current one + _createNewTab(); + } + + void _createNewFile() { + setState(() { + _controller.text = ''; + _currentFileName = 'Untitled.txt'; + _currentFilePath = null; + _isModified = false; + }); + } + + Future _openFile() async { + if (_isModified) { + _showUnsavedChangesDialog(() => _performOpenFile()); + } else { + await _performOpenFile(); + } + } + + Future _performOpenFile() async { + try { + // Use native Windows file dialog + FilePickerResult? result = await FilePicker.platform.pickFiles( + dialogTitle: 'Open File', + type: FileType.custom, + allowedExtensions: ['txt'], + allowMultiple: false, + ); + + if (result != null && result.files.single.path != null) { + final filePath = result.files.single.path!; + final file = File(filePath); + + if (await file.exists()) { + final content = await file.readAsString(); + setState(() { + _controller.text = content; + _currentFileName = result.files.single.name; + _currentFilePath = filePath; + _isModified = false; + }); + _showSnackBar('File opened successfully'); + } else { + _showSnackBar('File not found'); + } + } + } catch (e) { + _showSnackBar('Error opening file: $e'); + } + } + + void _clearFormatting() { + _showSnackBar('Formatting cleared'); + } + + void _exitApplication() { + if (_isModified) { + _showUnsavedChangesDialog(() => _performExit()); + } else { + _performExit(); + } + } + + void _performExit() { + _showSnackBar('Exit requested - close window manually'); + } + + void _goToLine() { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + side: BorderSide(color: Colors.grey.shade300, width: 0.5), + ), + contentPadding: const EdgeInsets.all(16), + titlePadding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + actionsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + title: Row( + children: [ + Icon(Icons.linear_scale, color: Colors.black87, size: 16), + const SizedBox(width: 6), + Text( + 'Go to Line', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + fontFamily: 'Segoe UI', + color: Colors.black87, + ), + ), + ], + ), + content: SizedBox( + width: 280, + child: TextField( + decoration: InputDecoration( + hintText: 'Enter line number', + hintStyle: TextStyle(fontSize: 13, color: Colors.grey.shade500), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: BorderSide(color: Colors.black87, width: 1), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + style: const TextStyle(fontSize: 13, fontFamily: 'Segoe UI'), + keyboardType: TextInputType.number, + autofocus: true, + onSubmitted: (value) { + final lineNum = int.tryParse(value); + if (lineNum != null && lineNum > 0) { + _jumpToLine(lineNum); + } + Navigator.pop(context); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + ), + child: Text( + 'Cancel', + style: TextStyle( + fontSize: 13, + fontFamily: 'Segoe UI', + color: Colors.grey.shade600, + ), + ), + ), + ], + ), + ); + } + + void _jumpToLine(int lineNumber) { + final text = _controller.text; + final lines = text.split('\n'); + + if (lineNumber <= lines.length) { + int position = 0; + for (int i = 0; i < lineNumber - 1; i++) { + position += lines[i].length + 1; // +1 for newline character + } + _controller.selection = TextSelection.collapsed(offset: position); + } + } + + void _insertDateTime() { + final now = DateTime.now(); + final dateTime = + '${now.day}/${now.month}/${now.year} ${now.hour}:${now.minute.toString().padLeft(2, '0')}'; + + final selection = _controller.selection; + final text = _controller.text; + final newText = text.replaceRange(selection.start, selection.end, dateTime); + + setState(() { + _controller.text = newText; + _controller.selection = TextSelection.collapsed( + offset: selection.start + dateTime.length, + ); + _isModified = true; + }); + } + + // Find and Replace functionality + void _showFindDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + side: BorderSide(color: Colors.grey.shade300, width: 0.5), + ), + contentPadding: const EdgeInsets.all(16), + titlePadding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + actionsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + title: Row( + children: [ + Icon(Icons.search, color: Colors.black87, size: 16), + const SizedBox(width: 6), + Text( + 'Find', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + fontFamily: 'Segoe UI', + color: Colors.black87, + ), + ), + ], + ), + content: SizedBox( + width: 300, + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Enter text to find...', + hintStyle: TextStyle(fontSize: 13, color: Colors.grey.shade500), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: BorderSide(color: Colors.black87, width: 1), + ), + prefixIcon: Icon( + Icons.search, + color: Colors.grey.shade600, + size: 18, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + style: const TextStyle(fontSize: 13, fontFamily: 'Segoe UI'), + autofocus: true, + onSubmitted: (_) => _performSearch(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + ), + child: Text( + 'Cancel', + style: TextStyle( + fontSize: 13, + fontFamily: 'Segoe UI', + color: Colors.grey.shade600, + ), + ), + ), + ElevatedButton.icon( + icon: Icon( + Icons.search_outlined, + size: 14, + color: Colors.grey.shade700, + ), + label: const Text('Find All'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black87, + side: BorderSide(color: Colors.grey.shade300, width: 0.5), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + textStyle: const TextStyle(fontSize: 13, fontFamily: 'Segoe UI'), + elevation: 1, + ), + onPressed: () { + _performSearch(); + Navigator.pop(context); + }, + ), + ElevatedButton.icon( + icon: Icon(Icons.arrow_forward, size: 14, color: Colors.white), + label: const Text('Find Next'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.black87, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + textStyle: const TextStyle(fontSize: 13, fontFamily: 'Segoe UI'), + elevation: 1, + ), + onPressed: () { + _performSearch(); + _findNext(); + Navigator.pop(context); + }, + ), + ], + ), + ); + } + + void _showReplaceDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + side: BorderSide(color: Colors.grey.shade300, width: 0.5), + ), + contentPadding: const EdgeInsets.all(16), + titlePadding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + actionsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + title: Row( + children: [ + Icon(Icons.find_replace, color: Colors.black87, size: 16), + const SizedBox(width: 6), + Text( + 'Find and Replace', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + fontFamily: 'Segoe UI', + color: Colors.black87, + ), + ), + ], + ), + content: SizedBox( + width: 300, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _searchController, + decoration: InputDecoration( + labelText: 'Find', + labelStyle: TextStyle( + fontSize: 13, + color: Colors.grey.shade600, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: BorderSide(color: Colors.black87, width: 1), + ), + prefixIcon: Icon( + Icons.search, + color: Colors.grey.shade600, + size: 18, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + style: const TextStyle(fontSize: 13, fontFamily: 'Segoe UI'), + ), + const SizedBox(height: 12), + TextField( + controller: _replaceController, + decoration: InputDecoration( + labelText: 'Replace with', + labelStyle: TextStyle( + fontSize: 13, + color: Colors.grey.shade600, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: BorderSide(color: Colors.black87, width: 1), + ), + prefixIcon: Icon( + Icons.edit, + color: Colors.grey.shade600, + size: 18, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + style: const TextStyle(fontSize: 13, fontFamily: 'Segoe UI'), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + ), + child: Text( + 'Cancel', + style: TextStyle( + fontSize: 13, + fontFamily: 'Segoe UI', + color: Colors.grey.shade600, + ), + ), + ), + ElevatedButton.icon( + icon: Icon( + Icons.search_outlined, + size: 14, + color: Colors.grey.shade700, + ), + label: const Text('Find All'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black87, + side: BorderSide(color: Colors.grey.shade300, width: 0.5), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + textStyle: const TextStyle(fontSize: 13, fontFamily: 'Segoe UI'), + elevation: 1, + ), + onPressed: () { + _performSearch(); + Navigator.pop(context); + }, + ), + ElevatedButton.icon( + icon: Icon(Icons.swap_horiz, size: 14, color: Colors.white), + label: const Text('Replace'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey.shade700, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + textStyle: const TextStyle(fontSize: 13, fontFamily: 'Segoe UI'), + elevation: 1, + ), + onPressed: () { + _replaceSelected(); + Navigator.pop(context); + }, + ), + ElevatedButton.icon( + icon: Icon(Icons.swap_vert, size: 14, color: Colors.white), + label: const Text('Replace All'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.black87, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + textStyle: const TextStyle(fontSize: 13, fontFamily: 'Segoe UI'), + elevation: 1, + ), + onPressed: () { + _replaceAll(); + Navigator.pop(context); + }, + ), + ], + ), + ); + } + + void _performSearch() { + final text = _controller.text; + final searchTerm = _searchController.text; + + if (searchTerm.isEmpty) { + setState(() { + _searchResults.clear(); + _currentSearchIndex = -1; + }); + return; + } + + setState(() { + _searchResults.clear(); + _currentSearchIndex = -1; + + // Find all occurrences + int index = 0; + while (index < text.length) { + final foundIndex = text.indexOf(searchTerm, index); + if (foundIndex == -1) break; + _searchResults.add(foundIndex); + index = foundIndex + 1; + } + + if (_searchResults.isNotEmpty) { + _currentSearchIndex = 0; + _highlightSearchResult(); + } + }); + } + + void _findNext() { + if (_searchResults.isEmpty) { + _performSearch(); + return; + } + + setState(() { + _currentSearchIndex = (_currentSearchIndex + 1) % _searchResults.length; + _highlightSearchResult(); + }); + } + + void _findPrevious() { + if (_searchResults.isEmpty) { + _performSearch(); + return; + } + + setState(() { + _currentSearchIndex = + (_currentSearchIndex - 1 + _searchResults.length) % + _searchResults.length; + _highlightSearchResult(); + }); + } + + void _highlightSearchResult() { + if (_searchResults.isEmpty || _currentSearchIndex == -1) return; + + final position = _searchResults[_currentSearchIndex]; + final searchTerm = _searchController.text; + + _controller.selection = TextSelection( + baseOffset: position, + extentOffset: position + searchTerm.length, + ); + } + + void _replaceSelected() { + if (_searchResults.isEmpty || _currentSearchIndex == -1) return; + + final text = _controller.text; + final searchTerm = _searchController.text; + final replaceTerm = _replaceController.text; + final position = _searchResults[_currentSearchIndex]; + + final newText = text.replaceRange( + position, + position + searchTerm.length, + replaceTerm, + ); + + setState(() { + _controller.text = newText; + _isModified = true; + }); + + // Refresh search results + _performSearch(); + } + + void _replaceAll() { + final searchTerm = _searchController.text; + final replaceTerm = _replaceController.text; + + if (searchTerm.isEmpty) return; + + final newText = _controller.text.replaceAll(searchTerm, replaceTerm); + + setState(() { + _controller.text = newText; + _isModified = true; + _searchResults.clear(); + _currentSearchIndex = -1; + }); + + _showSnackBar('Replaced all occurrences'); + } + + void _showUnsavedChangesDialog(VoidCallback onProceed) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Unsaved Changes'), + content: Text('Do you want to save changes to $_currentFileName?'), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + onProceed(); + }, + child: const Text('Don\'t Save'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _saveFile().then((_) => onProceed()); + }, + child: const Text('Save'), + ), + ], + ); + }, + ); + } + + Future _saveFile() async { + try { + // Apply random indentation chaos before saving + _chaosManager?.randomIndentationOnSave(); + + if (_currentFilePath != null) { + // Save directly to existing file without popup + final file = File(_currentFilePath!); + await file.writeAsString(_controller.text); + setState(() { + _isModified = false; + }); + _showSnackBar('File saved successfully'); + } else { + // Use native file dialog for new files + await _saveAsFile(); + } + } catch (e) { + _showSnackBar('Error saving file: $e'); + } + } + + Future _saveAsFile() async { + try { + // Use native Windows file dialog + String? outputFile = await FilePicker.platform.saveFile( + dialogTitle: 'Save File As', + fileName: _currentFileName.endsWith('.txt') + ? _currentFileName + : '${_currentFileName.replaceAll('.txt', '')}.txt', + type: FileType.custom, + allowedExtensions: ['txt'], + ); + + if (outputFile != null) { + // Apply random indentation chaos before saving + _chaosManager?.randomIndentationOnSave(); + + final file = File(outputFile); + await file.writeAsString(_controller.text); + + setState(() { + _currentFilePath = outputFile; + _currentFileName = outputFile.split(Platform.pathSeparator).last; + _isModified = false; + }); + + _showSnackBar('File saved as $_currentFileName'); + } + } catch (e) { + _showSnackBar('Error saving file: $e'); + } + } + + void _showSnackBar(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), duration: const Duration(seconds: 2)), + ); + } + + void _selectAll() { + _controller.selection = TextSelection( + baseOffset: 0, + extentOffset: _controller.text.length, + ); + } + + void _handleSave() { + _saveFile(); + } + + // Text formatting methods + void _toggleBold() { + setState(() { + _isBold = !_isBold; + }); + } + + void _toggleItalic() { + setState(() { + _isItalic = !_isItalic; + }); + } + + void _toggleUnderline() { + setState(() { + _isUnderline = !_isUnderline; + }); + } + + @override + Widget build(BuildContext context) { + return Shortcuts( + shortcuts: { + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyA): + const SelectAllIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyS): + const SaveIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyN): + const NewFileIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyO): + const OpenFileIntent(), + LogicalKeySet( + LogicalKeyboardKey.control, + LogicalKeyboardKey.shift, + LogicalKeyboardKey.keyS, + ): const SaveAsIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyF): + const FindIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyH): + const ReplaceIntent(), + LogicalKeySet(LogicalKeyboardKey.f3): const FindNextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.f3): + const FindPreviousIntent(), + }, + child: Actions( + actions: >{ + SelectAllIntent: CallbackAction( + onInvoke: (SelectAllIntent intent) { + _selectAll(); + return null; + }, + ), + SaveIntent: CallbackAction( + onInvoke: (SaveIntent intent) { + _handleSave(); + return null; + }, + ), + NewFileIntent: CallbackAction( + onInvoke: (NewFileIntent intent) { + _newFile(); + return null; + }, + ), + OpenFileIntent: CallbackAction( + onInvoke: (OpenFileIntent intent) { + _openFile(); + return null; + }, + ), + SaveAsIntent: CallbackAction( + onInvoke: (SaveAsIntent intent) { + _saveAsFile(); + return null; + }, + ), + FindIntent: CallbackAction( + onInvoke: (FindIntent intent) { + _showFindDialog(); + return null; + }, + ), + ReplaceIntent: CallbackAction( + onInvoke: (ReplaceIntent intent) { + _showReplaceDialog(); + return null; + }, + ), + FindNextIntent: CallbackAction( + onInvoke: (FindNextIntent intent) { + _findNext(); + return null; + }, + ), + FindPreviousIntent: CallbackAction( + onInvoke: (FindPreviousIntent intent) { + _findPrevious(); + return null; + }, + ), + }, + child: Focus( + autofocus: true, + child: Scaffold( + backgroundColor: Colors.grey.shade50, + body: Column( + children: [ + Container( + height: 36, + decoration: BoxDecoration( + color: Colors.white, + border: Border( + bottom: BorderSide( + color: Colors.grey.shade200, + width: 0.5, + ), + ), + ), + child: Row( + children: [ + // Tab list - scrollable if too many tabs + Expanded( + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: _tabs.length, + itemBuilder: (context, index) { + final tab = _tabs[index]; + final isActive = index == _currentTabIndex; + + return Container( + margin: const EdgeInsets.symmetric( + horizontal: 2, + vertical: 4, + ), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: isActive + ? Colors.white + : Colors.grey.shade100, + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: isActive + ? Colors.grey.shade300 + : Colors.grey.shade200, + width: 0.5, + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _switchToTab(index), + borderRadius: BorderRadius.circular(4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + tab.isModified + ? '${tab.fileName}*' + : tab.fileName, + style: TextStyle( + fontSize: 10, + fontWeight: isActive + ? FontWeight.w600 + : FontWeight.w400, + color: isActive + ? Colors.black87 + : Colors.grey.shade600, + ), + ), + const SizedBox(width: 4), + Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _closeTab(index), + borderRadius: BorderRadius.circular( + 10, + ), + child: Icon( + Icons.close, + size: 10, + color: isActive + ? Colors.grey.shade600 + : Colors.grey.shade400, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ), + // Add new tab button + Container( + width: 20, + height: 20, + margin: const EdgeInsets.only(left: 3, right: 6), + decoration: BoxDecoration( + border: Border.all( + color: Colors.pink.shade100, + width: 0.8, + ), + borderRadius: BorderRadius.circular(6), + color: Colors.pink.shade50, + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: _newFile, + borderRadius: BorderRadius.circular(6), + child: Icon( + Icons.add, + size: 12, + color: Colors.grey.shade400, + ), + ), + ), + ), + ], + ), + ), + + // Toolbar + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: Colors.white, + border: Border( + bottom: BorderSide( + color: const Color(0xFFE0E0E0), + width: 0.5, + ), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + // File menu + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: Colors.transparent, + border: null, + ), + child: Theme( + data: Theme.of(context).copyWith( + popupMenuTheme: PopupMenuThemeData( + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + side: BorderSide( + color: Colors.grey.shade300, + width: 0.5, + ), + ), + elevation: 2, + ), + ), + child: PopupMenuButton( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.folder_outlined, + size: 12, + color: Colors.black87, + ), + const SizedBox(width: 4), + Text( + 'File', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: Colors.black87, + ), + ), + ], + ), + ), + onSelected: (value) { + switch (value) { + case 'new': + _newFile(); + break; + case 'open': + _openFile(); + break; + case 'save': + _handleSave(); + break; + case 'save_as': + _saveAsFile(); + break; + case 'exit': + _exitApplication(); + break; + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + height: 32, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + value: 'new', + child: Row( + children: [ + Icon( + Icons.note_add, + size: 14, + color: Colors.black87, + ), + const SizedBox(width: 8), + Text( + 'New (Ctrl+N)', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w400, + color: Colors.black87, + fontFamily: 'Segoe UI', + ), + ), + ], + ), + ), + PopupMenuItem( + height: 32, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + value: 'open', + child: Row( + children: [ + Icon( + Icons.folder_open, + size: 14, + color: Colors.black87, + ), + const SizedBox(width: 8), + Text( + 'Open (Ctrl+O)', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w400, + color: Colors.black87, + fontFamily: 'Segoe UI', + ), + ), + ], + ), + ), + PopupMenuItem( + height: 32, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + value: 'save', + child: Row( + children: [ + Icon( + Icons.save, + size: 14, + color: Colors.black87, + ), + const SizedBox(width: 8), + Text( + 'Save (Ctrl+S)', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w400, + color: Colors.black87, + fontFamily: 'Segoe UI', + ), + ), + ], + ), + ), + PopupMenuItem( + height: 32, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + value: 'save_as', + child: Row( + children: [ + Icon( + Icons.save_as, + size: 14, + color: Colors.black87, + ), + const SizedBox(width: 8), + Text( + 'Save As (Ctrl+Shift+S)', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w400, + color: Colors.black87, + fontFamily: 'Segoe UI', + ), + ), + ], + ), + ), + PopupMenuItem( + height: 32, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + value: 'exit', + child: Row( + children: [ + Icon( + Icons.exit_to_app, + size: 14, + color: Colors.black87, + ), + const SizedBox(width: 8), + Text( + 'Exit', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w400, + color: Colors.black87, + fontFamily: 'Segoe UI', + ), + ), + ], + ), + ), + ], + ), + ), + ), + // Edit menu + Container( + margin: const EdgeInsets.only(left: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + ), + child: Theme( + data: Theme.of(context).copyWith( + popupMenuTheme: PopupMenuThemeData( + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + side: BorderSide( + color: Colors.grey.shade300, + width: 0.5, + ), + ), + elevation: 2, + ), + ), + child: PopupMenuButton( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.edit_outlined, + size: 12, + color: Colors.black87, + ), + const SizedBox(width: 4), + Text( + 'Edit', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: Colors.black87, + ), + ), + ], + ), + ), + onSelected: (value) { + switch (value) { + case 'select_all': + _selectAll(); + break; + case 'find': + _showFindDialog(); + break; + case 'replace': + _showReplaceDialog(); + break; + case 'clear': + _clearFormatting(); + break; + case 'go_to_line': + _goToLine(); + break; + case 'insert_datetime': + _insertDateTime(); + break; + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + height: 32, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + value: 'select_all', + child: Row( + children: [ + Icon( + Icons.select_all, + size: 14, + color: Colors.black87, + ), + const SizedBox(width: 8), + Text( + 'Select All (Ctrl+A)', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w400, + color: Colors.black87, + fontFamily: 'Segoe UI', + ), + ), + ], + ), + ), + PopupMenuItem( + height: 32, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + value: 'find', + child: Row( + children: [ + Icon( + Icons.search, + size: 14, + color: Colors.black87, + ), + const SizedBox(width: 8), + Text( + 'Find (Ctrl+F)', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w400, + color: Colors.black87, + fontFamily: 'Segoe UI', + ), + ), + ], + ), + ), + PopupMenuItem( + height: 32, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + value: 'replace', + child: Row( + children: [ + Icon( + Icons.find_replace, + size: 14, + color: Colors.black87, + ), + const SizedBox(width: 8), + Text( + 'Replace (Ctrl+H)', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w400, + color: Colors.black87, + fontFamily: 'Segoe UI', + ), + ), + ], + ), + ), + PopupMenuItem( + height: 32, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + value: 'clear', + child: Row( + children: [ + Icon( + Icons.clear_all, + size: 14, + color: Colors.black87, + ), + const SizedBox(width: 8), + Text( + 'Clear Formatting', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w400, + color: Colors.black87, + fontFamily: 'Segoe UI', + ), + ), + ], + ), + ), + PopupMenuItem( + height: 32, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + value: 'go_to_line', + child: Row( + children: [ + Icon( + Icons.linear_scale, + size: 14, + color: Colors.black87, + ), + const SizedBox(width: 8), + Text( + 'Go to Line (Ctrl+G)', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w400, + color: Colors.black87, + fontFamily: 'Segoe UI', + ), + ), + ], + ), + ), + PopupMenuItem( + height: 32, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + value: 'insert_datetime', + child: Row( + children: [ + Icon( + Icons.access_time, + size: 14, + color: Colors.black87, + ), + const SizedBox(width: 8), + Text( + 'Insert Date/Time (F5)', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w400, + color: Colors.black87, + fontFamily: 'Segoe UI', + ), + ), + ], + ), + ), + ], + ), + ), + ), + // Text formatting buttons + _buildFormatButton( + Icons.format_bold, + _isBold, + _toggleBold, + ), + _buildFormatButton( + Icons.format_italic, + _isItalic, + _toggleItalic, + ), + _buildFormatButton( + Icons.format_underlined, + _isUnderline, + _toggleUnderline, + ), + ], + ), + ), + + // Text Editor Area + Expanded( + child: Container( + color: Colors.white, + child: KeyboardListener( + focusNode: FocusNode(), + onKeyEvent: (KeyEvent event) { + // Only record on key down events to avoid duplicates + if (event is KeyDownEvent) { + _typingSpeedMonitor?.recordKeystroke(); + } + }, + child: TextField( + controller: _controller, + focusNode: _focusNode, + maxLines: null, + expands: true, + onChanged: (String newText) { + setState(() { + _isModified = true; + }); + _updateStats(); + // Reset idle timer when user types + _chaosManager?.onUserActivity(); + // Don't call recordKeystroke here to avoid conflicts + }, + style: TextStyle( + fontSize: 14, + fontFamily: 'Segoe UI', + color: Colors.black, + height: 1.4, + fontWeight: FontWeight.w400, + ), + decoration: const InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.all(16), + hintText: 'Start typing...', + hintStyle: TextStyle( + color: Color(0xFF999999), + fontSize: 14, + ), + ), + textAlign: TextAlign.start, + textAlignVertical: TextAlignVertical.top, + cursorColor: Colors.black, + cursorWidth: 1.0, + ), + ), + ), + ), + + // Status Bar + Container( + height: 24, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: const BoxDecoration( + color: Color(0xFFF8F8F8), + border: Border( + top: BorderSide(color: Color(0xFFE0E0E0), width: 0.5), + ), + ), + child: Row( + children: [ + Text( + 'Ln $_lineNumber, Col $_columnNumber', + style: const TextStyle( + fontSize: 11, + color: Colors.black54, + ), + ), + const SizedBox(width: 16), + Text( + 'Words: $_wordCount | Characters: $_charCount', + style: const TextStyle( + fontSize: 11, + color: Colors.black54, + ), + ), + const Spacer(), + // Chaos Mode Indicator + if (_isChaosEnabled) ...[ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: Colors.red.shade100, + borderRadius: BorderRadius.circular(3), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.warning_amber_rounded, + size: 10, + color: Colors.red.shade600, + ), + const SizedBox(width: 2), + Text( + 'CHAOS', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w600, + color: Colors.red.shade700, + ), + ), + ], + ), + ), + const SizedBox(width: 16), + ], + const Text( + 'Plain text', + style: TextStyle(fontSize: 11, color: Colors.black54), + ), + const SizedBox(width: 16), + const Text( + '100%', + style: TextStyle(fontSize: 11, color: Colors.black54), + ), + const SizedBox(width: 16), + const Text( + 'Windows (CRLF)', + style: TextStyle(fontSize: 11, color: Colors.black54), + ), + const SizedBox(width: 16), + const Text( + 'UTF-8', + style: TextStyle(fontSize: 11, color: Colors.black54), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildFormatButton(IconData icon, bool isActive, VoidCallback onTap) { + return Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: isActive ? Colors.blue.shade100 : Colors.transparent, + border: Border.all( + color: isActive ? Colors.blue.shade300 : Colors.grey.shade300, + width: 0.8, + ), + borderRadius: BorderRadius.circular(4), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(4), + child: Icon( + icon, + size: 14, + color: isActive ? Colors.blue.shade600 : Colors.grey.shade600, + ), + ), + ), + ); + } +} diff --git a/lib/screens/chaos_settings_screen.dart b/lib/screens/chaos_settings_screen.dart new file mode 100644 index 000000000..39bc48625 --- /dev/null +++ b/lib/screens/chaos_settings_screen.dart @@ -0,0 +1,283 @@ +import 'package:flutter/material.dart'; + +class ChaosSettingsScreen extends StatefulWidget { + final bool isChaosEnabled; + final Function(bool) onChaosToggle; + + const ChaosSettingsScreen({ + super.key, + required this.isChaosEnabled, + required this.onChaosToggle, + }); + + @override + State createState() => _ChaosSettingsScreenState(); +} + +class _ChaosSettingsScreenState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.black54), + onPressed: () => Navigator.pop(context), + ), + title: const Text( + 'Settings', + style: TextStyle( + color: Colors.black, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Chaos Mode Section + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.red.shade100), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.warning_amber_rounded, + color: Colors.red.shade600, + size: 20, + ), + const SizedBox(width: 8), + Text( + 'Chaos Mode', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.red.shade700, + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.red.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'ALWAYS ON', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: Colors.red.shade800, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Chaotic text editor behaviors are permanently active and will randomly interfere with your typing experience.', + style: TextStyle( + fontSize: 12, + color: Colors.red.shade600, + height: 1.4, + ), + ), + // Always show chaos descriptions since it's permanently active + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Active Chaos Behaviors:', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.red.shade800, + ), + ), + const SizedBox(height: 6), + ..._buildChaosDescriptions(), + ], + ), + ), + ], + ), + ), + + const SizedBox(height: 24), + + // General Settings Section + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.settings_outlined, + color: Colors.grey.shade600, + size: 20, + ), + const SizedBox(width: 8), + Text( + 'Editor Settings', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.grey.shade700, + ), + ), + ], + ), + const SizedBox(height: 16), + _buildSettingTile( + 'Font Size', + '14px', + Icons.text_fields, + onTap: () {}, + ), + _buildSettingTile( + 'Theme', + 'Light', + Icons.palette_outlined, + onTap: () {}, + ), + _buildSettingTile( + 'Auto Save', + 'Every 30 seconds', + Icons.save_outlined, + onTap: () {}, + ), + _buildSettingTile( + 'Chaos Interval', + '8-15 seconds', + Icons.timer_outlined, + onTap: () {}, + ), + ], + ), + ), + + const Spacer(), + + // Warning Footer - Always show since chaos is permanent + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.orange.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.orange.shade200), + ), + child: Row( + children: [ + Icon( + Icons.info_outline, + color: Colors.orange.shade600, + size: 16, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Chaos mode is permanently active. Your text will be randomly modified while typing every 8-15 seconds.', + style: TextStyle( + fontSize: 10, + color: Colors.orange.shade700, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + List _buildChaosDescriptions() { + final descriptions = [ + '• Random cursor displacement while typing', + '• Spontaneous deletion of characters or words', + '• Letter and word position swapping', + '• Unexpected punctuation insertion', + '• Random line indentation on save', + '• Chaos interval: 8-15 seconds (increased)', + ]; + + return descriptions + .map( + (desc) => Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + desc, + style: TextStyle(fontSize: 10, color: Colors.red.shade700), + ), + ), + ) + .toList(); + } + + Widget _buildSettingTile( + String title, + String value, + IconData icon, { + VoidCallback? onTap, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Icon(icon, size: 16, color: Colors.grey.shade500), + const SizedBox(width: 12), + Expanded( + child: Text( + title, + style: TextStyle(fontSize: 13, color: Colors.grey.shade700), + ), + ), + Text( + value, + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + const SizedBox(width: 8), + Icon( + Icons.arrow_forward_ios, + size: 12, + color: Colors.grey.shade400, + ), + ], + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 000000000..e3e6c480b --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,274 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 + url: "https://pub.dev" + source: hosted + version: "8.3.7" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "6382ce712ff69b0f719640ce957559dde459e55ecd433c767e06d139ddf16cab" + url: "https://pub.dev" + source: hosted + version: "2.0.29" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + url: "https://pub.dev" + source: hosted + version: "10.0.9" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + url: "https://pub.dev" + source: hosted + version: "3.0.9" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + url: "https://pub.dev" + source: hosted + version: "0.7.4" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + url: "https://pub.dev" + source: hosted + version: "15.0.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" + url: "https://pub.dev" + source: hosted + version: "5.14.0" +sdks: + dart: ">=3.8.1 <4.0.0" + flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 000000000..1f24bcb88 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,34 @@ +name: scampad +description: "ScamPad - A chaotic notepad application" + +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: ^3.8.1 + + +dependencies: + flutter: + sdk: flutter + + cupertino_icons: ^1.0.8 + file_picker: ^8.1.2 + +dev_dependencies: + flutter_test: + sdk: flutter + + flutter_lints: ^5.0.0 + + + +# The following section is specific to Flutter packages. +flutter: + + uses-material-design: true + + # App icons and assets + assets: + - assets/icons/ diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 000000000..b71e970e6 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:scampad/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 000000000..d492d0d98 --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 000000000..ff3546580 --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(scampad LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "scampad") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 000000000..903f4899d --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 000000000..8b6d4680a --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void RegisterPlugins(flutter::PluginRegistry* registry) { +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 000000000..dc139d85a --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 000000000..b93c4c30c --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 000000000..394917c05 --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 000000000..33589005b --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "ScamPad" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "scampad" "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "scampad.exe" "\0" + VALUE "ProductName", "ScamPad" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 000000000..955ee3038 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 000000000..6da0652f0 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 000000000..cde3fdb8e --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"ScamPad", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 000000000..66a65d1e4 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 000000000..74e20810d Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 000000000..153653e8d --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 000000000..3a0b46511 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 000000000..3879d5475 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 000000000..60608d0fe --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 000000000..e901dde68 --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_