From 9eb71e94c12b29bb99d9dd5b393d89162a59e1cf Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Tue, 18 Mar 2025 23:51:37 +0100 Subject: [PATCH] Implement a popup card that renders description markdown --- lib/bbcode_converter.dart | 174 ++++++++++++++++++ lib/main.dart | 24 ++- lib/markdown_tooltip.dart | 372 ++++++++++++++++++++++++++++++++++++++ lib/mod_list.dart | 6 +- pubspec.lock | 18 +- pubspec.yaml | 1 + 6 files changed, 592 insertions(+), 3 deletions(-) create mode 100644 lib/bbcode_converter.dart create mode 100644 lib/markdown_tooltip.dart diff --git a/lib/bbcode_converter.dart b/lib/bbcode_converter.dart new file mode 100644 index 0000000..54bc6ff --- /dev/null +++ b/lib/bbcode_converter.dart @@ -0,0 +1,174 @@ +/// Utility class to convert BBCode to Markdown for RimWorld mod descriptions +class BBCodeConverter { + /// Converts BBCode formatted text to Markdown format + static String toMarkdown(String bbcode) { + if (bbcode.isEmpty) return ''; + + // First, normalize line endings + String result = bbcode.replaceAll('\r\n', '\n'); + + // Fix unclosed tags - RimWorld descriptions often have unclosed tags + final List tagTypes = ['b', 'i', 'color', 'size', 'url', 'code', 'quote']; + for (final tag in tagTypes) { + final openCount = '[${tag}'.allMatches(result).length; + final closeCount = '[/${tag}]'.allMatches(result).length; + if (openCount > closeCount) { + result = result + '[/${tag}]' * (openCount - closeCount); + } + } + + // URLs + // [url=http://example.com]text[/url] -> [text](http://example.com) + result = RegExp(r'\[url=([^\]]+)\](.*?)\[/url\]', dotAll: true) + .allMatches(result) + .fold(result, (prev, match) { + final url = match.group(1); + final text = match.group(2); + return prev.replaceFirst( + match.group(0)!, + '[$text]($url)' + ); + }); + + // Simple URL [url]http://example.com[/url] -> + result = result.replaceAllMapped( + RegExp(r'\[url\](.*?)\[/url\]', dotAll: true), + (match) => '<${match.group(1)}>' + ); + + // Bold + result = result.replaceAll('[b]', '**').replaceAll('[/b]', '**'); + + // Italic + result = result.replaceAll('[i]', '_').replaceAll('[/i]', '_'); + + // Headers + result = result.replaceAllMapped( + RegExp(r'\[h1\](.*?)\[/h1\]', dotAll: true), + (match) => '# ${match.group(1)}' + ); + result = result.replaceAllMapped( + RegExp(r'\[h2\](.*?)\[/h2\]', dotAll: true), + (match) => '## ${match.group(1)}' + ); + result = result.replaceAllMapped( + RegExp(r'\[h3\](.*?)\[/h3\]', dotAll: true), + (match) => '### ${match.group(1)}' + ); + + // Lists - handle nested lists too + result = result.replaceAll('[list]', '\n').replaceAll('[/list]', '\n'); + + // Handle list items - giving them proper indentation + int listLevel = 0; + result = result.replaceAllMapped( + RegExp(r'\[\*\](.*?)(?=\[\*\]|\[/list\]|$)', dotAll: true), + (match) { + final content = match.group(1)?.trim() ?? ''; + return '* $content\n'; + } + ); + + // Color - convert to bold since Markdown doesn't support color + result = result.replaceAllMapped( + RegExp(r'\[color=([^\]]+)\](.*?)\[/color\]', dotAll: true), + (match) { + final content = match.group(2) ?? ''; + if (content.trim().isEmpty) return ''; + return '**${match.group(2)}**'; + } + ); + + // Images + // [img]url[/img] -> ![Image](url) + result = result.replaceAllMapped( + RegExp(r'\[img\](.*?)\[/img\]', dotAll: true), + (match) => '![Image](${match.group(1)})' + ); + + // Image with size [img width=300]url[/img] -> ![Image](url) + result = result.replaceAllMapped( + RegExp(r'\[img[^\]]*width=(\d+)[^\]]*\](.*?)\[/img\]', dotAll: true), + (match) => '![Image](${match.group(2)})' + ); + + // Tables - convert tables to markdown tables + if (result.contains('[table]')) { + // Process tables + final tableRegex = RegExp(r'\[table\](.*?)\[/table\]', dotAll: true); + final tables = tableRegex.allMatches(result); + + for (final tableMatch in tables) { + final tableContent = tableMatch.group(1) ?? ''; + final rows = RegExp(r'\[tr\](.*?)\[/tr\]', dotAll: true).allMatches(tableContent); + + final markdownTable = StringBuffer(); + var isFirstRow = true; + + for (final rowMatch in rows) { + final rowContent = rowMatch.group(1) ?? ''; + final cells = RegExp(r'\[td\](.*?)\[/td\]', dotAll: true).allMatches(rowContent); + + if (cells.isEmpty) continue; + + final rowBuffer = StringBuffer('|'); + + for (final cellMatch in cells) { + final cellContent = cellMatch.group(1)?.trim() ?? ''; + // Clean up any newlines inside cell content + final cleanCell = cellContent.replaceAll('\n', ' ').trim(); + rowBuffer.write(' $cleanCell |'); + } + + markdownTable.writeln(rowBuffer.toString()); + + // Add header separator after first row + if (isFirstRow) { + final cellCount = RegExp(r'\[td\]').allMatches(rowContent).length; + markdownTable.writeln('|${' --- |' * cellCount}'); + isFirstRow = false; + } + } + + result = result.replaceFirst(tableMatch.group(0)!, '\n${markdownTable.toString()}\n'); + } + } + + // Size - remove since Markdown doesn't directly support font size + result = result.replaceAllMapped( + RegExp(r'\[size=[^\]]+\](.*?)\[/size\]', dotAll: true), + (match) => match.group(1) ?? '' + ); + + // Code + result = result.replaceAll('[code]', '\n```\n').replaceAll('[/code]', '\n```\n'); + + // Quote + result = result.replaceAllMapped( + RegExp(r'\[quote\](.*?)\[/quote\]', dotAll: true), + (match) { + final content = match.group(1)?.trim() ?? ''; + if (content.isEmpty) return ''; + return '\n> ${content.replaceAll('\n', '\n> ')}\n'; + } + ); + + // Handle any remaining custom BBCode tags - just remove them + result = result.replaceAllMapped( + RegExp(r'\[([a-zA-Z0-9_]+)(?:=[^\]]+)?\](.*?)\[/\1\]', dotAll: true), + (match) => match.group(2) ?? '' + ); + + // Handle RimWorld-specific patterns + // [h1] without closing tag is common + result = result.replaceAllMapped( + RegExp(r'\[h1\]([^\[]+)'), + (match) => '# ${match.group(1)}\n' + ); + + // Replace multiple newlines with at most two newlines + result = result.replaceAll(RegExp(r'\n{3,}'), '\n\n'); + + return result; + } +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 0987695..d87d18c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:rimworld_modman/logger.dart'; +import 'package:rimworld_modman/markdown_tooltip.dart'; import 'package:rimworld_modman/mod.dart'; import 'package:rimworld_modman/mod_list.dart'; import 'package:rimworld_modman/mod_troubleshooter_widget.dart'; @@ -741,6 +742,17 @@ class _ModManagerPageState extends State { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ + // Description tooltip + if (mod.description.isNotEmpty) + MarkdownTooltip( + markdownContent: mod.description, + child: Icon( + Icons.description_outlined, + color: Colors.lightBlue.shade300, + size: AppThemeExtension.of(context).iconSizeRegular, + ), + ), + const SizedBox(width: 4), if (mod.isBaseGame) Tooltip( message: 'Base Game', @@ -771,7 +783,6 @@ class _ModManagerPageState extends State { ).iconSizeRegular, ), ), - const SizedBox(width: 4), if (mod.dependencies.isNotEmpty) Tooltip( message: @@ -982,6 +993,17 @@ class _ModManagerPageState extends State { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ + // Description tooltip + if (mod.description.isNotEmpty) + MarkdownTooltip( + markdownContent: mod.description, + child: Icon( + Icons.description_outlined, + color: Colors.lightBlue.shade300, + size: AppThemeExtension.of(context).iconSizeRegular, + ), + ), + const SizedBox(width: 4), if (mod.isBaseGame) Tooltip( message: 'Base Game', diff --git a/lib/markdown_tooltip.dart b/lib/markdown_tooltip.dart new file mode 100644 index 0000000..a3a624d --- /dev/null +++ b/lib/markdown_tooltip.dart @@ -0,0 +1,372 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:rimworld_modman/bbcode_converter.dart'; + +/// A custom tooltip widget that can properly render mod descriptions in BBCode format. +class MarkdownTooltip extends StatefulWidget { + /// The markdown text to display in the tooltip. + final String markdownContent; + + /// The widget that will trigger the tooltip. + final Widget child; + + /// Optional preferred width for the tooltip. + final double preferredWidth; + + /// Optional maximum height for the tooltip. + final double maxHeight; + + /// Optional text style theme for the markdown. + final MarkdownStyleSheet? styleSheet; + + /// Creates a tooltip that displays markdown content when hovered. + const MarkdownTooltip({ + super.key, + required this.markdownContent, + required this.child, + this.preferredWidth = 500.0, + this.maxHeight = 500.0, + this.styleSheet, + }); + + @override + State createState() => _MarkdownTooltipState(); +} + +class _MarkdownTooltipState extends State { + OverlayEntry? _overlayEntry; + final LayerLink _layerLink = LayerLink(); + bool _isTooltipVisible = false; + bool _isMouseInside = false; + bool _isPointerListenerActive = false; + + @override + void dispose() { + _removeGlobalListener(); + _hideTooltip(); + super.dispose(); + } + + void _showTooltip() { + if (_isTooltipVisible || !mounted) return; + + try { + _overlayEntry = _createOverlayEntry(); + final overlay = Overlay.of(context); + if (overlay.mounted) { + overlay.insert(_overlayEntry!); + _isTooltipVisible = true; + _addGlobalListener(); + } + } catch (e) { + debugPrint('Error showing tooltip: $e'); + } + } + + void _hideTooltip() { + if (_overlayEntry != null) { + try { + _overlayEntry!.remove(); + } catch (e) { + debugPrint('Error removing overlay entry: $e'); + } + _overlayEntry = null; + _isTooltipVisible = false; + } + } + + void _addGlobalListener() { + if (!_isPointerListenerActive && mounted) { + _isPointerListenerActive = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + try { + GestureBinding.instance.pointerRouter.addGlobalRoute(_handleGlobalPointer); + } catch (e) { + debugPrint('Error adding global route: $e'); + } + } + }); + } + } + + void _removeGlobalListener() { + if (_isPointerListenerActive) { + try { + GestureBinding.instance.pointerRouter.removeGlobalRoute(_handleGlobalPointer); + } catch (e) { + debugPrint('Error removing global route: $e'); + } + _isPointerListenerActive = false; + } + } + + void _handleGlobalPointer(PointerEvent event) { + if (!mounted || _overlayEntry == null) { + _removeGlobalListener(); + return; + } + + if (event is PointerDownEvent) { + // Handle pointer down outside the tooltip + final RenderBox? box = context.findRenderObject() as RenderBox?; + if (box == null) return; + + final Offset position = box.localToGlobal(Offset.zero); + final Size size = box.size; + + // Check if tap is inside the trigger widget + final bool insideTrigger = event.position.dx >= position.dx && + event.position.dx <= position.dx + size.width && + event.position.dy >= position.dy && + event.position.dy <= position.dy + size.height; + + if (!insideTrigger) { + // Tap outside, hide tooltip + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _hideTooltip(); + _removeGlobalListener(); + } + }); + } + } + } + + OverlayEntry _createOverlayEntry() { + // Calculate render box positions + final RenderBox renderBox = context.findRenderObject() as RenderBox; + final Size size = renderBox.size; + final Offset offset = renderBox.localToGlobal(Offset.zero); + + // Get screen size + final Size screenSize = MediaQuery.of(context).size; + + // Calculate tooltip position, ensuring it stays on screen + double leftPosition = offset.dx - widget.preferredWidth * 0.5 + size.width * 0.5; + double topPosition = offset.dy + size.height + 5.0; // 5px below the widget + + // Adjust horizontal position if too close to screen edges + if (leftPosition < 10) { + leftPosition = 10; // 10px padding from left edge + } else if (leftPosition + widget.preferredWidth > screenSize.width - 10) { + leftPosition = screenSize.width - widget.preferredWidth - 10; // 10px padding from right edge + } + + // If tooltip would go below screen bottom, show it above the widget instead + bool showAbove = topPosition + widget.maxHeight > screenSize.height - 10; + if (showAbove) { + topPosition = offset.dy - widget.maxHeight - 5; // 5px above the widget + } + + // Create follower offset based on calculated position + final Offset followerOffset = showAbove + ? Offset(size.width * 0.5 - widget.preferredWidth * 0.5, -widget.maxHeight - 5) + : Offset(size.width * 0.5 - widget.preferredWidth * 0.5, size.height + 5.0); + + // Create a custom style sheet based on the dark theme + final ThemeData theme = Theme.of(context); + final MarkdownStyleSheet defaultStyleSheet = MarkdownStyleSheet.fromTheme(theme).copyWith( + p: const TextStyle( + color: Colors.white, + fontSize: 14.0, + ), + h1: const TextStyle( + color: Colors.white, + fontSize: 18.0, + fontWeight: FontWeight.bold, + ), + h2: const TextStyle( + color: Colors.white, + fontSize: 16.0, + fontWeight: FontWeight.bold, + ), + h3: const TextStyle( + color: Colors.white, + fontSize: 15.0, + fontWeight: FontWeight.bold, + ), + blockquote: const TextStyle( + color: Color(0xFFBBBBBB), + fontSize: 14.0, + fontStyle: FontStyle.italic, + ), + code: const TextStyle( + color: Color(0xFFE0E0E0), + fontSize: 13.0, + fontFamily: 'monospace', + backgroundColor: Color(0xFF505050), + ), + codeblockDecoration: BoxDecoration( + color: const Color(0xFF404040), + borderRadius: BorderRadius.circular(4.0), + ), + a: const TextStyle( + color: Color(0xFF8AB4F8), + decoration: TextDecoration.underline, + ), + listBullet: const TextStyle( + color: Colors.white, + fontSize: 14.0, + ), + strong: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + em: const TextStyle( + color: Colors.white, + fontStyle: FontStyle.italic, + ), + horizontalRuleDecoration: const BoxDecoration( + border: Border( + top: BorderSide( + width: 1.0, + color: Color(0xFF606060), + ), + ), + ), + ); + + final effectiveStyleSheet = widget.styleSheet ?? defaultStyleSheet; + + return OverlayEntry( + builder: (context) { + return Positioned( + left: leftPosition, + top: topPosition, + width: widget.preferredWidth, + child: CompositedTransformFollower( + link: _layerLink, + offset: followerOffset, + child: Material( + elevation: 8.0, + borderRadius: BorderRadius.circular(8.0), + color: Colors.transparent, + child: Container( + constraints: BoxConstraints( + maxHeight: widget.maxHeight, + ), + decoration: BoxDecoration( + color: const Color(0xFF2A3440), // Match app's dark theme card color + borderRadius: BorderRadius.circular(8.0), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 10, + offset: const Offset(0, 5), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Container( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + width: double.infinity, + color: const Color(0xFF3D4A59), // Match app's primary color + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Mod Description', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 14.0, + ), + ), + InkWell( + onTap: () { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _hideTooltip(); + _removeGlobalListener(); + } + }); + }, + child: const Padding( + padding: EdgeInsets.all(2.0), + child: Icon( + Icons.close, + color: Colors.white, + size: 16.0, + ), + ), + ), + ], + ), + ), + // Markdown content + Flexible( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: MarkdownBody( + data: widget.markdownContent.length > 5000 + ? BBCodeConverter.toMarkdown('${widget.markdownContent.substring(0, 5000)}...\n\n*[Description truncated due to length]*') + : BBCodeConverter.toMarkdown(widget.markdownContent), + styleSheet: effectiveStyleSheet, + shrinkWrap: true, + softLineBreak: true, + selectable: true, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return CompositedTransformTarget( + link: _layerLink, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + // Toggle tooltip visibility on tap (for mobile users) + if (_isTooltipVisible) { + _hideTooltip(); + _removeGlobalListener(); + } else { + _showTooltip(); + } + }, + child: MouseRegion( + onEnter: (_) { + _isMouseInside = true; + // Delay showing tooltip slightly to avoid accidental triggers + Future.delayed(const Duration(milliseconds: 100), () { + if (mounted && _isMouseInside && !_isTooltipVisible) { + _showTooltip(); + } + }); + }, + onExit: (_) { + _isMouseInside = false; + // Delay hiding tooltip slightly to allow moving to the tooltip itself + Future.delayed(const Duration(milliseconds: 100), () { + if (mounted && !_isMouseInside && _isTooltipVisible) { + _hideTooltip(); + _removeGlobalListener(); + } + }); + }, + child: widget.child, + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/mod_list.dart b/lib/mod_list.dart index f2092d9..09623a1 100644 --- a/lib/mod_list.dart +++ b/lib/mod_list.dart @@ -383,8 +383,12 @@ class ModList { final logger = Logger.instance; logger.info('Generating load order...'); - for (final mod in activeMods.values) { + for (var mod in activeMods.values) { logger.info('Checking mod: ${mod.id}'); + if (specialMods.containsKey(mod.id)) { + logger.info('Special mod: ${mod.id}'); + mod = specialMods[mod.id]!.copyWith(); + } logger.info('Mod details: ${mod.toString()}'); for (final incomp in mod.incompatibilities) { if (activeMods.containsKey(incomp)) { diff --git a/pubspec.lock b/pubspec.lock index d154636..ce647dc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -126,6 +126,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_markdown: + dependency: "direct main" + description: + name: flutter_markdown + sha256: "04c4722cc36ec5af38acc38ece70d22d3c2123c61305d555750a091517bbe504" + url: "https://pub.dev" + source: hosted + version: "0.6.23" flutter_test: dependency: "direct dev" description: flutter @@ -227,6 +235,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" + url: "https://pub.dev" + source: hosted + version: "7.3.0" matcher: dependency: transitive description: @@ -506,4 +522,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.7.2 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.19.0" diff --git a/pubspec.yaml b/pubspec.yaml index 31b88ab..c2e9850 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: xml: ^6.5.0 intl: ^0.20.2 path: ^1.9.1 + flutter_markdown: ^0.6.20 dev_dependencies: flutter_test: