Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .cursorrules
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
These are Lead developer instructions for AI Agents :
- avoid except:pass handles, better crash with a clear error message than hide the problems.
- Use testing-driven development.
- Fix library code, not tests
- Never perform git commits
- Keep line length under 110 characters
- Finish your changes by running `make qa` in the boulder conda env.
71 changes: 51 additions & 20 deletions boulder/app.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
import os

import dash
Expand All @@ -10,7 +11,6 @@
)
from .config import (
get_config_from_path_with_comments,
get_initial_config,
get_initial_config_with_comments,
)
from .layout import get_layout
Expand Down Expand Up @@ -43,24 +43,20 @@
server = app.server # Expose the server for deployment

# Load initial configuration with optional override via environment variable
try:
# Allow overriding the initial configuration via environment variable
# Use either BOULDER_CONFIG_PATH or BOULDER_CONFIG for convenience
env_config_path = os.environ.get("BOULDER_CONFIG_PATH") or os.environ.get(
"BOULDER_CONFIG"
)

if env_config_path and env_config_path.strip():
cleaned = env_config_path.strip()
initial_config, original_yaml = get_config_from_path_with_comments(cleaned)
# When a specific file is provided, propagate its base name to the UI store
provided_filename = os.path.basename(cleaned)
else:
initial_config, original_yaml = get_initial_config_with_comments()
except Exception as e:
print(f"Warning: Could not load config with comments, using standard loader: {e}")
initial_config = get_initial_config()
original_yaml = ""
# Allow overriding the initial configuration via environment variable
# Use either BOULDER_CONFIG_PATH or BOULDER_CONFIG for convenience
env_config_path = os.environ.get("BOULDER_CONFIG_PATH") or os.environ.get(
"BOULDER_CONFIG"
)

if env_config_path and env_config_path.strip():
cleaned = env_config_path.strip()
initial_config, original_yaml = get_config_from_path_with_comments(cleaned)
# When a specific file is provided, propagate its base name to the UI store
provided_filename = os.path.basename(cleaned)
else:
initial_config, original_yaml = get_initial_config_with_comments()


# Set the layout
app.layout = get_layout(
Expand All @@ -74,6 +70,41 @@
callbacks.register_callbacks(app)


def run_server(debug: bool = False, host: str = "0.0.0.0", port: int = 8050) -> None:
def run_server(
debug: bool = False, host: str = "0.0.0.0", port: int = 8050, verbose: bool = False
) -> None:
"""Run the Dash server."""
if verbose:
# Configure logging for verbose output
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
logger.info("Boulder server starting in verbose mode")
logger.info(f"Server configuration: host={host}, port={port}, debug={debug}")

# Check for potential port conflicts and log them
import socket

try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind((host, port))
logger.info(f"Port {port} is available for binding")
except OSError as e:
logger.warning(
f"Port {port} binding check failed: {e} "
f"(this is normal if CLI already handled port conflicts)"
)

# Log initial configuration details
env_config_path = os.environ.get("BOULDER_CONFIG_PATH") or os.environ.get(
"BOULDER_CONFIG"
)
if env_config_path:
logger.info(f"Loading configuration from: {env_config_path}")
else:
logger.info("Using default configuration")

app.run(debug=debug, host=host, port=port)
43 changes: 43 additions & 0 deletions boulder/assets/dark_mode.css
Original file line number Diff line number Diff line change
Expand Up @@ -435,3 +435,46 @@ pre {
50% { background-position: 0 0, 0 0, 0 0; }
100% { background-position: 200px 0, -200px 0, 240px 0; }
}

/* Resizable graph container styles */
#graph-container {
position: relative;
transition: all 0.3s ease;
border: 1px solid var(--border-color);
border-radius: 4px;
overflow: hidden;
}

#graph-container:hover {
box-shadow: 0 4px 12px var(--shadow-hover);
}

/* Custom resize handle styling */
#resize-handle {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 6px;
background: linear-gradient(90deg, transparent 0%, var(--border-color) 20%, var(--border-color) 80%, transparent 100%);
cursor: ns-resize;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 10;
}

#graph-container:hover #resize-handle {
opacity: 1;
}

/* Visual feedback during resize */
#graph-container.resizing {
box-shadow: 0 6px 16px var(--shadow-hover);
}

/* Responsive adjustments for smaller screens */
@media (max-width: 768px) {
#graph-container {
margin: 0 -15px;
}
}
132 changes: 132 additions & 0 deletions boulder/assets/graph_resize.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Graph resize functionality
(function() {
'use strict';

let isResizing = false;
let startY = 0;
let startHeight = 0;
let graphContainer = null;
let reactorGraph = null;

function initializeResize() {
// Wait for the graph container to be available
const checkContainer = setInterval(() => {
graphContainer = document.getElementById('graph-container');
reactorGraph = document.getElementById('reactor-graph');

if (graphContainer && reactorGraph) {
clearInterval(checkContainer);
setupResizeHandle();
}
}, 100);
}

function setupResizeHandle() {
// Create resize handle
const resizeHandle = document.createElement('div');
resizeHandle.id = 'resize-handle';
resizeHandle.style.cssText = `
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 6px;
background: linear-gradient(90deg, transparent 0%, var(--border-color, #ccc) 20%, var(--border-color, #ccc) 80%, transparent 100%);
cursor: ns-resize;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 10;
`;

graphContainer.appendChild(resizeHandle);

// Show handle on hover
graphContainer.addEventListener('mouseenter', () => {
resizeHandle.style.opacity = '1';
});

graphContainer.addEventListener('mouseleave', () => {
if (!isResizing) {
resizeHandle.style.opacity = '0';
}
});

// Handle mouse events
resizeHandle.addEventListener('mousedown', startResize);
document.addEventListener('mousemove', resize);
document.addEventListener('mouseup', stopResize);

// Prevent text selection during resize
resizeHandle.addEventListener('selectstart', (e) => e.preventDefault());
resizeHandle.addEventListener('dragstart', (e) => e.preventDefault());
}

function startResize(e) {
isResizing = true;
startY = e.clientY;
startHeight = parseInt(getComputedStyle(reactorGraph).height, 10);

// Add visual feedback
document.body.style.cursor = 'ns-resize';
document.body.style.userSelect = 'none';
graphContainer.classList.add('resizing');

e.preventDefault();
}

function resize(e) {
if (!isResizing) return;

const deltaY = e.clientY - startY;
const newHeight = Math.max(200, startHeight + deltaY); // Minimum 200px

reactorGraph.style.height = newHeight + 'px';

e.preventDefault();
}

function stopResize() {
if (!isResizing) return;

isResizing = false;

// Remove visual feedback
document.body.style.cursor = '';
document.body.style.userSelect = '';
graphContainer.classList.remove('resizing');

// Hide resize handle if mouse is not over container
const resizeHandle = document.getElementById('resize-handle');
if (resizeHandle && !graphContainer.matches(':hover')) {
resizeHandle.style.opacity = '0';
}
}

// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeResize);
} else {
initializeResize();
}

// Re-initialize when Dash updates the DOM (for dynamic content)
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
const graphContainer = document.getElementById('graph-container');
const resizeHandle = document.getElementById('resize-handle');

if (graphContainer && !resizeHandle) {
// Container exists but no resize handle, set it up
setTimeout(setupResizeHandle, 100);
}
}
});
});

observer.observe(document.body, {
childList: true,
subtree: true
});

})();
2 changes: 2 additions & 0 deletions boulder/callbacks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
modal_callbacks,
notification_callbacks,
properties_callbacks,
resize_callbacks,
simulation_callbacks,
theme_callbacks,
)
Expand All @@ -18,6 +19,7 @@ def register_callbacks(app) -> None: # type: ignore
modal_callbacks.register_callbacks(app)
properties_callbacks.register_callbacks(app)
config_callbacks.register_callbacks(app)
resize_callbacks.register_callbacks(app)
simulation_callbacks.register_callbacks(app)
notification_callbacks.register_callbacks(app)
clientside_callbacks.register_callbacks(app)
Expand Down
25 changes: 20 additions & 5 deletions boulder/callbacks/config_callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
import yaml
from dash import Input, Output, State, dcc, html

from ..verbose_utils import get_verbose_logger, is_verbose_mode

logger = get_verbose_logger(__name__)

# Configure YAML to preserve dict order without Python tags
yaml.add_representer(
dict,
Expand Down Expand Up @@ -87,6 +91,7 @@ def render_config_upload_area(file_name: str) -> tuple:
Output("current-config", "data"),
Output("config-file-name", "data"),
Output("original-yaml-with-comments", "data"),
Output("upload-config", "contents"), # Add this to reset upload contents
],
[
Input("upload-config", "contents"),
Expand Down Expand Up @@ -119,6 +124,7 @@ def handle_config_upload_delete(
from ..config import (
load_yaml_string_with_comments,
normalize_config,
validate_config,
)

# Use comment-preserving YAML loader
Expand All @@ -130,18 +136,27 @@ def handle_config_upload_delete(

# Normalize from YAML with 🪨 STONE standard to internal format
normalized = normalize_config(decoded)
return normalized, upload_filename, decoded_string
# Validate the configuration (this will also convert units)
normalized = validate_config(normalized)
if is_verbose_mode():
logger.info(
f"Successfully loaded configuration file: {upload_filename}"
)
return normalized, upload_filename, decoded_string, dash.no_update
else:
print(
"Only YAML format with 🪨 STONE standard (.yaml/.yml) files are supported. Got:"
f" {upload_filename}"
)
return dash.no_update, "", ""
return dash.no_update, "", "", dash.no_update
except Exception as e:
print(f"Error processing uploaded file: {e}")
return dash.no_update, "", ""
if is_verbose_mode():
logger.error(f"Error processing uploaded file: {e}", exc_info=True)
else:
print(f"Error processing uploaded file: {e}")
return dash.no_update, "", "", dash.no_update
elif trigger == "delete-config-file" and delete_n_clicks:
return get_initial_config(), "", ""
return get_initial_config(), "", "", None # Reset upload contents to None
else:
raise dash.exceptions.PreventUpdate

Expand Down
Loading