579 lines
20 KiB
Python
579 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Fix Lipgloss layout issues in Bubble Tea applications.
|
|
Identifies hardcoded dimensions, incorrect calculations, overflow issues, etc.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Dict, List, Any, Tuple, Optional
|
|
|
|
|
|
def fix_layout_issues(code_path: str, description: str = "") -> Dict[str, Any]:
|
|
"""
|
|
Diagnose and fix common Lipgloss layout problems.
|
|
|
|
Args:
|
|
code_path: Path to Go file or directory
|
|
description: Optional user description of layout issue
|
|
|
|
Returns:
|
|
Dictionary containing:
|
|
- layout_issues: List of identified layout problems with fixes
|
|
- lipgloss_improvements: General recommendations
|
|
- code_fixes: Concrete code changes to apply
|
|
- validation: Validation report
|
|
"""
|
|
path = Path(code_path)
|
|
|
|
if not path.exists():
|
|
return {
|
|
"error": f"Path not found: {code_path}",
|
|
"validation": {"status": "error", "summary": "Invalid path"}
|
|
}
|
|
|
|
# Collect all .go files
|
|
go_files = []
|
|
if path.is_file():
|
|
if path.suffix == '.go':
|
|
go_files = [path]
|
|
else:
|
|
go_files = list(path.glob('**/*.go'))
|
|
|
|
if not go_files:
|
|
return {
|
|
"error": "No .go files found",
|
|
"validation": {"status": "error", "summary": "No Go files"}
|
|
}
|
|
|
|
# Analyze all files for layout issues
|
|
all_layout_issues = []
|
|
all_code_fixes = []
|
|
|
|
for go_file in go_files:
|
|
issues, fixes = _analyze_layout_issues(go_file)
|
|
all_layout_issues.extend(issues)
|
|
all_code_fixes.extend(fixes)
|
|
|
|
# Generate improvement recommendations
|
|
lipgloss_improvements = _generate_improvements(all_layout_issues)
|
|
|
|
# Summary
|
|
critical_count = sum(1 for i in all_layout_issues if i['severity'] == 'CRITICAL')
|
|
warning_count = sum(1 for i in all_layout_issues if i['severity'] == 'WARNING')
|
|
|
|
if critical_count > 0:
|
|
summary = f"🚨 Found {critical_count} critical layout issue(s)"
|
|
elif warning_count > 0:
|
|
summary = f"⚠️ Found {warning_count} layout issue(s) to address"
|
|
elif all_layout_issues:
|
|
summary = f"Found {len(all_layout_issues)} minor layout improvement(s)"
|
|
else:
|
|
summary = "✅ No major layout issues detected"
|
|
|
|
# Validation
|
|
validation = {
|
|
"status": "critical" if critical_count > 0 else "warning" if warning_count > 0 else "pass",
|
|
"summary": summary,
|
|
"checks": {
|
|
"no_hardcoded_dimensions": not any(i['type'] == 'hardcoded_dimensions' for i in all_layout_issues),
|
|
"proper_height_calc": not any(i['type'] == 'incorrect_height' for i in all_layout_issues),
|
|
"handles_padding": not any(i['type'] == 'missing_padding_calc' for i in all_layout_issues),
|
|
"handles_overflow": not any(i['type'] == 'overflow' for i in all_layout_issues)
|
|
}
|
|
}
|
|
|
|
return {
|
|
"layout_issues": all_layout_issues,
|
|
"lipgloss_improvements": lipgloss_improvements,
|
|
"code_fixes": all_code_fixes,
|
|
"summary": summary,
|
|
"user_description": description,
|
|
"files_analyzed": len(go_files),
|
|
"validation": validation
|
|
}
|
|
|
|
|
|
def _analyze_layout_issues(file_path: Path) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
"""Analyze a single Go file for layout issues."""
|
|
layout_issues = []
|
|
code_fixes = []
|
|
|
|
try:
|
|
content = file_path.read_text()
|
|
except Exception as e:
|
|
return layout_issues, code_fixes
|
|
|
|
lines = content.split('\n')
|
|
rel_path = file_path.name
|
|
|
|
# Check if file uses lipgloss
|
|
uses_lipgloss = bool(re.search(r'"github\.com/charmbracelet/lipgloss"', content))
|
|
|
|
if not uses_lipgloss:
|
|
return layout_issues, code_fixes
|
|
|
|
# Issue checks
|
|
issues, fixes = _check_hardcoded_dimensions(content, lines, rel_path)
|
|
layout_issues.extend(issues)
|
|
code_fixes.extend(fixes)
|
|
|
|
issues, fixes = _check_incorrect_height_calculations(content, lines, rel_path)
|
|
layout_issues.extend(issues)
|
|
code_fixes.extend(fixes)
|
|
|
|
issues, fixes = _check_missing_padding_accounting(content, lines, rel_path)
|
|
layout_issues.extend(issues)
|
|
code_fixes.extend(fixes)
|
|
|
|
issues, fixes = _check_overflow_issues(content, lines, rel_path)
|
|
layout_issues.extend(issues)
|
|
code_fixes.extend(fixes)
|
|
|
|
issues, fixes = _check_terminal_resize_handling(content, lines, rel_path)
|
|
layout_issues.extend(issues)
|
|
code_fixes.extend(fixes)
|
|
|
|
issues, fixes = _check_border_accounting(content, lines, rel_path)
|
|
layout_issues.extend(issues)
|
|
code_fixes.extend(fixes)
|
|
|
|
return layout_issues, code_fixes
|
|
|
|
|
|
def _check_hardcoded_dimensions(content: str, lines: List[str], file_path: str) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
"""Check for hardcoded width/height values."""
|
|
issues = []
|
|
fixes = []
|
|
|
|
# Pattern: .Width(80), .Height(24), etc.
|
|
dimension_pattern = r'\.(Width|Height|MaxWidth|MaxHeight)\s*\(\s*(\d{2,})\s*\)'
|
|
|
|
for i, line in enumerate(lines):
|
|
matches = re.finditer(dimension_pattern, line)
|
|
for match in matches:
|
|
dimension_type = match.group(1)
|
|
value = int(match.group(2))
|
|
|
|
# Likely a terminal dimension if >= 20
|
|
if value >= 20:
|
|
issues.append({
|
|
"severity": "WARNING",
|
|
"type": "hardcoded_dimensions",
|
|
"issue": f"Hardcoded {dimension_type}: {value}",
|
|
"location": f"{file_path}:{i+1}",
|
|
"current_code": line.strip(),
|
|
"explanation": f"Hardcoded {dimension_type} of {value} won't adapt to different terminal sizes",
|
|
"impact": "Layout breaks on smaller/larger terminals"
|
|
})
|
|
|
|
# Generate fix
|
|
if dimension_type in ["Width", "MaxWidth"]:
|
|
fixed_code = re.sub(
|
|
rf'\.{dimension_type}\s*\(\s*{value}\s*\)',
|
|
f'.{dimension_type}(m.termWidth)',
|
|
line.strip()
|
|
)
|
|
else: # Height, MaxHeight
|
|
fixed_code = re.sub(
|
|
rf'\.{dimension_type}\s*\(\s*{value}\s*\)',
|
|
f'.{dimension_type}(m.termHeight)',
|
|
line.strip()
|
|
)
|
|
|
|
fixes.append({
|
|
"location": f"{file_path}:{i+1}",
|
|
"original": line.strip(),
|
|
"fixed": fixed_code,
|
|
"explanation": f"Use dynamic terminal size from model (m.termWidth/m.termHeight)",
|
|
"requires": [
|
|
"Add termWidth and termHeight fields to model",
|
|
"Handle tea.WindowSizeMsg in Update()"
|
|
],
|
|
"code_example": '''// In model:
|
|
type model struct {
|
|
termWidth int
|
|
termHeight int
|
|
}
|
|
|
|
// In Update():
|
|
case tea.WindowSizeMsg:
|
|
m.termWidth = msg.Width
|
|
m.termHeight = msg.Height'''
|
|
})
|
|
|
|
return issues, fixes
|
|
|
|
|
|
def _check_incorrect_height_calculations(content: str, lines: List[str], file_path: str) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
"""Check for manual height calculations instead of lipgloss.Height()."""
|
|
issues = []
|
|
fixes = []
|
|
|
|
# Check View() function for manual calculations
|
|
view_start = -1
|
|
for i, line in enumerate(lines):
|
|
if re.search(r'func\s+\([^)]+\)\s+View\s*\(', line):
|
|
view_start = i
|
|
break
|
|
|
|
if view_start < 0:
|
|
return issues, fixes
|
|
|
|
# Look for manual arithmetic like "height - 5", "24 - headerHeight"
|
|
manual_calc_pattern = r'(height|Height|termHeight)\s*[-+]\s*\d+'
|
|
|
|
for i in range(view_start, min(view_start + 200, len(lines))):
|
|
if re.search(manual_calc_pattern, lines[i], re.IGNORECASE):
|
|
# Check if lipgloss.Height() is used in the vicinity
|
|
context = '\n'.join(lines[max(0, i-5):i+5])
|
|
uses_lipgloss_height = bool(re.search(r'lipgloss\.Height\s*\(', context))
|
|
|
|
if not uses_lipgloss_height:
|
|
issues.append({
|
|
"severity": "WARNING",
|
|
"type": "incorrect_height",
|
|
"issue": "Manual height calculation without lipgloss.Height()",
|
|
"location": f"{file_path}:{i+1}",
|
|
"current_code": lines[i].strip(),
|
|
"explanation": "Manual calculations don't account for actual rendered height",
|
|
"impact": "Incorrect spacing, overflow, or clipping"
|
|
})
|
|
|
|
# Generate fix
|
|
fixed_code = lines[i].strip().replace(
|
|
"height - ", "m.termHeight - lipgloss.Height("
|
|
).replace("termHeight - ", "m.termHeight - lipgloss.Height(")
|
|
|
|
fixes.append({
|
|
"location": f"{file_path}:{i+1}",
|
|
"original": lines[i].strip(),
|
|
"fixed": "Use lipgloss.Height() to get actual rendered height",
|
|
"explanation": "lipgloss.Height() accounts for padding, borders, margins",
|
|
"code_example": '''// ❌ BAD:
|
|
availableHeight := termHeight - 5 // Magic number!
|
|
|
|
// ✅ GOOD:
|
|
headerHeight := lipgloss.Height(m.renderHeader())
|
|
footerHeight := lipgloss.Height(m.renderFooter())
|
|
availableHeight := m.termHeight - headerHeight - footerHeight'''
|
|
})
|
|
|
|
return issues, fixes
|
|
|
|
|
|
def _check_missing_padding_accounting(content: str, lines: List[str], file_path: str) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
"""Check for nested styles without padding/margin accounting."""
|
|
issues = []
|
|
fixes = []
|
|
|
|
# Look for nested styles with padding
|
|
# Pattern: Style().Padding(X).Width(Y).Render(content)
|
|
nested_style_pattern = r'\.Padding\s*\([^)]+\).*\.Width\s*\(\s*(\w+)\s*\).*\.Render\s*\('
|
|
|
|
for i, line in enumerate(lines):
|
|
matches = re.finditer(nested_style_pattern, line)
|
|
for match in matches:
|
|
width_var = match.group(1)
|
|
|
|
# Check if GetHorizontalPadding is used
|
|
context = '\n'.join(lines[max(0, i-10):min(i+10, len(lines))])
|
|
uses_get_padding = bool(re.search(r'GetHorizontalPadding\s*\(\s*\)', context))
|
|
|
|
if not uses_get_padding and width_var != 'm.termWidth':
|
|
issues.append({
|
|
"severity": "CRITICAL",
|
|
"type": "missing_padding_calc",
|
|
"issue": "Padding not accounted for in nested width calculation",
|
|
"location": f"{file_path}:{i+1}",
|
|
"current_code": line.strip(),
|
|
"explanation": "Setting Width() then Padding() makes content area smaller than expected",
|
|
"impact": "Content gets clipped or wrapped incorrectly"
|
|
})
|
|
|
|
fixes.append({
|
|
"location": f"{file_path}:{i+1}",
|
|
"original": line.strip(),
|
|
"fixed": "Account for padding using GetHorizontalPadding()",
|
|
"explanation": "Padding reduces available content area",
|
|
"code_example": '''// ❌ BAD:
|
|
style := lipgloss.NewStyle().
|
|
Padding(2).
|
|
Width(80).
|
|
Render(text) // Text area is 76, not 80!
|
|
|
|
// ✅ GOOD:
|
|
style := lipgloss.NewStyle().Padding(2)
|
|
contentWidth := 80 - style.GetHorizontalPadding()
|
|
content := lipgloss.NewStyle().Width(contentWidth).Render(text)
|
|
result := style.Width(80).Render(content)'''
|
|
})
|
|
|
|
return issues, fixes
|
|
|
|
|
|
def _check_overflow_issues(content: str, lines: List[str], file_path: str) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
"""Check for potential text overflow."""
|
|
issues = []
|
|
fixes = []
|
|
|
|
# Check for long strings without wrapping
|
|
has_wordwrap = bool(re.search(r'"github\.com/muesli/reflow/wordwrap"', content))
|
|
has_wrap_or_truncate = bool(re.search(r'(wordwrap|truncate|Truncate)', content, re.IGNORECASE))
|
|
|
|
# Look for string rendering without width constraints
|
|
render_pattern = r'\.Render\s*\(\s*(\w+)\s*\)'
|
|
|
|
for i, line in enumerate(lines):
|
|
matches = re.finditer(render_pattern, line)
|
|
for match in matches:
|
|
var_name = match.group(1)
|
|
|
|
# Check if there's width control
|
|
has_width_control = bool(re.search(r'\.Width\s*\(', line))
|
|
|
|
if not has_width_control and not has_wrap_or_truncate and len(line) > 40:
|
|
issues.append({
|
|
"severity": "WARNING",
|
|
"type": "overflow",
|
|
"issue": f"Rendering '{var_name}' without width constraint",
|
|
"location": f"{file_path}:{i+1}",
|
|
"current_code": line.strip(),
|
|
"explanation": "Long content can exceed terminal width",
|
|
"impact": "Text wraps unexpectedly or overflows"
|
|
})
|
|
|
|
fixes.append({
|
|
"location": f"{file_path}:{i+1}",
|
|
"original": line.strip(),
|
|
"fixed": "Add wordwrap or width constraint",
|
|
"explanation": "Constrain content to terminal width",
|
|
"code_example": '''// Option 1: Use wordwrap
|
|
import "github.com/muesli/reflow/wordwrap"
|
|
|
|
content := wordwrap.String(longText, m.termWidth)
|
|
|
|
// Option 2: Use lipgloss Width + truncate
|
|
style := lipgloss.NewStyle().Width(m.termWidth)
|
|
content := style.Render(longText)
|
|
|
|
// Option 3: Manual truncate
|
|
import "github.com/muesli/reflow/truncate"
|
|
|
|
content := truncate.StringWithTail(longText, uint(m.termWidth), "...")'''
|
|
})
|
|
|
|
return issues, fixes
|
|
|
|
|
|
def _check_terminal_resize_handling(content: str, lines: List[str], file_path: str) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
"""Check for proper terminal resize handling."""
|
|
issues = []
|
|
fixes = []
|
|
|
|
# Check if WindowSizeMsg is handled
|
|
handles_resize = bool(re.search(r'case\s+tea\.WindowSizeMsg:', content))
|
|
|
|
# Check if model stores term dimensions
|
|
has_term_fields = bool(re.search(r'(termWidth|termHeight|width|height)\s+int', content))
|
|
|
|
if not handles_resize and uses_lipgloss(content):
|
|
issues.append({
|
|
"severity": "CRITICAL",
|
|
"type": "missing_resize_handling",
|
|
"issue": "No tea.WindowSizeMsg handling detected",
|
|
"location": file_path,
|
|
"explanation": "Layout won't adapt when terminal is resized",
|
|
"impact": "Content clipped or misaligned after resize"
|
|
})
|
|
|
|
fixes.append({
|
|
"location": file_path,
|
|
"original": "N/A",
|
|
"fixed": "Add WindowSizeMsg handler",
|
|
"explanation": "Store terminal dimensions and update on resize",
|
|
"code_example": '''// In model:
|
|
type model struct {
|
|
termWidth int
|
|
termHeight int
|
|
}
|
|
|
|
// In Update():
|
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.termWidth = msg.Width
|
|
m.termHeight = msg.Height
|
|
|
|
// Update child components with new size
|
|
m.viewport.Width = msg.Width
|
|
m.viewport.Height = msg.Height - 2 // Leave room for header
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// In View():
|
|
func (m model) View() string {
|
|
// Use m.termWidth and m.termHeight for dynamic layout
|
|
content := lipgloss.NewStyle().
|
|
Width(m.termWidth).
|
|
Height(m.termHeight).
|
|
Render(m.content)
|
|
return content
|
|
}'''
|
|
})
|
|
|
|
elif handles_resize and not has_term_fields:
|
|
issues.append({
|
|
"severity": "WARNING",
|
|
"type": "resize_not_stored",
|
|
"issue": "WindowSizeMsg handled but dimensions not stored",
|
|
"location": file_path,
|
|
"explanation": "Handling resize but not storing dimensions for later use",
|
|
"impact": "Can't use current terminal size in View()"
|
|
})
|
|
|
|
return issues, fixes
|
|
|
|
|
|
def _check_border_accounting(content: str, lines: List[str], file_path: str) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
"""Check for border accounting in layout calculations."""
|
|
issues = []
|
|
fixes = []
|
|
|
|
# Check for borders without proper accounting
|
|
has_border = bool(re.search(r'\.Border\s*\(', content))
|
|
has_border_width_calc = bool(re.search(r'GetHorizontalBorderSize|GetVerticalBorderSize', content))
|
|
|
|
if has_border and not has_border_width_calc:
|
|
# Find border usage lines
|
|
for i, line in enumerate(lines):
|
|
if '.Border(' in line:
|
|
issues.append({
|
|
"severity": "WARNING",
|
|
"type": "missing_border_calc",
|
|
"issue": "Border used without accounting for border size",
|
|
"location": f"{file_path}:{i+1}",
|
|
"current_code": line.strip(),
|
|
"explanation": "Borders take space (2 chars horizontal, 2 chars vertical)",
|
|
"impact": "Content area smaller than expected"
|
|
})
|
|
|
|
fixes.append({
|
|
"location": f"{file_path}:{i+1}",
|
|
"original": line.strip(),
|
|
"fixed": "Account for border size",
|
|
"explanation": "Use GetHorizontalBorderSize() and GetVerticalBorderSize()",
|
|
"code_example": '''// With border:
|
|
style := lipgloss.NewStyle().
|
|
Border(lipgloss.RoundedBorder()).
|
|
Width(80)
|
|
|
|
// Calculate content area:
|
|
contentWidth := 80 - style.GetHorizontalBorderSize()
|
|
contentHeight := 24 - style.GetVerticalBorderSize()
|
|
|
|
// Use for inner content:
|
|
innerContent := lipgloss.NewStyle().
|
|
Width(contentWidth).
|
|
Height(contentHeight).
|
|
Render(text)
|
|
|
|
result := style.Render(innerContent)'''
|
|
})
|
|
|
|
return issues, fixes
|
|
|
|
|
|
def uses_lipgloss(content: str) -> bool:
|
|
"""Check if file uses lipgloss."""
|
|
return bool(re.search(r'"github\.com/charmbracelet/lipgloss"', content))
|
|
|
|
|
|
def _generate_improvements(issues: List[Dict[str, Any]]) -> List[str]:
|
|
"""Generate general improvement recommendations."""
|
|
improvements = []
|
|
|
|
issue_types = set(issue['type'] for issue in issues)
|
|
|
|
if 'hardcoded_dimensions' in issue_types:
|
|
improvements.append(
|
|
"🎯 Use dynamic terminal sizing: Store termWidth/termHeight in model, update from tea.WindowSizeMsg"
|
|
)
|
|
|
|
if 'incorrect_height' in issue_types:
|
|
improvements.append(
|
|
"📏 Use lipgloss.Height() and lipgloss.Width() for accurate measurements"
|
|
)
|
|
|
|
if 'missing_padding_calc' in issue_types:
|
|
improvements.append(
|
|
"📐 Account for padding with GetHorizontalPadding() and GetVerticalPadding()"
|
|
)
|
|
|
|
if 'overflow' in issue_types:
|
|
improvements.append(
|
|
"📝 Use wordwrap or truncate to prevent text overflow"
|
|
)
|
|
|
|
if 'missing_resize_handling' in issue_types:
|
|
improvements.append(
|
|
"🔄 Handle tea.WindowSizeMsg to support terminal resizing"
|
|
)
|
|
|
|
if 'missing_border_calc' in issue_types:
|
|
improvements.append(
|
|
"🔲 Account for borders with GetHorizontalBorderSize() and GetVerticalBorderSize()"
|
|
)
|
|
|
|
# General best practices
|
|
improvements.extend([
|
|
"✨ Test your TUI at various terminal sizes (80x24, 120x40, 200x50)",
|
|
"🔍 Use lipgloss debugging: Print style.String() to see computed dimensions",
|
|
"📦 Cache computed styles in model to avoid recreation on every render",
|
|
"🎨 Use PlaceHorizontal/PlaceVertical for alignment instead of manual padding"
|
|
])
|
|
|
|
return improvements
|
|
|
|
|
|
def validate_layout_fixes(result: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Validate layout fixes result."""
|
|
if 'error' in result:
|
|
return {"status": "error", "summary": result['error']}
|
|
|
|
validation = result.get('validation', {})
|
|
status = validation.get('status', 'unknown')
|
|
summary = validation.get('summary', 'Layout analysis complete')
|
|
|
|
checks = [
|
|
(result.get('layout_issues') is not None, "Has issues list"),
|
|
(result.get('lipgloss_improvements') is not None, "Has improvements"),
|
|
(result.get('code_fixes') is not None, "Has code fixes"),
|
|
]
|
|
|
|
all_pass = all(check[0] for check in checks)
|
|
|
|
return {
|
|
"status": status,
|
|
"summary": summary,
|
|
"checks": {check[1]: check[0] for check in checks},
|
|
"valid": all_pass
|
|
}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
if len(sys.argv) < 2:
|
|
print("Usage: fix_layout_issues.py <code_path> [description]")
|
|
sys.exit(1)
|
|
|
|
code_path = sys.argv[1]
|
|
description = sys.argv[2] if len(sys.argv) > 2 else ""
|
|
|
|
result = fix_layout_issues(code_path, description)
|
|
print(json.dumps(result, indent=2))
|