feat(dashboard): implement smart auto-layout with expansion and better defaults
This commit implements 5 major improvements to the dashboard layout system:
**1. Improved Default Layout (defaultLayout.js)**
- Changed from 2 tabs to 3 tabs for better organization:
- Tab 1 (Status): User widgets only (userInfo, userStats, userMood, userAttributes)
- Tab 2 (Scene): Scene widgets + characters (calendar, weather, temp, clock, location, presentCharacters)
- Tab 3 (Inventory): Full inventory widget
- Cleaner separation prevents cramming all widgets on one tab
**2. Widget Max Size Limits (widget definition files)**
- Added maxAutoSize property to all widgets (enforced only during auto-arrange):
- Info widgets (calendar, weather, temp, clock): { w: 2, h: 3 }
- Location: { w: 3, h: 3 }
- presentCharacters: { w: 3, h: 6 } (can expand significantly)
- Inventory: { w: 3, h: 8 } (full tab)
- Prevents blind expansion while allowing intelligent space filling
**3. Smart Expansion Algorithm (gridEngine.js)**
- Added expansion pass after compaction in autoLayout():
- Sorts widgets top-to-bottom, left-to-right
- Tries to expand height first (fills vertical gaps)
- Then tries to expand width (fills horizontal gaps)
- Respects maxAutoSize limits from widget definitions
- Only expands if no collision with other widgets
- Widgets now fill available space instead of staying at default sizes
- Example: presentCharacters expands from 2x3 to 3x6 when space available
**4. Auto-Reflow on Column Change (dashboardManager.js)**
- Modified onColumnsChange callback to auto-layout after column count changes
- When grid transitions (2→3 or 3→2), automatically reflo ws widgets
- Prevents overlap and optimizes for new column count
- User experience: seamless adaptation when console opens/closes
**5. Fixed Grid Height/Scrollbar CSS (style.css)**
- Added flex: 1, overflow-y: auto, min-height: 0 to .rpg-dashboard-grid
- Grid now properly fills available space in dashboard container
- Accounts for bottom buttons (manual update, settings)
- Prevents "fingernail of extra height" that caused scrollbars
**Technical Changes:**
- Passed widget registry to GridEngine for maxAutoSize lookups
- getWidgetMaxSize() helper looks up definitions from registry
- Moved registry initialization before GridEngine construction
- Grid now uses flexbox to fill available vertical space
**User-Facing Improvements:**
- Reset layout creates logical 3-tab structure from the start
- Auto-arrange expands widgets to fill available space intelligently
- Resizing window/console automatically reflows layout
- No more unwanted scrollbars from slight overflow
Fixes cramped layouts, underutilized space, and scrollbar issues.
This commit is contained in:
@@ -101,48 +101,36 @@ export class DashboardManager {
|
|||||||
// Create container structure
|
// Create container structure
|
||||||
this.createContainerStructure();
|
this.createContainerStructure();
|
||||||
|
|
||||||
|
// Initialize Widget Registry (use provided registry or create new one)
|
||||||
|
this.registry = this.config.registry || new WidgetRegistry();
|
||||||
|
|
||||||
// Initialize Grid Engine (columns calculated dynamically)
|
// Initialize Grid Engine (columns calculated dynamically)
|
||||||
this.gridEngine = new GridEngine({
|
this.gridEngine = new GridEngine({
|
||||||
rowHeight: this.config.rowHeight,
|
rowHeight: this.config.rowHeight,
|
||||||
gap: this.config.gap,
|
gap: this.config.gap,
|
||||||
container: this.gridContainer,
|
container: this.gridContainer,
|
||||||
|
registry: this.registry, // Pass registry for maxAutoSize lookups
|
||||||
onColumnsChange: (newCols, oldCols) => {
|
onColumnsChange: (newCols, oldCols) => {
|
||||||
console.log('[DashboardManager] Grid columns changed:', oldCols, '→', newCols);
|
console.log('[DashboardManager] Grid columns changed:', oldCols, '→', newCols);
|
||||||
|
|
||||||
// Fix widget dimensions when column count changes
|
// Auto-reflow current tab to optimize for new column count
|
||||||
// This prevents widgets from shrinking when grid switches between 2/3/4 columns
|
|
||||||
const currentTab = this.tabManager.getTab(this.currentTabId);
|
const currentTab = this.tabManager.getTab(this.currentTabId);
|
||||||
if (currentTab) {
|
if (currentTab && currentTab.widgets && currentTab.widgets.length > 0) {
|
||||||
currentTab.widgets.forEach(widget => {
|
console.log(`[DashboardManager] Auto-reflowing ${currentTab.widgets.length} widgets for ${newCols} columns`);
|
||||||
// If widget was full-width in old grid, make it full-width in new grid
|
|
||||||
if (widget.w === oldCols) {
|
// Run auto-layout to reflow and expand widgets for new grid
|
||||||
console.log(`[DashboardManager] Adjusting full-width widget ${widget.id}: w=${widget.w} → ${newCols}`);
|
// This prevents overlap and optimizes space usage
|
||||||
widget.w = newCols;
|
this.gridEngine.autoLayout(currentTab.widgets, { preserveOrder: true });
|
||||||
}
|
|
||||||
// If widget is wider than new grid, clamp it
|
|
||||||
else if (widget.w > newCols) {
|
|
||||||
console.log(`[DashboardManager] Clamping oversized widget ${widget.id}: w=${widget.w} → ${newCols}`);
|
|
||||||
widget.w = newCols;
|
|
||||||
}
|
|
||||||
// If widget x position is out of bounds, reset to 0
|
|
||||||
if (widget.x >= newCols) {
|
|
||||||
console.log(`[DashboardManager] Resetting out-of-bounds widget ${widget.id}: x=${widget.x} → 0`);
|
|
||||||
widget.x = 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Save changes
|
// Save changes
|
||||||
this.triggerAutoSave();
|
this.triggerAutoSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-render all widgets with adjusted dimensions
|
// Re-render all widgets with new layout
|
||||||
this.renderAllWidgets();
|
this.renderAllWidgets();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize Widget Registry (use provided registry or create new one)
|
|
||||||
this.registry = this.config.registry || new WidgetRegistry();
|
|
||||||
|
|
||||||
// Initialize Tab Manager with dashboard data structure
|
// Initialize Tab Manager with dashboard data structure
|
||||||
// Create default tab if no tabs exist
|
// Create default tab if no tabs exist
|
||||||
if (this.dashboard.tabs.length === 0) {
|
if (this.dashboard.tabs.length === 0) {
|
||||||
|
|||||||
@@ -32,14 +32,14 @@ export function generateDefaultDashboard() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
tabs: [
|
tabs: [
|
||||||
|
// Tab 1: Status (User widgets only - compact and focused)
|
||||||
{
|
{
|
||||||
id: 'tab-status',
|
id: 'tab-status',
|
||||||
name: 'Status',
|
name: 'Status',
|
||||||
icon: '📊',
|
icon: '📊',
|
||||||
order: 0,
|
order: 0,
|
||||||
widgets: [
|
widgets: [
|
||||||
// === USER CLUSTER (Top) ===
|
// Row 0: User Info (avatar, name, level)
|
||||||
// Row 0: User Info (avatar, name, level) - AT TOP
|
|
||||||
{
|
{
|
||||||
id: 'widget-userinfo',
|
id: 'widget-userinfo',
|
||||||
type: 'userInfo',
|
type: 'userInfo',
|
||||||
@@ -61,17 +61,17 @@ export function generateDefaultDashboard() {
|
|||||||
statBarGradient: true
|
statBarGradient: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Row 3: User Mood (left column)
|
// Row 3: User Mood
|
||||||
{
|
{
|
||||||
id: 'widget-usermood',
|
id: 'widget-usermood',
|
||||||
type: 'userMood',
|
type: 'userMood',
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 3,
|
y: 3,
|
||||||
w: 1,
|
w: 2,
|
||||||
h: 1,
|
h: 1,
|
||||||
config: {}
|
config: {}
|
||||||
},
|
},
|
||||||
// Row 4-5: User Attributes (full width, needs 2 columns for 3x2 grid)
|
// Row 4-5: User Attributes
|
||||||
{
|
{
|
||||||
id: 'widget-userattributes',
|
id: 'widget-userattributes',
|
||||||
type: 'userAttributes',
|
type: 'userAttributes',
|
||||||
@@ -80,15 +80,22 @@ export function generateDefaultDashboard() {
|
|||||||
w: 2,
|
w: 2,
|
||||||
h: 2,
|
h: 2,
|
||||||
config: {}
|
config: {}
|
||||||
},
|
}
|
||||||
|
]
|
||||||
// === SCENE CLUSTER (Middle) ===
|
},
|
||||||
// Row 6-7: Calendar (left) + Weather (right)
|
// Tab 2: Scene (Scene info widgets + characters)
|
||||||
|
{
|
||||||
|
id: 'tab-scene',
|
||||||
|
name: 'Scene',
|
||||||
|
icon: '🌍',
|
||||||
|
order: 1,
|
||||||
|
widgets: [
|
||||||
|
// Row 0-1: Calendar (left) + Weather (right)
|
||||||
{
|
{
|
||||||
id: 'widget-calendar',
|
id: 'widget-calendar',
|
||||||
type: 'calendar',
|
type: 'calendar',
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 6,
|
y: 0,
|
||||||
w: 1,
|
w: 1,
|
||||||
h: 2,
|
h: 2,
|
||||||
config: {}
|
config: {}
|
||||||
@@ -97,19 +104,19 @@ export function generateDefaultDashboard() {
|
|||||||
id: 'widget-weather',
|
id: 'widget-weather',
|
||||||
type: 'weather',
|
type: 'weather',
|
||||||
x: 1,
|
x: 1,
|
||||||
y: 6,
|
y: 0,
|
||||||
w: 1,
|
w: 1,
|
||||||
h: 2,
|
h: 2,
|
||||||
config: {
|
config: {
|
||||||
compact: false
|
compact: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Row 8-9: Temperature (left) + Clock (right)
|
// Row 2-3: Temperature (left) + Clock (right)
|
||||||
{
|
{
|
||||||
id: 'widget-temperature',
|
id: 'widget-temperature',
|
||||||
type: 'temperature',
|
type: 'temperature',
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 8,
|
y: 2,
|
||||||
w: 1,
|
w: 1,
|
||||||
h: 2,
|
h: 2,
|
||||||
config: {
|
config: {
|
||||||
@@ -120,31 +127,29 @@ export function generateDefaultDashboard() {
|
|||||||
id: 'widget-clock',
|
id: 'widget-clock',
|
||||||
type: 'clock',
|
type: 'clock',
|
||||||
x: 1,
|
x: 1,
|
||||||
y: 8,
|
y: 2,
|
||||||
w: 1,
|
w: 1,
|
||||||
h: 2,
|
h: 2,
|
||||||
config: {
|
config: {
|
||||||
format: 'digital'
|
format: 'digital'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Row 10-11: Location (full width)
|
// Row 4-5: Location (full width)
|
||||||
{
|
{
|
||||||
id: 'widget-location',
|
id: 'widget-location',
|
||||||
type: 'location',
|
type: 'location',
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 10,
|
y: 4,
|
||||||
w: 2,
|
w: 2,
|
||||||
h: 2,
|
h: 2,
|
||||||
config: {}
|
config: {}
|
||||||
},
|
},
|
||||||
|
// Row 6-8: Present Characters (full width, will expand with auto-layout)
|
||||||
// === SOCIAL CLUSTER (Bottom) ===
|
|
||||||
// Row 12-14: Present Characters (full width)
|
|
||||||
{
|
{
|
||||||
id: 'widget-presentchars',
|
id: 'widget-presentchars',
|
||||||
type: 'presentCharacters',
|
type: 'presentCharacters',
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 12,
|
y: 6,
|
||||||
w: 2,
|
w: 2,
|
||||||
h: 3,
|
h: 3,
|
||||||
config: {
|
config: {
|
||||||
@@ -154,11 +159,12 @@ export function generateDefaultDashboard() {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
// Tab 3: Inventory (Full tab for inventory system)
|
||||||
{
|
{
|
||||||
id: 'tab-inventory',
|
id: 'tab-inventory',
|
||||||
name: 'Inventory',
|
name: 'Inventory',
|
||||||
icon: '🎒',
|
icon: '🎒',
|
||||||
order: 1,
|
order: 2,
|
||||||
widgets: [
|
widgets: [
|
||||||
{
|
{
|
||||||
id: 'widget-inventory',
|
id: 'widget-inventory',
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export class GridEngine {
|
|||||||
this.snapToGrid = config.snapToGrid !== false;
|
this.snapToGrid = config.snapToGrid !== false;
|
||||||
this.container = config.container || null;
|
this.container = config.container || null;
|
||||||
|
|
||||||
|
// Widget registry for accessing widget definitions (e.g., maxAutoSize)
|
||||||
|
this.registry = config.registry || null;
|
||||||
|
|
||||||
// Container width will be set dynamically
|
// Container width will be set dynamically
|
||||||
this.containerWidth = 0;
|
this.containerWidth = 0;
|
||||||
|
|
||||||
@@ -562,7 +565,92 @@ export class GridEngine {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[GridEngine] Auto-layout complete (compacted ${compactedCount} widgets)`);
|
console.log(`[GridEngine] Compaction complete (${compactedCount} widgets moved up)`);
|
||||||
|
|
||||||
|
// Expansion pass: Try to expand widgets to fill available space
|
||||||
|
console.log('[GridEngine] Expanding widgets to fill available space...');
|
||||||
|
let expandedCount = 0;
|
||||||
|
|
||||||
|
// Sort widgets by position (top-to-bottom, left-to-right) for orderly expansion
|
||||||
|
const sortedForExpand = [...sorted].sort((a, b) => {
|
||||||
|
if (a.y !== b.y) return a.y - b.y; // Top to bottom
|
||||||
|
return a.x - b.x; // Left to right
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper to get widget max size from registry
|
||||||
|
const getWidgetMaxSize = (widget) => {
|
||||||
|
// Try to get widget definition from registry
|
||||||
|
if (this.registry && widget.type) {
|
||||||
|
const definition = this.registry.get(widget.type);
|
||||||
|
if (definition && definition.maxAutoSize) {
|
||||||
|
return definition.maxAutoSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Default max size if not specified (flexible expansion)
|
||||||
|
return { w: this.columns, h: 10 };
|
||||||
|
};
|
||||||
|
|
||||||
|
sortedForExpand.forEach(widget => {
|
||||||
|
const maxSize = getWidgetMaxSize(widget);
|
||||||
|
const originalW = widget.w;
|
||||||
|
const originalH = widget.h;
|
||||||
|
|
||||||
|
// Try expanding height first (fills vertical gaps)
|
||||||
|
let expandedH = false;
|
||||||
|
for (let tryH = originalH + 1; tryH <= Math.min(maxSize.h, originalH + 3); tryH++) {
|
||||||
|
// Clear current position
|
||||||
|
for (let row = widget.y; row < widget.y + widget.h; row++) {
|
||||||
|
for (let col = widget.x; col < widget.x + widget.w; col++) {
|
||||||
|
occupied.delete(`${col},${row}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if expanded height is free
|
||||||
|
if (isFree(widget.x, widget.y, widget.w, tryH)) {
|
||||||
|
widget.h = tryH;
|
||||||
|
markOccupied(widget, widget.x, widget.y, widget.w, tryH);
|
||||||
|
expandedH = true;
|
||||||
|
expandedCount++;
|
||||||
|
console.log(`[GridEngine] Expanded ${widget.id} height: ${originalH} → ${tryH}`);
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
// Re-mark original and try next size
|
||||||
|
markOccupied(widget, widget.x, widget.y, widget.w, widget.h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try expanding width (fills horizontal gaps)
|
||||||
|
let expandedW = false;
|
||||||
|
for (let tryW = originalW + 1; tryW <= Math.min(maxSize.w, this.columns); tryW++) {
|
||||||
|
// Clear current position
|
||||||
|
for (let row = widget.y; row < widget.y + widget.h; row++) {
|
||||||
|
for (let col = widget.x; col < widget.x + widget.w; col++) {
|
||||||
|
occupied.delete(`${col},${row}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if expanded width is free
|
||||||
|
if (isFree(widget.x, widget.y, tryW, widget.h)) {
|
||||||
|
widget.w = tryW;
|
||||||
|
markOccupied(widget, widget.x, widget.y, tryW, widget.h);
|
||||||
|
expandedW = true;
|
||||||
|
expandedCount++;
|
||||||
|
console.log(`[GridEngine] Expanded ${widget.id} width: ${originalW} → ${tryW}`);
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
// Re-mark original and try next size
|
||||||
|
markOccupied(widget, widget.x, widget.y, widget.w, widget.h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expandedH && !expandedW) {
|
||||||
|
// Widget couldn't expand - ensure it's still marked in grid
|
||||||
|
markOccupied(widget, widget.x, widget.y, widget.w, widget.h);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[GridEngine] Expansion complete (${expandedCount} expansions made)`);
|
||||||
|
console.log(`[GridEngine] Auto-layout complete`);
|
||||||
return widgets;
|
return widgets;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ export function registerCalendarWidget(registry, dependencies) {
|
|||||||
category: 'scene',
|
category: 'scene',
|
||||||
minSize: { w: 1, h: 2 },
|
minSize: { w: 1, h: 2 },
|
||||||
defaultSize: { w: 1, h: 2 },
|
defaultSize: { w: 1, h: 2 },
|
||||||
|
maxAutoSize: { w: 2, h: 3 }, // Max size for auto-arrange expansion
|
||||||
requiresSchema: false,
|
requiresSchema: false,
|
||||||
|
|
||||||
render(container, config = {}) {
|
render(container, config = {}) {
|
||||||
@@ -281,6 +282,7 @@ export function registerWeatherWidget(registry, dependencies) {
|
|||||||
description: 'Weather emoji and forecast',
|
description: 'Weather emoji and forecast',
|
||||||
minSize: { w: 1, h: 2 },
|
minSize: { w: 1, h: 2 },
|
||||||
defaultSize: { w: 1, h: 2 },
|
defaultSize: { w: 1, h: 2 },
|
||||||
|
maxAutoSize: { w: 2, h: 3 }, // Max size for auto-arrange expansion
|
||||||
requiresSchema: false,
|
requiresSchema: false,
|
||||||
|
|
||||||
render(container, config = {}) {
|
render(container, config = {}) {
|
||||||
@@ -314,6 +316,7 @@ export function registerTemperatureWidget(registry, dependencies) {
|
|||||||
description: 'Temperature display with thermometer',
|
description: 'Temperature display with thermometer',
|
||||||
minSize: { w: 1, h: 2 },
|
minSize: { w: 1, h: 2 },
|
||||||
defaultSize: { w: 1, h: 2 },
|
defaultSize: { w: 1, h: 2 },
|
||||||
|
maxAutoSize: { w: 2, h: 3 }, // Max size for auto-arrange expansion
|
||||||
requiresSchema: false,
|
requiresSchema: false,
|
||||||
|
|
||||||
render(container, config = {}) {
|
render(container, config = {}) {
|
||||||
@@ -354,6 +357,7 @@ export function registerClockWidget(registry, dependencies) {
|
|||||||
description: 'Analog clock with time display',
|
description: 'Analog clock with time display',
|
||||||
minSize: { w: 1, h: 2 },
|
minSize: { w: 1, h: 2 },
|
||||||
defaultSize: { w: 1, h: 2 },
|
defaultSize: { w: 1, h: 2 },
|
||||||
|
maxAutoSize: { w: 2, h: 3 }, // Max size for auto-arrange expansion
|
||||||
requiresSchema: false,
|
requiresSchema: false,
|
||||||
|
|
||||||
render(container, config = {}) {
|
render(container, config = {}) {
|
||||||
@@ -403,6 +407,7 @@ export function registerLocationWidget(registry, dependencies) {
|
|||||||
description: 'Map with location display',
|
description: 'Map with location display',
|
||||||
minSize: { w: 1, h: 2 },
|
minSize: { w: 1, h: 2 },
|
||||||
defaultSize: { w: 2, h: 2 },
|
defaultSize: { w: 2, h: 2 },
|
||||||
|
maxAutoSize: { w: 3, h: 3 }, // Max size for auto-arrange expansion
|
||||||
requiresSchema: false,
|
requiresSchema: false,
|
||||||
|
|
||||||
render(container, config = {}) {
|
render(container, config = {}) {
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export function registerInventoryWidget(registry, dependencies) {
|
|||||||
category: 'inventory',
|
category: 'inventory',
|
||||||
minSize: { w: 2, h: 4 },
|
minSize: { w: 2, h: 4 },
|
||||||
defaultSize: { w: 2, h: 6 },
|
defaultSize: { w: 2, h: 6 },
|
||||||
|
maxAutoSize: { w: 3, h: 8 }, // Max size for auto-arrange expansion (full tab)
|
||||||
requiresSchema: false,
|
requiresSchema: false,
|
||||||
|
|
||||||
render(container, config = {}) {
|
render(container, config = {}) {
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ export function registerPresentCharactersWidget(registry, dependencies) {
|
|||||||
category: 'social',
|
category: 'social',
|
||||||
minSize: { w: 2, h: 2 },
|
minSize: { w: 2, h: 2 },
|
||||||
defaultSize: { w: 2, h: 3 },
|
defaultSize: { w: 2, h: 3 },
|
||||||
|
maxAutoSize: { w: 3, h: 6 }, // Max size for auto-arrange expansion (can expand significantly)
|
||||||
requiresSchema: false,
|
requiresSchema: false,
|
||||||
|
|
||||||
render(container, config = {}) {
|
render(container, config = {}) {
|
||||||
|
|||||||
@@ -1152,7 +1152,10 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
|
|||||||
.rpg-dashboard-grid {
|
.rpg-dashboard-grid {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 200px;
|
flex: 1; /* Fill available space in dashboard container */
|
||||||
|
overflow-y: auto; /* Allow scrolling within grid if needed */
|
||||||
|
overflow-x: hidden;
|
||||||
|
min-height: 0; /* Allow flex to shrink below natural size */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide resize handles by default */
|
/* Hide resize handles by default */
|
||||||
|
|||||||
Reference in New Issue
Block a user