"""
Enhanced Professional Dockable Three-Pane Window.
This module provides a sophisticated, highly customizable three-pane window
with professional theming, smooth animations, and intuitive drag-and-drop
detaching/attaching functionality.
"""
import tkinter as tk
from dataclasses import dataclass
from tkinter import ttk
from typing import Callable, Dict, List, Optional, Tuple
from .logging_config import get_logger
from .themes import ThemeManager, get_theme_manager
from .utils import platform_handler
# Initialize logger for this module
logger = get_logger(__name__)
[docs]
def validate_icon_path(icon_path: str) -> Tuple[bool, str]:
"""
Validate an icon path for cross-platform compatibility.
Args:
icon_path (str): Path to the icon file to validate.
Returns:
Tuple[bool, str]: A tuple containing:
- bool: True if the icon path is valid and supported, False otherwise
- str: Descriptive message about the validation result
"""
return platform_handler.validate_icon_path(icon_path)
[docs]
@dataclass
class PaneConfig:
"""
Configuration for a pane in the three-pane window.
This class defines all the visual and behavioral properties for a pane,
including its title, icon, sizing constraints, and interaction capabilities.
"""
title: str = "" # Display title for the pane header
icon: str = "" # Icon for the pane (emoji, text, or file path)
window_icon: str = (
"" # Path to icon file for detached windows (.ico, .png, .gif, .bmp, .xbm)
)
custom_titlebar: bool = False # Use custom title bar instead of system title bar
custom_titlebar_shadow: bool = True # Add shadow/border to custom title bar windows
show_in_taskbar: bool = (
True # Whether detached windows appear in taskbar (Windows-specific)
)
detached_height: int = 0 # Fixed height for detached windows (0 = auto)
detached_scrollable: bool = (
True # Add scrollbars if content exceeds detached window size
)
min_width: int = 100 # Minimum width constraint for the pane
max_width: int = 500 # Maximum width constraint for the pane
default_width: int = 200 # Default width when first displayed
resizable: bool = True # Whether the pane can be resized by the user
detachable: bool = True # Whether the pane can be detached into a separate window
closable: bool = False # Whether the pane can be closed/hidden
fixed_width: Optional[int] = (
None # If set, pane will stick to this width and not be resizable
)
[docs]
class DragHandle(tk.Frame):
"""Professional drag handle for detaching panes."""
def __init__(
self,
parent,
pane_side: str,
on_detach: Callable,
theme_manager: ThemeManager,
**kwargs,
):
"""Initialize drag handle with parent, pane side, detach callback, and theme."""
super().__init__(parent, **kwargs)
self.pane_side = pane_side
self.on_detach = on_detach
self.theme_manager = theme_manager
self.is_dragging = False
self.drag_start_x = 0
self.drag_start_y = 0
self.drag_threshold = 10
self._setup_ui()
self._bind_events()
def _setup_ui(self):
"""Set up the drag handle UI."""
theme = self.theme_manager.get_current_theme()
# Configure the handle
self.configure(
height=24, bg=theme.colors.panel_header_bg, relief="flat", cursor="fleur"
)
# Create grip dots
self.grip_frame = tk.Frame(self, bg=theme.colors.panel_header_bg)
self.grip_frame.pack(expand=True, fill="both")
# Add visual grip indicators
for i in range(3):
for j in range(2):
dot = tk.Frame(
self.grip_frame,
width=3,
height=3,
bg=theme.colors.secondary_text,
relief="flat",
)
dot.place(x=8 + j * 4, y=8 + i * 4)
# Add tooltip-like title
if hasattr(self, "title") and self.title:
title_label = tk.Label(
self.grip_frame,
text=self.title,
bg=theme.colors.panel_header_bg,
fg=theme.colors.panel_header_fg,
font=(theme.typography.font_family, theme.typography.font_size_small),
)
title_label.pack(side="right", padx=8)
def _bind_events(self):
"""Bind mouse events for dragging."""
self.bind("<Button-1>", self._on_drag_start)
self.bind("<B1-Motion>", self._on_drag_motion)
self.bind("<ButtonRelease-1>", self._on_drag_end)
self.bind("<Enter>", self._on_enter)
self.bind("<Leave>", self._on_leave)
# Bind to child widgets too
for child in self.winfo_children():
child.bind("<Button-1>", self._on_drag_start)
child.bind("<B1-Motion>", self._on_drag_motion)
child.bind("<ButtonRelease-1>", self._on_drag_end)
def _on_enter(self, event):
"""
Handle mouse enter event.
Changes the drag handle appearance to provide visual feedback
when the mouse hovers over it.
Args:
event: Tkinter event object containing mouse position and state.
"""
theme = self.theme_manager.get_current_theme()
self.configure(bg=theme.colors.accent_bg)
self.grip_frame.configure(bg=theme.colors.accent_bg)
def _on_leave(self, event):
"""
Handle mouse leave event.
Restores the drag handle to its normal appearance when the mouse
leaves the area, unless a drag operation is currently in progress.
Args:
event: Tkinter event object containing mouse position and state.
"""
if not self.is_dragging:
theme = self.theme_manager.get_current_theme()
self.configure(bg=theme.colors.panel_header_bg)
self.grip_frame.configure(bg=theme.colors.panel_header_bg)
def _on_drag_start(self, event):
"""
Handle the start of a drag operation.
Records the initial mouse position for calculating drag distance
and determining when to trigger detachment.
Args:
event: Tkinter event object containing mouse position and state.
"""
self.is_dragging = False
self.drag_start_x = event.x_root
self.drag_start_y = event.y_root
def _on_drag_motion(self, event):
"""
Handle mouse motion during a potential drag operation.
Monitors mouse movement and initiates visual drag feedback once
the movement exceeds the drag threshold distance.
Args:
event: Tkinter event object containing current mouse position.
"""
if not self.is_dragging:
# Check if we've moved enough to start dragging
dx = abs(event.x_root - self.drag_start_x)
dy = abs(event.y_root - self.drag_start_y)
if dx > self.drag_threshold or dy > self.drag_threshold:
self.is_dragging = True
self._start_drag_visual()
def _on_drag_end(self, event):
"""
Handle the end of a drag operation.
Determines whether the drag distance was sufficient to trigger
pane detachment and calls the detach callback if needed.
Args:
event: Tkinter event object containing final mouse position.
"""
if self.is_dragging:
self._end_drag_visual()
# Check if we should detach
dx = abs(event.x_root - self.drag_start_x)
dy = abs(event.y_root - self.drag_start_y)
if dx > 50 or dy > 50: # Detach threshold
self.on_detach()
self.is_dragging = False
def _start_drag_visual(self):
"""Start drag visual feedback."""
theme = self.theme_manager.get_current_theme()
self.configure(bg=theme.colors.drag_indicator)
self.grip_frame.configure(bg=theme.colors.drag_indicator)
def _end_drag_visual(self):
"""End drag visual feedback."""
theme = self.theme_manager.get_current_theme()
self.configure(bg=theme.colors.panel_header_bg)
self.grip_frame.configure(bg=theme.colors.panel_header_bg)
[docs]
class DetachedWindow(tk.Toplevel):
"""
Professional detached window for displaying pane content in a separate window.
This class creates a standalone window that can display pane content when
a pane is detached from the main three-pane layout. It supports custom
titlebars, theming, scrollable content, and reattachment functionality.
"""
def _is_icon_file(self, path: str) -> bool:
"""Check if a string is likely an icon file path."""
return platform_handler._is_icon_file(path)
def __init__(
self,
parent,
pane_side: str,
config: PaneConfig,
content_builder: Callable,
on_reattach: Callable,
theme_manager: ThemeManager,
layout_instance=None,
**kwargs,
):
"""
Initialize detached window with configuration and callbacks.
Args:
parent: The parent window (usually the main application window).
pane_side (str): Which pane this window represents ('left', 'center', 'right').
config (PaneConfig): Configuration object defining window behavior and appearance.
content_builder (Callable): Function to call to build the window's content.
on_reattach (Callable): Callback function to call when the window should be reattached.
theme_manager (ThemeManager): Theme manager for consistent styling.
layout_instance: Reference to the main layout instance (optional).
**kwargs: Additional keyword arguments passed to tk.Toplevel.
"""
super().__init__(parent, **kwargs)
self.pane_side = pane_side
self.config = config
self.content_builder = content_builder
self.on_reattach = on_reattach
self.theme_manager = theme_manager
self.layout_instance = layout_instance # Reference to the main layout
self._setup_window()
self._setup_ui()
def _setup_window(self):
"""Set up the detached window."""
theme = self.theme_manager.get_current_theme()
# Window properties
if self.config.custom_titlebar:
# Set window title first (for taskbar identification)
self.title(f"{self.config.title or self.pane_side.title()} Panel")
# Use overrideredirect for all platforms
self.overrideredirect(True)
else:
self.title(f"{self.config.title or self.pane_side.title()} Panel")
# Set window size - use custom height if specified
window_height = (
self.config.detached_height if self.config.detached_height > 0 else 400
)
self.geometry(f"{self.config.default_width}x{window_height}")
self.minsize(self.config.min_width, 200)
if self.config.max_width > 0:
self.maxsize(self.config.max_width, 2000)
# Window styling
if self.config.custom_titlebar:
# Always create a border for custom title bar windows
# The difference is in the border style (shadow vs clean)
self._setup_windows_border(theme)
else:
self.configure(bg=theme.colors.primary_bg)
# Window icon (if available)
# Use window_icon if provided, otherwise use icon only if it's a file path
icon_path = self.config.window_icon or (
self.config.icon if self._is_icon_file(self.config.icon) else ""
)
if icon_path:
self._set_window_icon(icon_path)
# Setup focus management for better user experience
self._setup_focus_management()
# Handle window close
self.protocol("WM_DELETE_WINDOW", self._on_window_close)
# Make window appear professional
self.transient(self.master)
# Platform-specific window behavior
self._setup_platform_specific_behavior()
self.focus_set()
def _setup_platform_specific_behavior(self):
"""Set up platform-specific window behavior."""
import platform
system = platform.system()
if system == "Darwin": # macOS
# macOS-specific adjustments
if self.config.custom_titlebar:
# On macOS, overrideredirect windows may have issues
# Consider using attributes instead
try:
self.attributes("-titlebar", False)
except tk.TclError:
# Fall back to overrideredirect if attributes not supported
pass
elif system == "Linux":
# Linux-specific adjustments
if self.config.custom_titlebar:
# Some Linux window managers handle overrideredirect differently
try:
# Try to set window type hint for better behavior
self.attributes("-type", "dialog")
except tk.TclError:
pass
elif system == "Windows":
# Windows-specific adjustments are now handled in _setup_window
pass
def _set_window_icon(self, icon_path: str):
"""Set window icon with cross-platform compatibility."""
platform_handler.set_window_icon(self, icon_path)
def _setup_ui(self):
"""Set up the UI."""
theme = self.theme_manager.get_current_theme()
# Determine the parent container (border frame if using Windows border,
# otherwise self)
parent_container = getattr(self, "_border_frame", self)
# Header with reattach button (and custom title bar if needed)
header_height = 32 # Keep same height for consistency
if self.config.custom_titlebar:
# Add a header that matches Windows title bar style
header_frame = tk.Frame(
parent_container,
bg=theme.colors.panel_header_bg,
height=header_height,
relief="flat",
borderwidth=0,
)
else:
header_frame = tk.Frame(
parent_container, bg=theme.colors.panel_header_bg, height=header_height
)
header_frame.pack(fill="x", padx=0, pady=0)
header_frame.pack_propagate(False)
# Custom title bar controls (if using custom title bar)
controls_frame = None
if self.config.custom_titlebar:
controls_frame = self._setup_custom_titlebar(header_frame, theme)
# Title and Icon
if self.config.title:
# For detached windows, we'll create separate icon and title elements
# to handle both text and image icons properly
# Check if title already includes the icon to avoid duplication
# For text icons (emojis), check if title starts with the icon
# For file icons, show them separately since titles won't contain images
title_includes_icon = (
self.config.icon
and not self._is_icon_file(self.config.icon) # Only check for text
and self.config.title.startswith(self.config.icon)
)
# Create a container for icon and title
title_container = tk.Frame(header_frame, bg=theme.colors.panel_header_bg)
title_container.pack(side="left", padx=8, pady=6, fill="x", expand=True)
# Add icon if present and not already in title
if self.config.icon and not title_includes_icon:
icon_label = self._create_detached_icon_label(title_container, theme)
if icon_label:
icon_label.pack(side="left", padx=(0, 4))
# Add title
title_label = tk.Label(
title_container,
text=self.config.title,
bg=theme.colors.panel_header_bg,
fg=theme.colors.panel_header_fg,
font=(
theme.typography.font_family,
theme.typography.font_size_normal,
"bold",
),
anchor="w", # Left align to prevent truncation
)
title_label.pack(side="left", fill="x", expand=True)
# Make title label draggable too (for custom title bar)
if self.config.custom_titlebar:
title_label.bind("<Button-1>", self._start_drag)
title_label.bind("<B1-Motion>", self._on_drag)
# Reattach button - match the detach button style exactly
if self.config.custom_titlebar:
# For custom title bar, put reattach button in controls frame
# instead of close button
reattach_btn = tk.Button(
controls_frame, # Use controls_frame instead of header_frame
text="⧈",
command=self.on_reattach,
width=2,
height=1,
bg=theme.colors.panel_header_bg,
fg=theme.colors.secondary_text,
activebackground=theme.colors.accent_bg,
activeforeground=theme.colors.accent_text,
relief="flat",
borderwidth=0,
cursor="hand2",
font=(theme.typography.font_family, theme.typography.font_size_small),
)
reattach_btn.pack(side="right", padx=2)
else:
# For regular title bar, put in header frame
reattach_btn = tk.Button(
header_frame,
text="⧈",
command=self.on_reattach,
width=2,
height=1,
bg=theme.colors.panel_header_bg,
fg=theme.colors.secondary_text,
activebackground=theme.colors.accent_bg,
activeforeground=theme.colors.accent_text,
relief="flat",
borderwidth=0,
cursor="hand2",
font=(theme.typography.font_family, theme.typography.font_size_small),
)
reattach_btn.pack(side="right", padx=8, pady=4)
# Add hover effects to match detach button
def on_enter_reattach(e):
reattach_btn.configure(
bg=theme.colors.accent_bg, fg=theme.colors.accent_text
)
def on_leave_reattach(e):
reattach_btn.configure(
bg=theme.colors.panel_header_bg, fg=theme.colors.secondary_text
)
reattach_btn.bind("<Enter>", on_enter_reattach)
reattach_btn.bind("<Leave>", on_leave_reattach)
# Separator
separator = tk.Frame(parent_container, bg=theme.colors.separator, height=1)
separator.pack(fill="x")
# Content frame - with optional scrollbars
if self.config.detached_scrollable and self.config.detached_height > 0:
# Create scrollable content area
self._setup_scrollable_content(theme, parent_container)
else:
# Regular content frame
self.content_frame = tk.Frame(
parent_container, bg=theme.colors.panel_content_bg
)
self.content_frame.pack(fill="both", expand=True, padx=0, pady=0)
# Build content
if self.content_builder:
self.content_builder(self.content_frame)
def _setup_custom_titlebar(self, header_frame, theme):
"""Set up custom title bar with window controls."""
# Make the header draggable
self._drag_data = {"x": 0, "y": 0}
header_frame.bind("<Button-1>", self._start_drag)
header_frame.bind("<B1-Motion>", self._on_drag)
# Window control buttons frame (right side)
controls_frame = tk.Frame(header_frame, bg=theme.colors.panel_header_bg)
controls_frame.pack(side="right", padx=4, pady=4)
# Note: Close button replaced by reattach button in the main UI setup
# Note: Minimize button removed for custom title bars since overrideredirect
# windows can't be properly minimized
return controls_frame
def _setup_windows_border(self, theme):
"""Set up Windows-style border for custom title bar windows."""
if self.config.custom_titlebar_shadow:
# Windows-style border with shadow effect
border_bg = "#2d2d30" # Slightly darker for shadow effect
border_color = "#3c3c3c" # Windows-like border color
else:
# Clean border without shadow
border_bg = "#404040" # Clean dark border
border_color = "#505050" # Lighter border for clean look
# Configure the main window with border
self.configure(
bg=border_bg,
highlightbackground=border_color,
highlightthickness=1,
relief="flat",
)
# Create an inner frame that will contain all content
self._border_frame = tk.Frame(
self, bg=theme.colors.primary_bg, relief="flat", borderwidth=0
)
self._border_frame.pack(fill="both", expand=True, padx=1, pady=1)
# Update the parent for all subsequent UI elements
self._content_parent = self._border_frame
def _setup_scrollable_content(self, theme, parent=None):
"""Set up scrollable content area for detached windows."""
if parent is None:
parent = self
# Create canvas and scrollbar for scrolling
canvas = tk.Canvas(
parent, bg=theme.colors.panel_content_bg, highlightthickness=0
)
scrollbar_v = tk.Scrollbar(parent, orient="vertical", command=canvas.yview)
scrollbar_h = tk.Scrollbar(parent, orient="horizontal", command=canvas.xview)
# Configure canvas scrolling
canvas.configure(yscrollcommand=scrollbar_v.set, xscrollcommand=scrollbar_h.set)
# Create the actual content frame inside the canvas
self.content_frame = tk.Frame(canvas, bg=theme.colors.panel_content_bg)
# Add content frame to canvas
canvas_window = canvas.create_window(
(0, 0), window=self.content_frame, anchor="nw"
)
# Pack scrollbars and canvas
scrollbar_v.pack(side="right", fill="y")
scrollbar_h.pack(side="bottom", fill="x")
canvas.pack(fill="both", expand=True)
# Update scroll region when content changes
def configure_scroll_region(event=None):
canvas.configure(scrollregion=canvas.bbox("all"))
# Also update the canvas window width to match canvas width
canvas_width = canvas.winfo_width()
if canvas_width > 1: # Avoid issues during initialization
canvas.itemconfig(canvas_window, width=canvas_width)
self.content_frame.bind("<Configure>", configure_scroll_region)
canvas.bind("<Configure>", configure_scroll_region)
# Enable mouse wheel scrolling
def on_mousewheel(event):
canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
# Bind mouse wheel to canvas and content frame
canvas.bind("<MouseWheel>", on_mousewheel)
self.content_frame.bind("<MouseWheel>", on_mousewheel)
# Store references for cleanup
self._canvas = canvas
self._scrollbar_v = scrollbar_v
self._scrollbar_h = scrollbar_h
def _start_drag(self, event):
"""Start dragging the window and bring it to front."""
# Bring window to front and give it focus when clicked
self.lift()
self.focus_set()
# Store drag data for dragging functionality
self._drag_data["x"] = event.x
self._drag_data["y"] = event.y
def _on_drag(self, event):
"""Handle window dragging."""
x = self.winfo_x() + (event.x - self._drag_data["x"])
y = self.winfo_y() + (event.y - self._drag_data["y"])
self.geometry(f"+{x}+{y}")
def _setup_focus_management(self):
"""Set up comprehensive focus management for the detached window."""
def bring_to_front(event=None):
"""Bring the window to front and give it focus."""
try:
self.lift()
self.focus_set()
# Temporarily set topmost to ensure it comes to front, then remove it
self.attributes("-topmost", True)
self.after_idle(lambda: self.attributes("-topmost", False))
except (tk.TclError, AttributeError):
# Ignore specific errors in focus management (window may be destroyed)
# Log could be added here if needed for debugging
pass # nosec B110
def bind_focus_recursively(widget):
"""Recursively bind focus events to all child widgets."""
try:
# Bind click events to bring window to front
widget.bind("<Button-1>", lambda e: bring_to_front(), add=True)
widget.bind("<FocusIn>", bring_to_front, add=True)
# Recursively bind to all children
for child in widget.winfo_children():
bind_focus_recursively(child)
except (tk.TclError, AttributeError):
# Ignore specific binding errors for widgets that don't support events
# Log could be added here if needed for debugging
pass # nosec B110
# Bind to the main window
self.bind("<Button-1>", lambda e: bring_to_front(), add=True)
self.bind("<FocusIn>", bring_to_front, add=True)
# Bind to all child widgets
bind_focus_recursively(self)
def _on_window_close(self):
"""Handle window close."""
self.on_reattach()
[docs]
def refresh_theme(self):
"""Refresh the detached window with the current theme."""
try:
# Update the theme manager reference to ensure it's current
_ = self.theme_manager.get_current_theme()
# Clear and recreate the UI with new theme
for child in self.winfo_children():
child.destroy()
# Reset any internal references that might have been destroyed
if hasattr(self, "_border_frame"):
delattr(self, "_border_frame")
if hasattr(self, "_content_parent"):
delattr(self, "_content_parent")
# Recreate the window setup (including borders for custom titlebar)
self._setup_window()
self._setup_ui()
# Note: The content frame is recreated in _setup_ui() with the new theme,
# so no additional theme update is needed for the content
except tk.TclError:
# Window has been destroyed, ignore
pass
except Exception as e:
logger.error("Error refreshing detached window theme: %s", e)
def _create_detached_icon_label(self, parent, theme):
"""Create an icon label for detached window (text or image)."""
if not self.config.icon:
return None
try:
if self._is_icon_file(self.config.icon):
# Try to load as image file
import os
if os.path.exists(self.config.icon):
try:
photo = tk.PhotoImage(file=self.config.icon)
# Resize if too large (optional - you can adjust these values)
if photo.width() > 16 or photo.height() > 16:
# For now, just use the image as-is
# You could add resizing logic here if needed
pass
icon_label = tk.Label(
parent,
image=photo,
bg=theme.colors.panel_header_bg,
)
# Keep a reference to prevent garbage collection
icon_label.image = photo
return icon_label
except tk.TclError:
# If image loading fails, fall back to text
pass
# Use as text icon (emoji or text)
icon_label = tk.Label(
parent,
text=self.config.icon,
bg=theme.colors.panel_header_bg,
fg=theme.colors.panel_header_fg,
font=(theme.typography.font_family, theme.typography.font_size_normal),
)
return icon_label
except Exception as e:
logger.warning("Failed to create detached icon label: %s", e)
return None
[docs]
class EnhancedDockableThreePaneWindow(tk.Frame):
"""
Enhanced professional dockable three-pane window with theming and advanced UI.
Features:
- Professional theming system
- Drag-and-drop detaching
- Smooth animations
- Customizable pane configurations
- Professional visual feedback
"""
[docs]
def __init__(
self,
master=None,
left_config: Optional[PaneConfig] = None,
center_config: Optional[PaneConfig] = None,
right_config: Optional[PaneConfig] = None,
left_builder: Optional[Callable] = None,
center_builder: Optional[Callable] = None,
right_builder: Optional[Callable] = None,
theme_name: str = "light",
theme=None, # Alternative parameter name for theme_name
enable_animations: bool = True,
menu_bar: Optional[tk.Menu] = None,
show_status_bar: bool = False,
show_toolbar: bool = False,
**kwargs,
):
"""
Initialize enhanced dockable three-pane window with configuration options.
Args:
master: Parent widget (usually the root window).
left_config (Optional[PaneConfig]): Configuration for the left pane.
center_config (Optional[PaneConfig]): Configuration for the center pane.
right_config (Optional[PaneConfig]): Configuration for the right pane.
left_builder (Optional[Callable]): Function to build left pane content.
center_builder (Optional[Callable]): Function to build center pane content.
right_builder (Optional[Callable]): Function to build right pane content.
theme_name (str): Name of the theme to use ("light", "dark", "blue", "native").
theme: Alternative parameter for theme_name (supports ThemeType enum).
enable_animations (bool): Whether to enable smooth animations.
menu_bar (Optional[tk.Menu]): Menu bar to attach to the parent window.
show_status_bar (bool): Whether to show a status bar at the bottom.
show_toolbar (bool): Whether to show a toolbar at the top.
**kwargs: Additional keyword arguments passed to tk.Frame.
"""
super().__init__(master, **kwargs)
# Configuration
self.left_config = left_config or PaneConfig(title="Left Panel", icon="📁")
self.center_config = center_config or PaneConfig(
title="Main Content", icon="📝", detachable=False
)
self.right_config = right_config or PaneConfig(title="Right Panel", icon="🔧")
# Builders
self.left_builder = left_builder
self.center_builder = center_builder
self.right_builder = right_builder
# Theme management - handle both theme_name and theme parameters
# Use the global theme manager to ensure synchronization
self.theme_manager = get_theme_manager()
if theme is not None:
# Handle ThemeType enum or string
if hasattr(theme, "value"):
theme_name = theme.value
else:
theme_name = str(theme)
# Validate theme name and set titlebar color
if not self.theme_manager.set_theme(theme_name, window=master):
raise ValueError(f"Invalid theme: {theme_name}")
# Animation settings
self.enable_animations = enable_animations
# Menu bar
self.menu_bar = menu_bar
# UI components
self.show_status_bar = show_status_bar
self.show_toolbar = show_toolbar
self.status_bar = None
self.toolbar = None
# State tracking
self.detached_windows: Dict[str, DetachedWindow] = {}
self.pane_frames: Dict[str, tk.Frame] = {}
self.pane_headers: Dict[str, PaneHeader] = {}
self.pane_positions: Dict[str, int] = {} # Track original positions
self.pane_visibility: Dict[str, bool] = (
{}
) # Track visibility (winfo_children() doesn't update immediately)
# Setup
self._setup_styles()
self._create_widgets()
def _setup_styles(self):
"""Set up TTK styles."""
self.style = ttk.Style()
self.theme_manager.apply_ttk_theme(self.style)
# Try to create a custom style for non-resizable paned windows
try:
# Create a style that makes sashes less visible/interactive
self.style.configure(
"FixedSash.TPanedwindow", sashwidth=1, sashrelief="flat"
)
self.style.map(
"FixedSash.TPanedwindow",
background=[
(
"active",
self.theme_manager.get_current_theme().colors.secondary_bg,
)
],
)
except tk.TclError:
pass
def _create_widgets(self):
"""Create the main widget structure."""
theme = self.theme_manager.get_current_theme()
# Main container
self.configure(bg=theme.colors.secondary_bg)
# Add menu bar if provided
if self.menu_bar:
# Create a frame to hold the menu bar
menu_frame = tk.Frame(self, bg=theme.colors.primary_bg, height=25)
menu_frame.pack(fill=tk.X, padx=0, pady=0)
menu_frame.pack_propagate(False)
# Configure the menu bar for this window
if hasattr(self.master, "config"):
self.master.config(menu=self.menu_bar)
# Add toolbar if requested
if self.show_toolbar:
self._create_toolbar()
# Determine if we need custom layout for fixed panes
# Only use custom layout if we have truly fixed panes
# (fixed_width or resizable=False)
self._has_fixed_panes = (
self.left_builder
and (
self.left_config.fixed_width is not None
or not self.left_config.resizable
)
) or (
self.right_builder
and (
self.right_config.fixed_width is not None
or not self.right_config.resizable
)
)
if self._has_fixed_panes:
# Use custom layout for fixed panes
self._create_custom_layout()
else:
# Use standard TTK PanedWindow for fully resizable layout
self.paned = ttk.PanedWindow(
self, orient=tk.HORIZONTAL, style="Themed.TPanedwindow"
)
self.paned.pack(fill=tk.BOTH, expand=True, padx=2, pady=2)
# Create panes
self._create_left_pane()
self._create_center_pane()
self._create_right_pane()
# Add status bar if requested
if self.show_status_bar:
self._create_status_bar()
# Configure behavior after everything is created
if self._has_fixed_panes:
self.after_idle(self._trigger_custom_layout)
else:
self.after_idle(self._setup_fixed_pane_behavior)
def _create_left_pane(self):
"""Create the left pane."""
if not self.left_builder:
return
# Choose parent based on layout type
parent = self.layout_frame if self._has_fixed_panes else self.paned
container = ttk.Frame(parent, style="Themed.TFrame")
# Configure width based on settings
width = self.left_config.fixed_width or self.left_config.default_width
container.configure(width=width)
# If fixed width is set, prevent resizing
if self.left_config.fixed_width is not None:
container.pack_propagate(False)
# Header
header = PaneHeader(
container,
self.left_config,
"left",
lambda: self._detach_pane("left"),
None, # No close callback for now
self.theme_manager,
)
header.pack(fill="x", padx=0, pady=0)
# Content frame
content_frame = tk.Frame(
container, bg=self.theme_manager.get_current_theme().colors.panel_content_bg
)
content_frame.pack(fill="both", expand=True, padx=0, pady=0)
# Build content
self.left_builder(content_frame)
# Store references
self.pane_frames["left"] = container
self.pane_headers["left"] = header
self.pane_positions["left"] = 0 # Left pane is always at position 0
self.pane_visibility["left"] = True
# Add to layout
if self._has_fixed_panes:
# Custom layout - pane will be positioned by _handle_custom_resize
pass
else:
# Add to paned window with appropriate weight
weight = 0 if self.left_config.fixed_width is not None else 1
self.paned.add(container, weight=weight)
# Configure pane width constraints if fixed width is set
# (only for TTK PanedWindow)
if not self._has_fixed_panes and self.left_config.fixed_width is not None:
self.paned.after_idle(
lambda: self._configure_fixed_pane_width(
"left", self.left_config.fixed_width
)
)
def _create_center_pane(self):
"""Create the center pane."""
if not self.center_builder:
return
# Choose parent based on layout type
parent = self.layout_frame if self._has_fixed_panes else self.paned
container = ttk.Frame(parent, style="Themed.TFrame")
# Header (if title is provided)
if self.center_config.title:
header = PaneHeader(
container,
self.center_config,
"center",
lambda: (
self._detach_pane("center")
if self.center_config.detachable
else None
),
None,
self.theme_manager,
)
header.pack(fill="x", padx=0, pady=0)
self.pane_headers["center"] = header
# Content frame
content_frame = tk.Frame(
container, bg=self.theme_manager.get_current_theme().colors.panel_content_bg
)
content_frame.pack(fill="both", expand=True, padx=0, pady=0)
# Build content
self.center_builder(content_frame)
# Store references
self.pane_frames["center"] = container
center_position = 1 # Center is typically at position 1
if "left" not in self.pane_frames:
center_position = 0 # If no left pane, center starts at 0
self.pane_positions["center"] = center_position
self.pane_visibility["center"] = True
# Add to layout
if self._has_fixed_panes:
# Custom layout - pane will be positioned by _handle_custom_resize
pass
else:
# Add to paned window
self.paned.add(container, weight=3)
def _create_right_pane(self):
"""Create the right pane."""
if not self.right_builder:
return
# Choose parent based on layout type
parent = self.layout_frame if self._has_fixed_panes else self.paned
container = ttk.Frame(parent, style="Themed.TFrame")
# Configure width based on settings
width = self.right_config.fixed_width or self.right_config.default_width
container.configure(width=width)
# If fixed width is set, prevent resizing
if self.right_config.fixed_width is not None:
container.pack_propagate(False)
# Header
header = PaneHeader(
container,
self.right_config,
"right",
lambda: self._detach_pane("right"),
None, # No close callback for now
self.theme_manager,
)
header.pack(fill="x", padx=0, pady=0)
# Content frame
content_frame = tk.Frame(
container, bg=self.theme_manager.get_current_theme().colors.panel_content_bg
)
content_frame.pack(fill="both", expand=True, padx=0, pady=0)
# Build content
self.right_builder(content_frame)
# Store references
self.pane_frames["right"] = container
self.pane_headers["right"] = header
# Right pane position depends on what other panes exist
right_position = len(self.pane_frames) # Will be the last position
self.pane_positions["right"] = right_position
self.pane_visibility["right"] = True
# Add to layout
if self._has_fixed_panes:
# Custom layout - pane will be positioned by _handle_custom_resize
pass
else:
# Add to paned window with appropriate weight
weight = 0 if self.right_config.fixed_width is not None else 1
self.paned.add(container, weight=weight)
# Configure pane width constraints if fixed width is set
# (only for TTK PanedWindow)
if not self._has_fixed_panes and self.right_config.fixed_width is not None:
self.paned.after_idle(
lambda: self._configure_fixed_pane_width(
"right", self.right_config.fixed_width
)
)
def _configure_pane_width(self, pane_side: str, width: int):
"""Configure a pane to have a specific width (but still resizable)."""
if pane_side not in self.pane_frames:
return
container = self.pane_frames[pane_side]
# Get the pane index in the PanedWindow
pane_index = None
for i, child in enumerate(self.paned.winfo_children()):
if child == container:
pane_index = i
break
if pane_index is not None:
try:
# Force update the layout first
self.paned.update_idletasks()
# Set the initial width but allow resizing
self.paned.paneconfig(pane_index, width=width)
# Set reasonable minimum size
config = getattr(self, f"{pane_side}_config", None)
if config:
self.paned.paneconfig(pane_index, minsize=config.min_width)
# Force the paned window to respect the width multiple times
for delay in [10, 50, 100, 200, 500]:
self.paned.after(
delay,
lambda w=width, idx=pane_index: self.paned.paneconfig(
idx, width=w
),
)
# Also try to force the container width
container.configure(width=width)
self.paned.after(10, lambda: container.configure(width=width))
except tk.TclError:
# Fallback: just set the container width
container.configure(width=width)
container.pack_propagate(False)
def _configure_fixed_pane_width(self, pane_side: str, fixed_width: int):
"""
Configure a pane to have a fixed width that cannot be resized by the user.
This method sets both minimum and maximum width constraints to the same value,
effectively preventing the pane from being resized through the sash.
Args:
pane_side (str): Which pane to configure ('left', 'center', 'right').
fixed_width (int): The fixed width in pixels to set for the pane.
"""
if pane_side not in self.pane_frames:
return
container = self.pane_frames[pane_side]
# Get the pane index in the PanedWindow
pane_index = None
for i, child in enumerate(self.paned.winfo_children()):
if child == container:
pane_index = i
break
if pane_index is not None:
# Configure the pane to have fixed width
try:
# Set minimum and maximum width to the same value to prevent resizing
self.paned.paneconfig(
pane_index, minsize=fixed_width, width=fixed_width
)
# Try to set a maximum size if the ttk version supports it
try:
self.paned.paneconfig(pane_index, maxsize=fixed_width)
except tk.TclError:
# Some versions of ttk don't support maxsize, that's okay
pass
except tk.TclError:
# Fallback: just set the width
container.configure(width=fixed_width)
def _setup_fixed_pane_behavior(self):
"""Set up behavior for fixed-width and non-resizable panes."""
try:
# Force layout update
self.paned.update_idletasks()
# Configure each pane and store sash positions for fixed panes
pane_configs = [
("left", self.left_config),
("center", self.center_config),
("right", self.right_config),
]
for i, (pane_side, config) in enumerate(pane_configs):
if pane_side not in self.pane_frames:
continue
container = self.pane_frames[pane_side]
# Find pane index in the PanedWindow
pane_index = None
for j, child in enumerate(self.paned.winfo_children()):
if child == container:
pane_index = j
break
if pane_index is None:
continue
# Configure pane constraints
if config.fixed_width is not None:
# Fixed width pane
self.paned.paneconfig(
pane_index, minsize=config.fixed_width, width=config.fixed_width
)
try:
self.paned.paneconfig(pane_index, maxsize=config.fixed_width)
except tk.TclError:
pass
elif not config.resizable:
# Non-resizable pane (use default width as fixed)
fixed_width = config.default_width
self.paned.paneconfig(
pane_index, minsize=fixed_width, width=fixed_width
)
try:
self.paned.paneconfig(pane_index, maxsize=fixed_width)
except tk.TclError:
pass
else:
# Resizable pane - set reasonable constraints
self.paned.paneconfig(
pane_index, minsize=config.min_width, width=config.default_width
)
# More aggressive approach: continuously monitor and reset sash positions
self._monitor_sash_positions()
except (tk.TclError, AttributeError):
# Layout might not be ready yet
pass
def _monitor_sash_positions(self):
"""Continuously monitor and reset sash positions for fixed panes."""
try:
# Calculate expected sash positions based on fixed pane widths
expected_positions = self._calculate_expected_sash_positions()
# Check and reset sash positions if they've moved
num_panes = len(self.paned.winfo_children())
for i in range(num_panes - 1): # Number of sashes = number of panes - 1
try:
current_pos = self.paned.sashpos(i)
expected_pos = expected_positions.get(i)
if expected_pos is not None and abs(current_pos - expected_pos) > 2:
# Sash has moved from expected position, reset it
self.paned.sashpos(i, expected_pos)
except tk.TclError:
pass
# Schedule next check
self.after(50, self._monitor_sash_positions)
except (tk.TclError, AttributeError):
# Widget might be destroyed, stop monitoring
pass
def _calculate_expected_sash_positions(self):
"""Calculate where sashes should be positioned based on fixed pane widths."""
expected_positions = {}
try:
pane_configs = [
("left", self.left_config),
("center", self.center_config),
("right", self.right_config),
]
# Get visible panes in order
visible_panes = []
for pane_side, config in pane_configs:
if pane_side in self.pane_frames:
visible_panes.append((pane_side, config))
# Calculate cumulative widths to determine sash positions
cumulative_width = 0
for i, (pane_side, config) in enumerate(
visible_panes[:-1]
): # Exclude last pane
# Determine width for this pane
if config.fixed_width is not None:
pane_width = config.fixed_width
elif not config.resizable:
pane_width = config.default_width
else:
# For resizable panes, get current width from the paned window
try:
container = self.pane_frames[pane_side]
pane_index = None
for j, child in enumerate(self.paned.winfo_children()):
if child == container:
pane_index = j
break
if pane_index is not None:
pane_width = container.winfo_width()
else:
pane_width = config.default_width
except (tk.TclError, AttributeError):
pane_width = config.default_width
cumulative_width += pane_width
expected_positions[i] = cumulative_width
except (AttributeError, IndexError):
pass
return expected_positions
def _setup_sash_disabling(self):
"""Try to disable sash interaction using various methods."""
try:
# Method 1: Override the sash cursor to indicate non-resizable
self.paned.configure(cursor="arrow") # Instead of resize cursor
# Method 2: Bind to all mouse events on the paned window to
# intercept sash interactions
self.paned.bind("<Button-1>", self._intercept_sash_click, add=True)
self.paned.bind("<B1-Motion>", self._intercept_sash_drag, add=True)
self.paned.bind(
"<Double-Button-1>", self._intercept_sash_double_click, add=True
)
except tk.TclError:
pass
def _intercept_sash_click(self, event):
"""Intercept sash clicks and prevent them for fixed panes."""
try:
# Check if click is on a sash that should be disabled
if self._is_click_on_disabled_sash(event):
return "break" # Prevent the event from propagating
except (tk.TclError, AttributeError):
pass
def _intercept_sash_drag(self, event):
"""Intercept sash drags and prevent them for fixed panes."""
try:
# Check if drag is on a sash that should be disabled
if self._is_click_on_disabled_sash(event):
return "break" # Prevent the event from propagating
except (tk.TclError, AttributeError):
pass
def _intercept_sash_double_click(self, event):
"""Intercept sash double-clicks and prevent them for fixed panes."""
try:
# Check if double-click is on a sash that should be disabled
if self._is_click_on_disabled_sash(event):
return "break" # Prevent the event from propagating
except (tk.TclError, AttributeError):
pass
def _is_click_on_disabled_sash(self, event):
"""Check if a click/drag event is on a sash that should be disabled."""
try:
num_panes = len(self.paned.winfo_children())
for i in range(num_panes - 1):
try:
sash_pos = self.paned.sashpos(i)
# Check if click is near this sash (within 5 pixels)
if abs(event.x - sash_pos) <= 5:
# Check if either adjacent pane is fixed
left_pane_fixed = self._is_pane_at_index_fixed(i)
right_pane_fixed = self._is_pane_at_index_fixed(i + 1)
if left_pane_fixed or right_pane_fixed:
return True # This sash should be disabled
except tk.TclError:
pass
except (tk.TclError, AttributeError):
pass
return False
def _is_pane_at_index_fixed(self, pane_index):
"""Check if a pane at the given index is fixed (non-resizable)."""
try:
pane_configs = [
("left", self.left_config),
("center", self.center_config),
("right", self.right_config),
]
# Map pane index to config
visible_panes = []
for pane_side, config in pane_configs:
if pane_side in self.pane_frames:
visible_panes.append((pane_side, config))
if 0 <= pane_index < len(visible_panes):
_, config = visible_panes[pane_index]
return config.fixed_width is not None or not config.resizable
except (AttributeError, IndexError):
pass
return False
def _create_custom_layout(self):
"""Create custom layout for windows with fixed panes."""
# Create a main container frame
self.layout_frame = tk.Frame(
self, bg=self.theme_manager.get_current_theme().colors.secondary_bg
)
self.layout_frame.pack(fill=tk.BOTH, expand=True, padx=2, pady=2)
# Bind to resize events to handle layout
self.layout_frame.bind("<Configure>", self._handle_custom_resize)
# Create visual sashes (non-interactive)
self._create_visual_sashes()
def _create_visual_sashes(self):
"""Create visual sashes that look like PanedWindow sashes but aren't interactive."""
theme = self.theme_manager.get_current_theme()
# Left sash (between left and center)
if self.left_builder and self.center_builder:
self.left_sash = tk.Frame(
self.layout_frame,
bg=theme.colors.border,
width=2,
cursor="arrow", # Non-resize cursor
)
# Right sash (between center and right)
if self.center_builder and self.right_builder:
self.right_sash = tk.Frame(
self.layout_frame,
bg=theme.colors.border,
width=2,
cursor="arrow", # Non-resize cursor
)
def _handle_custom_resize(self, event=None):
"""Handle resize events for custom layout."""
if not hasattr(self, "layout_frame"):
return
try:
container_width, container_height = self._get_container_dimensions()
if not self._is_container_ready(container_width, container_height):
return
# Get attachment states and calculate dimensions
attachment_states = self._get_pane_attachment_states()
pane_widths = self._calculate_pane_widths(
container_width, attachment_states
)
# Position all panes
self._position_panes(pane_widths, container_height, attachment_states)
except (tk.TclError, AttributeError):
# Layout not ready or widget destroyed
pass
def _get_container_dimensions(self):
"""Get the container dimensions."""
container_width = self.layout_frame.winfo_width()
container_height = self.layout_frame.winfo_height()
return container_width, container_height
def _is_container_ready(self, width, height):
"""Check if container dimensions are ready for layout."""
return width > 1 and height > 1
def _get_pane_attachment_states(self):
"""Get the attachment state for all panes."""
return {
"left": self.left_builder and "left" in self.pane_frames,
"center": self.center_builder and "center" in self.pane_frames,
"right": self.right_builder and "right" in self.pane_frames,
}
def _calculate_pane_widths(self, container_width, attachment_states):
"""Calculate widths for all panes based on their configurations."""
sash_width = 2
# Calculate individual pane widths
left_width = self._calculate_left_pane_width(attachment_states["left"])
right_width = self._calculate_right_pane_width(attachment_states["right"])
# Calculate sashes width
sashes_width = self._calculate_sashes_width(attachment_states, sash_width)
# Calculate center width (remaining space)
center_width = container_width - left_width - right_width - sashes_width
center_width = max(center_width, 50) # Ensure minimum width
return {
"left": left_width,
"center": center_width,
"right": right_width,
"sash": sash_width,
}
def _calculate_left_pane_width(self, left_attached):
"""Calculate the width for the left pane."""
if not left_attached:
return 0
if self.left_config.fixed_width is not None:
return self.left_config.fixed_width
else:
return self.left_config.default_width
def _calculate_right_pane_width(self, right_attached):
"""Calculate the width for the right pane."""
if not right_attached:
return 0
if self.right_config.fixed_width is not None:
return self.right_config.fixed_width
else:
return self.right_config.default_width
def _calculate_sashes_width(self, attachment_states, sash_width):
"""Calculate the total width needed for sashes."""
sashes_width = 0
if attachment_states["left"] and attachment_states["center"]:
sashes_width += sash_width
if attachment_states["center"] and attachment_states["right"]:
sashes_width += sash_width
return sashes_width
def _position_panes(self, pane_widths, container_height, attachment_states):
"""Position all panes and sashes."""
x_pos = 0
# Position left pane and its sash
if attachment_states["left"]:
x_pos = self._position_left_pane(
x_pos, pane_widths, container_height, attachment_states
)
# Position center pane and its sash
if attachment_states["center"]:
x_pos = self._position_center_pane(
x_pos, pane_widths, container_height, attachment_states
)
# Position right pane
if attachment_states["right"]:
self._position_right_pane(x_pos, pane_widths, container_height)
def _position_left_pane(
self, x_pos, pane_widths, container_height, attachment_states
):
"""Position the left pane and its sash."""
# Position left pane
self.pane_frames["left"].place(
x=x_pos, y=0, width=pane_widths["left"], height=container_height
)
x_pos += pane_widths["left"]
# Position left sash if center is attached
if attachment_states["center"] and hasattr(self, "left_sash"):
self.left_sash.place(
x=x_pos, y=0, width=pane_widths["sash"], height=container_height
)
x_pos += pane_widths["sash"]
elif hasattr(self, "left_sash"):
self.left_sash.place_forget()
return x_pos
def _position_center_pane(
self, x_pos, pane_widths, container_height, attachment_states
):
"""Position the center pane and its sash."""
# Position center pane
self.pane_frames["center"].place(
x=x_pos, y=0, width=pane_widths["center"], height=container_height
)
x_pos += pane_widths["center"]
# Position right sash if right is attached
if attachment_states["right"] and hasattr(self, "right_sash"):
self.right_sash.place(
x=x_pos, y=0, width=pane_widths["sash"], height=container_height
)
x_pos += pane_widths["sash"]
elif hasattr(self, "right_sash"):
self.right_sash.place_forget()
return x_pos
def _position_right_pane(self, x_pos, pane_widths, container_height):
"""Position the right pane."""
self.pane_frames["right"].place(
x=x_pos, y=0, width=pane_widths["right"], height=container_height
)
def _trigger_custom_layout(self):
"""Trigger the initial custom layout."""
if hasattr(self, "layout_frame"):
self._handle_custom_resize()
def _detach_pane(self, pane_side: str):
"""Detach a pane to a separate window."""
if pane_side in self.detached_windows:
return # Already detached
# Get configuration and builder
config = getattr(self, f"{pane_side}_config")
builder = getattr(self, f"{pane_side}_builder")
if not config.detachable or not builder:
return
# Store the original position before removing
original_position = self.pane_positions.get(pane_side, 0)
# Remove from layout
if pane_side in self.pane_frames:
if self._has_fixed_panes:
# Custom layout - just hide the pane
self.pane_frames[pane_side].place_forget()
else:
# TTK PanedWindow layout
self.paned.forget(self.pane_frames[pane_side])
self.pane_frames[pane_side].destroy()
del self.pane_frames[pane_side]
# Keep the position info for reattaching
self.pane_positions[f"{pane_side}_detached"] = original_position
# Trigger layout update for custom layout
if self._has_fixed_panes:
self.after_idle(self._handle_custom_resize)
# Create detached window
detached_window = DetachedWindow(
self.winfo_toplevel(),
pane_side,
config,
builder,
lambda: self._reattach_pane(pane_side),
self.theme_manager,
layout_instance=self,
)
self.detached_windows[pane_side] = detached_window
# Position the window nicely
self._position_detached_window(detached_window, pane_side)
def _reattach_pane(self, pane_side: str):
"""Reattach a detached pane."""
if pane_side not in self.detached_windows:
return
# Get the original position
original_position = self.pane_positions.get(f"{pane_side}_detached", 0)
# Destroy detached window
self.detached_windows[pane_side].destroy()
del self.detached_windows[pane_side]
# Clean up position tracking
if f"{pane_side}_detached" in self.pane_positions:
del self.pane_positions[f"{pane_side}_detached"]
# Recreate the pane at the correct position
if pane_side == "left":
self._reattach_left_pane(original_position)
elif pane_side == "right":
self._reattach_right_pane(original_position)
elif pane_side == "center":
self._reattach_center_pane(original_position)
def _position_detached_window(self, window: DetachedWindow, pane_side: str):
"""Position a detached window nicely."""
# Get main window position
main_window = self.winfo_toplevel()
main_window.update_idletasks()
main_x = main_window.winfo_x()
main_y = main_window.winfo_y()
main_width = main_window.winfo_width()
# Calculate position based on pane side
if pane_side == "left":
x = main_x - window.config.default_width - 10
y = main_y
elif pane_side == "right":
x = main_x + main_width + 10
y = main_y
else: # center
x = main_x + (main_width - window.config.default_width) // 2
y = main_y - 50
# Ensure window is on screen
screen_width = window.winfo_screenwidth()
screen_height = window.winfo_screenheight()
x = max(0, min(x, screen_width - window.config.default_width))
y = max(0, min(y, screen_height - 400))
window.geometry(f"{window.config.default_width}x400+{x}+{y}")
# Ensure detached window appears in front
window.lift()
window.focus_force()
window.attributes("-topmost", True)
window.after(100, lambda: window.attributes("-topmost", False))
def _reattach_left_pane(self, position: int):
"""Reattach the left pane at the correct position."""
if not self.left_builder:
return
# Choose parent based on layout type
parent = self.layout_frame if self._has_fixed_panes else self.paned
container = ttk.Frame(parent, style="Themed.TFrame")
# Configure width based on settings
width = self.left_config.fixed_width or self.left_config.default_width
container.configure(width=width)
# If fixed width is set, prevent resizing
if self.left_config.fixed_width is not None:
container.pack_propagate(False)
# Header
header = PaneHeader(
container,
self.left_config,
"left",
lambda: self._detach_pane("left"),
None,
self.theme_manager,
)
header.pack(fill="x", padx=0, pady=0)
# Content frame
content_frame = tk.Frame(
container, bg=self.theme_manager.get_current_theme().colors.panel_content_bg
)
content_frame.pack(fill="both", expand=True, padx=0, pady=0)
# Build content
self.left_builder(content_frame)
# Store references
self.pane_frames["left"] = container
self.pane_headers["left"] = header
self.pane_positions["left"] = position
# Add to layout
if self._has_fixed_panes:
# Custom layout - trigger resize to position pane
self.after_idle(self._handle_custom_resize)
else:
# Insert at the correct position (left should always be at position 0)
weight = 0 if self.left_config.fixed_width is not None else 1
self.paned.insert(0, container, weight=weight)
# Configure pane width constraints
if self.left_config.fixed_width is not None:
self.paned.after_idle(
lambda: self._configure_fixed_pane_width(
"left", self.left_config.fixed_width
)
)
else:
# Configure default width for non-fixed panes
self.paned.after_idle(
lambda: self._configure_pane_width(
"left", self.left_config.default_width
)
)
def _reattach_right_pane(self, position: int):
"""Reattach the right pane at the correct position."""
if not self.right_builder:
return
# Choose parent based on layout type
parent = self.layout_frame if self._has_fixed_panes else self.paned
container = ttk.Frame(parent, style="Themed.TFrame")
# Configure width based on settings
width = self.right_config.fixed_width or self.right_config.default_width
container.configure(width=width)
# If fixed width is set, prevent resizing
if self.right_config.fixed_width is not None:
container.pack_propagate(False)
# Header
header = PaneHeader(
container,
self.right_config,
"right",
lambda: self._detach_pane("right"),
None,
self.theme_manager,
)
header.pack(fill="x", padx=0, pady=0)
# Content frame
content_frame = tk.Frame(
container, bg=self.theme_manager.get_current_theme().colors.panel_content_bg
)
content_frame.pack(fill="both", expand=True, padx=0, pady=0)
# Build content
self.right_builder(content_frame)
# Store references
self.pane_frames["right"] = container
self.pane_headers["right"] = header
self.pane_positions["right"] = position
# Add to layout
if self._has_fixed_panes:
# Custom layout - trigger resize to position pane
self.after_idle(self._handle_custom_resize)
else:
# Insert at the end (right pane should be last)
weight = 0 if self.right_config.fixed_width is not None else 1
self.paned.add(container, weight=weight)
# Configure pane width constraints
if self.right_config.fixed_width is not None:
self.paned.after_idle(
lambda: self._configure_fixed_pane_width(
"right", self.right_config.fixed_width
)
)
else:
# Configure default width for non-fixed panes
self.paned.after_idle(
lambda: self._configure_pane_width(
"right", self.right_config.default_width
)
)
def _reattach_center_pane(self, position: int):
"""Reattach the center pane at the correct position."""
if not self.center_builder:
return
# Choose parent based on layout type
parent = self.layout_frame if self._has_fixed_panes else self.paned
container = ttk.Frame(parent, style="Themed.TFrame")
# Header (if title is provided)
if self.center_config.title:
header = PaneHeader(
container,
self.center_config,
"center",
lambda: (
self._detach_pane("center")
if self.center_config.detachable
else None
),
None,
self.theme_manager,
)
header.pack(fill="x", padx=0, pady=0)
self.pane_headers["center"] = header
# Content frame
content_frame = tk.Frame(
container, bg=self.theme_manager.get_current_theme().colors.panel_content_bg
)
content_frame.pack(fill="both", expand=True, padx=0, pady=0)
# Build content
self.center_builder(content_frame)
# Store references
self.pane_frames["center"] = container
self.pane_positions["center"] = position
# Add to layout
if self._has_fixed_panes:
# Custom layout - trigger resize to position pane
self.after_idle(self._handle_custom_resize)
else:
# Insert at the correct position (center should be between left and right)
insert_position = 0
if "left" in self.pane_frames:
insert_position = 1
self.paned.insert(insert_position, container, weight=3)
[docs]
def set_theme(self, theme_name):
"""Change the theme."""
# Handle ThemeType enum or string
if hasattr(theme_name, "value"):
theme_name = theme_name.value
else:
theme_name = str(theme_name)
if self.theme_manager.set_theme(theme_name):
self._refresh_theme()
def _refresh_theme(self):
"""Refresh the theme for all components."""
self._setup_styles()
theme = self.theme_manager.get_current_theme()
# Update main components
self._refresh_main_container(theme)
self._refresh_paned_window()
self._refresh_toolbar(theme)
self._refresh_status_bar(theme)
# Update pane components
self._refresh_pane_headers()
self._refresh_pane_content_frames(theme)
self._refresh_detached_windows()
# Update custom widgets and force refresh
self._refresh_custom_widgets()
self.update_idletasks()
def _refresh_main_container(self, theme):
"""Refresh the main container background."""
self.configure(bg=theme.colors.secondary_bg)
def _refresh_paned_window(self):
"""Refresh the paned window style."""
if hasattr(self, "paned"):
self.paned.configure(style="Themed.TPanedwindow")
def _refresh_toolbar(self, theme):
"""Refresh the toolbar and its buttons."""
if not (hasattr(self, "toolbar_frame") and self.toolbar_frame):
return
self.toolbar_frame.configure(bg=theme.colors.primary_bg)
# Update toolbar buttons
for child in self.toolbar_frame.winfo_children():
if isinstance(child, tk.Button):
child.configure(
bg=theme.colors.button_bg,
fg=theme.colors.button_fg,
activebackground=theme.colors.button_hover,
)
def _refresh_status_bar(self, theme):
"""Refresh the status bar and its label."""
if not (hasattr(self, "status_bar") and self.status_bar):
return
self.status_bar.configure(bg=theme.colors.primary_bg)
if hasattr(self, "status_label") and self.status_label:
self.status_label.configure(
bg=theme.colors.primary_bg, fg=theme.colors.primary_text
)
def _refresh_pane_headers(self):
"""Refresh all pane headers."""
for pane_side, header in self.pane_headers.items():
if header:
header.refresh_theme()
def _refresh_pane_content_frames(self, theme):
"""Refresh pane content frames."""
for pane_side, frame in self.pane_frames.items():
if frame:
self._refresh_pane_content_children(frame, theme)
def _refresh_pane_content_children(self, frame, theme):
"""Refresh children of a pane content frame."""
for child in frame.winfo_children():
if isinstance(child, tk.Frame) and not isinstance(child, PaneHeader):
child.configure(bg=theme.colors.panel_content_bg)
def _refresh_detached_windows(self):
"""Refresh all detached windows."""
for window in self.detached_windows.values():
window.theme_manager = self.theme_manager
if hasattr(window, "refresh_theme"):
window.refresh_theme()
[docs]
def get_pane_frame(self, pane_side: str) -> Optional[tk.Frame]:
"""Get the content frame for a pane."""
if pane_side in self.detached_windows:
return self.detached_windows[pane_side].content_frame
elif pane_side in self.pane_frames:
# Find the content frame within the pane frame
for child in self.pane_frames[pane_side].winfo_children():
if isinstance(child, tk.Frame) and child != self.pane_headers.get(
pane_side
):
return child
return None
[docs]
def is_pane_detached(self, pane_side: str) -> bool:
"""Check if a pane is detached."""
return pane_side in self.detached_windows
[docs]
def set_pane_fixed_width(self, pane_side: str, width: int):
"""Set a pane to have a fixed width."""
if pane_side == "left":
self.left_config.fixed_width = width
elif pane_side == "right":
self.right_config.fixed_width = width
else:
return # Center pane doesn't support fixed width
# Apply the change if pane is currently attached
if pane_side in self.pane_frames:
container = self.pane_frames[pane_side]
container.configure(width=width)
container.pack_propagate(False)
self.after_idle(lambda: self._configure_fixed_pane_width(pane_side, width))
[docs]
def clear_pane_fixed_width(self, pane_side: str):
"""Remove fixed width constraint from a pane."""
if pane_side == "left":
self.left_config.fixed_width = None
elif pane_side == "right":
self.right_config.fixed_width = None
else:
return # Center pane doesn't support fixed width
# Apply the change if pane is currently attached
if pane_side in self.pane_frames:
container = self.pane_frames[pane_side]
container.pack_propagate(True)
# Reset pane configuration to allow resizing
for i, child in enumerate(self.paned.winfo_children()):
if child == container:
try:
self.paned.paneconfig(i, minsize=50) # Set reasonable minimum
# Remove maxsize if it was set
try:
self.paned.paneconfig(i, maxsize=0) # 0 means no limit
except tk.TclError:
pass
except tk.TclError:
pass
break
[docs]
def is_pane_fixed_width(self, pane_side: str) -> bool:
"""Check if a pane has fixed width."""
if pane_side == "left":
return self.left_config.fixed_width is not None
elif pane_side == "right":
return self.right_config.fixed_width is not None
return False
[docs]
def get_pane_width(self, pane_side: str) -> int:
"""Get the current width of a pane."""
if pane_side == "left":
return self.left_config.fixed_width or self.left_config.default_width
elif pane_side == "right":
return self.right_config.fixed_width or self.right_config.default_width
elif pane_side == "center" and pane_side in self.pane_frames:
return self.pane_frames[pane_side].winfo_width()
return 0
def _create_toolbar(self):
"""Create the toolbar."""
theme = self.theme_manager.get_current_theme()
self.toolbar = tk.Frame(
self, bg=theme.colors.secondary_bg, height=32, relief="flat"
)
self.toolbar.pack(fill=tk.X, padx=2, pady=(2, 0))
self.toolbar.pack_propagate(False)
# Add some basic toolbar content
toolbar_label = tk.Label(
self.toolbar,
text="Toolbar",
bg=theme.colors.secondary_bg,
fg=theme.colors.primary_text,
font=(theme.typography.font_family, theme.typography.font_size_small),
)
toolbar_label.pack(side=tk.LEFT, padx=8, pady=4)
def _create_status_bar(self):
"""Create the status bar."""
theme = self.theme_manager.get_current_theme()
self.status_bar = tk.Frame(
self, bg=theme.colors.secondary_bg, height=24, relief="sunken", bd=1
)
self.status_bar.pack(fill=tk.X, side=tk.BOTTOM, padx=2, pady=(0, 2))
self.status_bar.pack_propagate(False)
# Add some basic status bar content
self.status_label = tk.Label(
self.status_bar,
text="Ready",
bg=theme.colors.secondary_bg,
fg=theme.colors.secondary_text,
font=(theme.typography.font_family, theme.typography.font_size_small),
)
self.status_label.pack(side=tk.LEFT, padx=8, pady=2)
[docs]
def get_left_frame(self):
"""Get the left pane content frame."""
return self.get_pane_frame("left")
[docs]
def get_center_frame(self):
"""Get the center pane content frame."""
return self.get_pane_frame("center")
[docs]
def get_right_frame(self):
"""Get the right pane content frame."""
return self.get_pane_frame("right")
[docs]
def update_status(self, message: str):
"""Update the status bar message."""
if hasattr(self, "status_label") and self.status_label:
self.status_label.configure(text=message)
elif hasattr(self, "status_bar") and self.status_bar:
# Fallback: Find the status label and update it
for child in self.status_bar.winfo_children():
if isinstance(child, tk.Label):
child.configure(text=message)
break
else:
# If no status bar, log the status message
logger.debug("Status: %s", message)
[docs]
def show_left_pane(self):
"""Show the left pane if it's hidden."""
if "left" in self.pane_frames and "left" not in self.detached_windows:
# For PanedWindow, we need to add it back to the paned widget
if not self.pane_visibility.get("left", False):
# Insert at the beginning (left position)
self.paned.insert(0, self.pane_frames["left"])
self.pane_visibility["left"] = True
# Restore proper width
self.paned.after_idle(
lambda: self._configure_pane_width(
"left", self.left_config.default_width
)
)
elif "left" in self.detached_windows:
# If detached, bring window to front
self.detached_windows["left"].lift()
[docs]
def hide_left_pane(self):
"""Hide the left pane."""
if "left" in self.pane_frames and "left" not in self.detached_windows:
# For PanedWindow, we remove it from the paned widget
try:
if self.pane_visibility.get("left", False):
self.paned.forget(self.pane_frames["left"])
self.pane_visibility["left"] = False
except tk.TclError:
# Pane might not be managed by the PanedWindow
pass
[docs]
def toggle_left_pane(self):
"""Toggle the visibility of the left pane."""
if "left" in self.pane_frames and "left" not in self.detached_windows:
# Check if it's currently visible using our tracking
if self.pane_visibility.get("left", False):
self.hide_left_pane()
else:
self.show_left_pane()
elif "left" in self.detached_windows:
# If detached, toggle window visibility
detached_window = self.detached_windows["left"]
if detached_window.state() == "normal":
detached_window.withdraw()
else:
detached_window.deiconify()
detached_window.lift()
[docs]
def show_right_pane(self):
"""Show the right pane if it's hidden."""
if "right" in self.pane_frames and "right" not in self.detached_windows:
# For PanedWindow, we need to add it back to the paned widget
if not self.pane_visibility.get("right", False):
# Insert at the end (right position)
self.paned.add(self.pane_frames["right"])
self.pane_visibility["right"] = True
# Restore proper width
self.paned.after_idle(
lambda: self._configure_pane_width(
"right", self.right_config.default_width
)
)
elif "right" in self.detached_windows:
# If detached, bring window to front
self.detached_windows["right"].lift()
[docs]
def hide_right_pane(self):
"""Hide the right pane."""
if "right" in self.pane_frames and "right" not in self.detached_windows:
# For PanedWindow, we remove it from the paned widget
try:
if self.pane_visibility.get("right", False):
self.paned.forget(self.pane_frames["right"])
self.pane_visibility["right"] = False
except tk.TclError:
# Pane might not be managed by the PanedWindow
pass
[docs]
def toggle_right_pane(self):
"""Toggle the visibility of the right pane."""
if "right" in self.pane_frames and "right" not in self.detached_windows:
# Check if it's currently visible using our tracking
if self.pane_visibility.get("right", False):
self.hide_right_pane()
else:
self.show_right_pane()
elif "right" in self.detached_windows:
# If detached, toggle window visibility
detached_window = self.detached_windows["right"]
if detached_window.state() == "normal":
detached_window.withdraw()
else:
detached_window.deiconify()
detached_window.lift()
[docs]
def show_center_pane(self):
"""Show the center pane if it's hidden."""
if "center" in self.pane_frames and "center" not in self.detached_windows:
# For PanedWindow, we need to add it back to the paned widget
if self.pane_frames["center"] not in self.paned.winfo_children():
# Insert in the middle position
children = list(self.paned.winfo_children())
insert_pos = 1 if len(children) > 0 else 0
self.paned.insert(insert_pos, self.pane_frames["center"])
[docs]
def hide_center_pane(self):
"""Hide the center pane."""
if "center" in self.pane_frames and "center" not in self.detached_windows:
# For PanedWindow, we remove it from the paned widget
try:
if self.pane_frames["center"] in self.paned.winfo_children():
self.paned.forget(self.pane_frames["center"])
except tk.TclError:
# Pane might not be managed by the PanedWindow
pass
[docs]
def toggle_center_pane(self):
"""Toggle the visibility of the center pane."""
if "center" in self.pane_frames and "center" not in self.detached_windows:
# Check if it's currently in the paned window
if self.pane_frames["center"] in self.paned.winfo_children():
self.hide_center_pane()
else:
self.show_center_pane()
[docs]
def is_pane_visible(self, pane_side: str) -> bool:
"""
Check if a pane is currently visible.
Args:
pane_side (str): Which pane to check ('left', 'center', 'right').
Returns:
bool: True if the pane is visible (either attached or detached), False otherwise.
"""
if pane_side in self.pane_frames:
if pane_side in self.detached_windows:
# If detached, check window state
return self.detached_windows[pane_side].state() == "normal"
else:
# If attached, use our visibility tracking
return self.pane_visibility.get(pane_side, False)
return False
[docs]
def get_status_text(self) -> str:
"""Get the current status bar text."""
if hasattr(self, "status_label") and self.status_label:
return self.status_label.cget("text")
elif hasattr(self, "status_bar") and self.status_bar:
for child in self.status_bar.winfo_children():
if isinstance(child, tk.Label):
return child.cget("text")
return ""
[docs]
def set_status_text(self, text: str):
"""Set the status bar text (alias for update_status)."""
self.update_status(text)
[docs]
def get_theme_name(self) -> str:
"""Get the current theme name."""
return (
self.theme_manager.current_theme.name
if self.theme_manager.current_theme
else "unknown"
)
[docs]
def get_available_themes(self) -> list:
"""
Get list of available theme names.
Returns:
list: List of theme names that can be used with switch_theme().
"""
return ["light", "dark", "blue"] # Based on the themes available
[docs]
def refresh_ui(self):
"""Refresh the entire UI (useful after theme changes)."""
self._refresh_theme()
self.update_idletasks()
def _refresh_custom_widgets(self):
"""Refresh custom widgets (text, scrollbars, etc.) in all panes."""
current_theme = self.theme_manager.get_current_theme()
for pane_side in ["left", "center", "right"]:
# Only update attached panes - detached panes are handled by their
# refresh_theme method
if pane_side not in self.detached_windows:
frame = self.get_pane_content_frame(pane_side)
if frame and hasattr(frame, "update_theme"):
try:
# Call the frame's update_theme method with current theme name
frame.update_theme(current_theme.name)
except Exception as e:
logger.error(
"Error updating theme for %s pane: %s", pane_side, e
)
[docs]
def get_pane_content_frame(self, pane_side: str) -> Optional[tk.Frame]:
"""
Get the content frame for a specific pane.
This method provides access to the frame where pane content is displayed,
useful for adding widgets or updating content dynamically.
Args:
pane_side (str): Which pane to get ('left', 'center', 'right').
Returns:
Optional[tk.Frame]: The content frame for the specified pane, or None if not found.
"""
if pane_side == "center":
return self.get_center_frame()
elif pane_side == "left":
return self.get_left_frame()
elif pane_side == "right":
return self.get_right_frame()
return None
[docs]
def switch_theme(self, theme_name: str, update_status: bool = True) -> bool:
"""
Switch to a new theme and automatically update all widgets.
Args:
theme_name: Name of the theme to switch to
update_status: Whether to update the status bar with theme info
Returns:
bool: True if theme was successfully switched, False otherwise
"""
# Set the theme
if self.theme_manager.set_theme(theme_name, window=self.master):
# Refresh the UI
self.refresh_ui()
# Update status bar if requested and available
if update_status and hasattr(self, "update_status"):
platform_info = self.theme_manager.get_platform_info()
status_text = (
f"Theme: {theme_name} | Platform: {platform_info['platform']} | "
f"Scrollbars: {platform_info['scrollbar_type']}"
)
self.update_status(status_text)
return True
else:
logger.warning("Failed to set theme '%s'", theme_name)
return False