import 'dart:async'; import 'dart:collection'; import 'dart:io'; import 'dart:math'; import 'dart:typed_data'; import 'dart:isolate'; import 'package:file_picker/file_picker.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'widgets/scrollview.dart' as custom; ThemeProvider _themeProvider = ThemeProvider(); void main() async { WidgetsFlutterBinding.ensureInitialized(); await _themeProvider.loadTheme(); runApp( MultiProvider( providers: [ ChangeNotifierProvider.value(value: _themeProvider), ], child: BinaryViewerApp(), ), ); } enum ThemesEnum { Light_Default(colorScheme: ColorSchemes.lightDefaultColor), Dark_Default(colorScheme: ColorSchemes.darkDefaultColor), Light_Blue(colorScheme: ColorSchemes.lightBlue), Dark_Blue(colorScheme: ColorSchemes.darkBlue), Light_Green(colorScheme: ColorSchemes.lightGreen), Dark_Green(colorScheme: ColorSchemes.darkGreen), Light_Orange(colorScheme: ColorSchemes.lightOrange), Dark_Orange(colorScheme: ColorSchemes.darkOrange), Light_Violet(colorScheme: ColorSchemes.lightViolet), Dark_Violet(colorScheme: ColorSchemes.darkViolet), Light_Rose(colorScheme: ColorSchemes.lightRose), Dark_Rose(colorScheme: ColorSchemes.darkRose); final ColorScheme colorScheme; const ThemesEnum({required this.colorScheme}); } class ThemeProvider extends ChangeNotifier { ThemesEnum _currentTheme = ThemesEnum.Light_Rose; ThemesEnum get currentTheme => _currentTheme; Future loadTheme() async { SharedPreferences prefs = await SharedPreferences.getInstance(); String? themeStr = prefs.getString('theme'); if (themeStr != null) { _currentTheme = ThemesEnum.values.firstWhere((theme) => theme.name == themeStr, orElse: () => ThemesEnum.Light_Rose); } else { _currentTheme = ThemesEnum.Light_Rose; } notifyListeners(); } void setTheme(ThemesEnum theme) async { _currentTheme = theme; SharedPreferences prefs = await SharedPreferences.getInstance(); await prefs.setString('theme', _currentTheme.name); notifyListeners(); } } class BinaryViewerApp extends StatelessWidget { const BinaryViewerApp({super.key}); @override Widget build(BuildContext context) { return Consumer( builder: (context, themeProvider, child) { print("Current theme: ${themeProvider.currentTheme.name}"); return ShadcnApp( title: 'Binary Viewer', theme: ThemeData( colorScheme: themeProvider.currentTheme.colorScheme, radius: 0.5, ), home: BinaryViewerScreen(), ); }, ); } } class BinaryViewerScreen extends StatefulWidget { @override State createState() => _BinaryViewerScreenState(); } enum ViewMode { hex, ascii, binary, integer, float, double, } enum ViewEndian { little, big, } class _BinaryViewerScreenState extends State { RandomAccessFile? _file; int _fileLength = 0; Isolate? _readerIsolate; SendPort? _readerSendPort; ReceivePort? _mainReceivePort; int _isolateId = 0; final Map> _pendingReads = {}; final Queue _readQueue = Queue(); Timer? _throttleTimer; Timer? _debounceTimer; static const int maxReadsPerSecond = 40; ViewEndian _leftEndian = ViewEndian.little; ViewMode _leftMode = ViewMode.hex; ViewEndian _rightEndian = ViewEndian.little; ViewMode _rightMode = ViewMode.ascii; final ScrollController _scrollController = ScrollController(); final ScrollController _horizontalScrollController = ScrollController(); int _firstVisibleRow = 0; int _lastVisibleRow = 0; static const int bufferRows = 10; final Map _rowCache = {}; @override void dispose() { _debounceTimer?.cancel(); _readerIsolate?.kill(priority: Isolate.immediate); _mainReceivePort?.close(); _throttleTimer?.cancel(); _scrollController.dispose(); _horizontalScrollController.dispose(); super.dispose(); } void _updateVisibleRows() { if (_scrollController.hasClients) { double offset = _scrollController.offset; double rowHeight = 35; // Updated row height based on user info int first = (offset / rowHeight).floor(); int last = ((offset + _scrollController.position.viewportDimension) / rowHeight).ceil(); int newFirst = max(0, first - bufferRows); int newLast = min((_fileLength / 16).ceil() - 1, last + bufferRows); if (newFirst != _firstVisibleRow || newLast != _lastVisibleRow) { _debounceTimer?.cancel(); _debounceTimer = Timer(const Duration(milliseconds: 50), () { print('setState: _firstVisibleRow=$newFirst, _lastVisibleRow=$newLast'); setState(() { _firstVisibleRow = newFirst; _lastVisibleRow = newLast; }); // Evict cache rows far outside the buffer final minKeep = max(0, newFirst - bufferRows * 2); final maxKeep = min((_fileLength / 16).ceil() - 1, newLast + bufferRows * 2); _rowCache.removeWhere((offset, _) { int row = offset ~/ 16; return row < minKeep || row > maxKeep; }); }); } } } @override void initState() { super.initState(); _scrollController.addListener(_updateVisibleRows); } Future _openFile(String path) async { RandomAccessFile file = await File(path).open(); int length = await File(path).length(); await _file?.close(); _readerIsolate?.kill(priority: Isolate.immediate); _mainReceivePort?.close(); _mainReceivePort = ReceivePort(); _isolateId++; final isolateId = _isolateId; _readerIsolate = await Isolate.spawn(_fileReaderIsolate, { 'sendPort': _mainReceivePort!.sendPort, 'path': path, 'isolateId': isolateId, }); _mainReceivePort!.listen((msg) { if (msg is SendPort) { _readerSendPort = msg; } else if (msg is Map && msg['isolateId'] == _isolateId) { int offset = msg['offset']; Uint8List bytes = msg['bytes']; final completer = _pendingReads.remove(offset); if (completer != null && !completer.isCompleted) { completer.complete(bytes); } } }); _throttleTimer?.cancel(); _throttleTimer = Timer.periodic(Duration(milliseconds: 1000 ~/ maxReadsPerSecond), (_) { if (_readQueue.isNotEmpty && _readerSendPort != null) { int offset = _readQueue.removeFirst(); final completer = _pendingReads[offset]; if (completer != null && !completer.isCompleted) { int bytesToRead = min(16, _fileLength - offset); if (bytesToRead > 0) { _readerSendPort!.send({ 'offset': offset, 'length': bytesToRead, 'isolateId': _isolateId, }); } else { completer.complete(Uint8List(0)); _pendingReads.remove(offset); } } } }); setState(() { _file = file; _fileLength = length; }); // Ensure initial visible rows are calculated and loaded WidgetsBinding.instance.addPostFrameCallback((_) { _updateVisibleRows(); }); } static void _fileReaderIsolate(Map args) async { final mainSendPort = args['sendPort'] as SendPort; final path = args['path'] as String; final isolateId = args['isolateId'] as int; final file = await File(path).open(); final port = ReceivePort(); mainSendPort.send(port.sendPort); await for (final msg in port) { if (msg is Map && msg['isolateId'] == isolateId) { int offset = msg['offset']; int length = msg['length']; file.setPositionSync(offset); Uint8List bytes = file.readSync(length); mainSendPort.send({ 'offset': offset, 'bytes': bytes, 'isolateId': isolateId, }); } } } Future _readBytes(int offset) async { if (_file == null) return Uint8List(0); if (_pendingReads.containsKey(offset)) { return _pendingReads[offset]!.future; } final completer = Completer(); _pendingReads[offset] = completer; _readQueue.add(offset); return completer.future; } int selectedAddress = -1; void _onAddressClicked(int address, int byteIndex) { int absoluteAddress = address + byteIndex; print('Address clicked: 0x${absoluteAddress.toRadixString(16).padLeft(8, '0').toUpperCase()}'); // TODO: Add your address click handling logic here setState(() { selectedAddress = absoluteAddress; }); } double _calculateViewModeWidth(ViewMode mode) { double width; switch (mode) { case ViewMode.hex: width = 16 * 32; break; case ViewMode.ascii: width = 16 * 24; break; case ViewMode.binary: width = 16 * 75; break; case ViewMode.integer: width = 4 * 96; break; case ViewMode.float: width = 4 * 190; break; case ViewMode.double: width = 2 * 190; break; } // Account for padding width += 20; return max(width, 330); } @override Widget build(BuildContext context) { double leftWidth = _calculateViewModeWidth(_leftMode); double rightWidth = _calculateViewModeWidth(_rightMode); return Scaffold( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Menubar( children: [ MenuButton( subMenu: [ MenuButton( child: const Text("Open"), onPressed: (context) async { FilePickerResult? result = await FilePicker.platform.pickFiles( initialDirectory: _file != null ? File(_file!.path).parent.path : null, ); if (result != null && result.files.isNotEmpty) { String path = result.files.first.path!; await _openFile(path); } }, ) ], child: const Text("File"), ), MenuButton( subMenu: [ MenuButton( subMenu: [ for (var theme in ThemesEnum.values) MenuButton( leading: Container( width: 16, height: 16, decoration: BoxDecoration( color: theme.colorScheme.primary, borderRadius: BorderRadius.circular(4), border: Border.all( color: theme.colorScheme.primaryForeground, width: 1, ), ), ), child: Text(theme.name.replaceAll('_', ' ')), onPressed: (context) { _themeProvider.setTheme(theme); }, ) ], child: const Text("Theme"), ), ], child: const Text("Options"), ) ], ).withMargin( horizontal: 10 ), Builder( builder: (context) { if (_file == null) { return Breadcrumb( children: [ const Text("No file opened"), ], ); } List parts = _file?.path.split(Platform.pathSeparator) ?? []; String fileName = parts.last; String parentDir = parts.length > 1 ? parts[parts.length - 2] : ""; return Breadcrumb( children: [ const MoreDots(), Text(parentDir), Text(fileName), ], ); } ) ], ), const SizedBox(height: 6), Expanded( child: OutlinedContainer( child: LayoutBuilder( builder: (context, constraints) { return custom.RawScrollbar( controller: _scrollController, thumbVisibility: true, trackVisibility: true, notificationPredicate: (notification) => notification.depth == 1, scrollbarMargin: EdgeInsets.only(left: 2, top: 2, right: 1), renderOffset: Offset(0, 56), radius: Radius.circular(10), thumbColor: _themeProvider._currentTheme.colorScheme.primaryForeground, thickness: 8, trackBorderColor: _themeProvider._currentTheme.colorScheme.border, trackColor: _themeProvider._currentTheme.colorScheme.background, child: custom.RawScrollbar( controller: _horizontalScrollController, thumbColor: _themeProvider._currentTheme.colorScheme.primaryForeground, thickness: 8, trackBorderColor: _themeProvider._currentTheme.colorScheme.border, trackColor: _themeProvider._currentTheme.colorScheme.background, trackVisibility: true, thumbVisibility: true, interactive: true, forceInteractive: true, radius: Radius.circular(10), scrollbarMargin: EdgeInsets.only(left: 2, top: 3, bottom: 2, right: 11), child: SingleChildScrollView( controller: _horizontalScrollController, scrollDirection: Axis.horizontal, physics: const ClampingScrollPhysics(), child: SizedBox( width: max(100 + leftWidth + rightWidth + 60, constraints.maxWidth), height: constraints.maxHeight, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ IntrinsicHeight( child: Row( children: [ SizedBox( width: 100, child: Center( child: Text( "Address", ).withMargin( all: 10 ), ), ), VerticalDivider(), SizedBox( width: leftWidth, child: Row( children: [ const SizedBox(width: 10), SizedBox( width: 200, child: Select( itemBuilder: (context, item) { return Text( item, ); }, value: _leftMode.name, onChanged: (value) { setState(() { _leftMode = ViewMode.values.firstWhere((mode) => mode.name == value); }); }, popup: SelectPopup( items: SelectItemList( children: [ for (var mode in ViewMode.values) SelectItemButton( value: mode.name, child: Text(mode.name), ) ] ) ), ).withMargin( vertical: 10 ), ), const SizedBox(width: 10), SizedBox( width: 100, child: Select( itemBuilder: (context, item) { return Text( item, ); }, value: _leftEndian.name, onChanged: (value) { setState(() { _leftEndian = ViewEndian.values.firstWhere((mode) => mode.name == value); }); }, popup: SelectPopup( items: SelectItemList( children: [ for (var mode in ViewEndian.values) SelectItemButton( value: mode.name, child: Text(mode.name), ) ] ) ), ).withMargin( vertical: 10 ), ), const SizedBox(width: 10), ], ), ), VerticalDivider(), SizedBox( width: rightWidth, child: Row( children: [ const SizedBox(width: 10), SizedBox( width: 200, child: Select( itemBuilder: (context, item) { return Text( item, ); }, value: _rightMode.name, onChanged: (value) { setState(() { _rightMode = ViewMode.values.firstWhere((mode) => mode.name == value); }); }, popup: SelectPopup( items: SelectItemList( children: [ for (var mode in ViewMode.values) SelectItemButton( value: mode.name, child: Text(mode.name), ) ] ) ), ).withMargin( vertical: 10 ), ), const SizedBox(width: 10), SizedBox( width: 100, child: Select( itemBuilder: (context, item) { return Text( item, ); }, value: _rightEndian.name, onChanged: (value) { setState(() { _rightEndian = ViewEndian.values.firstWhere((mode) => mode.name == value); }); }, popup: SelectPopup( items: SelectItemList( children: [ for (var mode in ViewEndian.values) SelectItemButton( value: mode.name, child: Text(mode.name), ) ] ) ), ).withMargin( vertical: 10 ), ), ], ), ), ], ), ), Divider(), if (_file != null) Expanded( child: ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith( scrollbars: false, overscroll: false, ), child: SizedBox( width: double.infinity, child: NotificationListener( onNotification: (scrollInfo) { _updateVisibleRows(); return false; }, child: SizedBox( width: max(100 + leftWidth + rightWidth + 60, MediaQuery.of(context).size.width - 20), child: ListView.builder( controller: _scrollController, itemCount: (_fileLength / 16).ceil() + 1, itemExtent: 35.0, // Match the actual row height cacheExtent: 2000.0, // Increase cache for smoother scrolling addAutomaticKeepAlives: false, // Reduce memory usage addRepaintBoundaries: true, // Improve repaint performance itemBuilder: (context, i) { String address = (i * 16).toRadixString(16).padLeft(8, '0').toUpperCase(); address = "0x$address"; if (i == (_fileLength / 16).ceil()) { return SizedBox( height: 35, child: Column( children: [ Divider(), Expanded( child: Center( child: Center(child: Text('End of File', style: TextStyle(fontSize: 14)).h4), ), ), const SizedBox(height: 12), ], ), ); } if (i < _firstVisibleRow || i > _lastVisibleRow) { return IntrinsicHeight( child: Row( children: [ SizedBox( width: 100, child: Center( child: Text( address, style: TextStyle(fontSize: 12), ).muted, ), ).withMargin(vertical: 10), VerticalDivider(), AspectRatio( aspectRatio: 1, child: CircularProgressIndicator().withMargin(all: 10) ) ], ), ); } if (_rowCache.containsKey(i * 16)) { // Row data is cached, build directly Uint8List bytes = _rowCache[i * 16]!; String address = (i * 16).toRadixString(16).padLeft(8, '0').toUpperCase(); address = "0x$address"; return IntrinsicHeight( child: Row( children: [ SizedBox( width: 100, child: Center( child: Text( address, style: TextStyle(fontSize: 12), ).muted, ), ).withMargin(vertical: 10), VerticalDivider(), SizedBox( width: leftWidth - 20, child: Row( children: [ ...getView(context, _leftMode, _leftEndian, bytes, i * 16, _onAddressClicked, selectedAddress), ], ), ).withMargin(horizontal: 10), VerticalDivider(), SizedBox( width: rightWidth - 20, child: Row( children: [ ...getView(context, _rightMode, _rightEndian, bytes, i * 16, _onAddressClicked, selectedAddress), ], ), ).withMargin(horizontal: 10), ], ), ); } // Not cached, use FutureBuilder return FutureBuilder( future: _readBytes(i * 16), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return IntrinsicHeight( child: Row( children: [ SizedBox( width: 100, child: Center( child: Text( address, style: TextStyle(fontSize: 12), ).muted, ), ).withMargin(vertical: 10), VerticalDivider(), AspectRatio( aspectRatio: 1, child: CircularProgressIndicator().withMargin(all: 10) ) ], ), ); } if (snapshot.hasError) { print('Error loading bytes for offset ${i * 16}: ${snapshot.error}'); return Padding( padding: const EdgeInsets.symmetric(vertical: 10), child: Center(child: Text('Error loading data', style: TextStyle(color: Colors.red))), ); } Uint8List bytes = snapshot.data ?? Uint8List(0); // Cache the loaded data _rowCache[i * 16] = bytes; return IntrinsicHeight( child: Row( children: [ SizedBox( width: 100, child: Center( child: Text( address, style: TextStyle(fontSize: 12), ).muted, ), ).withMargin(vertical: 10), VerticalDivider(), SizedBox( width: leftWidth - 20, child: Row( children: [ ...getView(context, _leftMode, _leftEndian, bytes, i * 16, _onAddressClicked, selectedAddress), ], ), ).withMargin(horizontal: 10), VerticalDivider(), SizedBox( width: rightWidth - 20, child: Row( children: [ ...getView(context, _rightMode, _rightEndian, bytes, i * 16, _onAddressClicked, selectedAddress), ], ), ).withMargin(horizontal: 10), ], ), ); }, ); }, ), ), ), ), ), ), ], ), ), ), ), ); }, ), ).withMargin( horizontal: 10 ), ), ], ).withMargin( vertical: 10 ), ); } } Widget _createViewButton(String text, int byteOffset, int baseAddress, Function(int, int) onAddressClick, int selectedAddress, {double? width, bool isBold = false, AlignmentGeometry? alignment}) { int currentAddress = baseAddress + byteOffset; bool isSelected = currentAddress == selectedAddress; Widget button; if (isSelected) { button = SizedBox( child: OutlinedContainer( padding: EdgeInsets.symmetric(horizontal: 7), borderRadius: BorderRadius.circular(8), height: 31, boxShadow: [ BoxShadow( color: _themeProvider.currentTheme.colorScheme.primary.withOpacity(0.5), blurRadius: 4, spreadRadius: 2, ), ], child: Align( alignment: alignment ?? Alignment.centerLeft, child: Text( text, style: TextStyle( fontSize: 12, fontWeight: isBold ? FontWeight.bold : null, ), ).mono, ), ), ); } else { button = GhostButton( onPressed: () => onAddressClick(baseAddress, byteOffset), density: ButtonDensity.icon, alignment: alignment ?? Alignment.centerLeft, child: Text( text, style: TextStyle( fontSize: 12, fontWeight: isBold ? FontWeight.bold : null, ), ).mono, ); } return width != null ? SizedBox(width: width, child: button) : button; } List getView(BuildContext context, ViewMode mode, ViewEndian endian, Uint8List bytes, int baseAddress, Function(int, int) onAddressClick, int selectedAddress) { List leftWidgets = []; if (mode == ViewMode.hex) { for (int i = 0; i < bytes.length; i++) { String byteStr = bytes[i].toRadixString(16).padLeft(2, '0').toUpperCase(); leftWidgets.add(_createViewButton( byteStr, i, baseAddress, onAddressClick, selectedAddress, width: 32, isBold: true)); } } else if (mode == ViewMode.ascii) { for (int i = 0; i < bytes.length; i++) { String char = bytes[i] >= 32 && bytes[i] <= 126 ? String.fromCharCode(bytes[i]) : '.'; leftWidgets.add(_createViewButton( char, i, baseAddress, onAddressClick, selectedAddress, width: 24)); } } else if (mode == ViewMode.binary) { for (int i = 0; i < bytes.length; i++) { String byteStr = bytes[i].toRadixString(2).padLeft(8, '0'); leftWidgets.add(_createViewButton( byteStr, i, baseAddress, onAddressClick, selectedAddress)); } } else if (mode == ViewMode.integer) { ByteData byteData = ByteData.sublistView(bytes); int maxIntegers = bytes.length ~/ 4; for (int j = 0; j < 4 && j < maxIntegers; j++) { if (j * 4 + 4 <= bytes.length) { int value = byteData.getInt32(j * 4, endian == ViewEndian.little ? Endian.little : Endian.big); String valueStr = value.toString().padLeft(11, ' '); leftWidgets.add(_createViewButton( valueStr, j * 4, baseAddress, onAddressClick, selectedAddress, alignment: Alignment.centerLeft)); } } } else if (mode == ViewMode.float) { ByteData byteData = ByteData.sublistView(bytes); int maxFloats = bytes.length ~/ 4; for (int j = 0; j < 4 && j < maxFloats; j++) { if (j * 4 + 4 <= bytes.length) { double value = byteData.getFloat32(j * 4, endian == ViewEndian.little ? Endian.little : Endian.big); String valueStr = value.toStringAsFixed(2); leftWidgets.add(_createViewButton( valueStr, j * 4, baseAddress, onAddressClick, selectedAddress, width: 190, alignment: Alignment.centerLeft)); } } } else if (mode == ViewMode.double) { ByteData byteData = ByteData.sublistView(bytes); int maxDoubles = bytes.length ~/ 8; for (int j = 0; j < 2 && j < maxDoubles; j++) { if (j * 8 + 8 <= bytes.length) { double value = byteData.getFloat64(j * 8, endian == ViewEndian.little ? Endian.little : Endian.big); String valueStr = value.toStringAsFixed(2); leftWidgets.add(_createViewButton(valueStr, j * 8, baseAddress, onAddressClick, selectedAddress, width: 190, alignment: Alignment.centerLeft)); } } } return leftWidgets; }