Progress visualization is one of the most psychologically powerful interface elements - it transforms uncertainty into understanding, anxiety into anticipation. The circular indicator represents the perfect marriage of mathematical precision and human intuition, using the ancient symbolism of the circle to represent completion and wholeness in digital interfaces.
This guide explores not just how to create circular indicators in Fern, but the fascinating psychology of progress visualization, the mathematics of circular arcs, and the design principles that make circular progress indicators so universally understood and emotionally engaging.
Circles have deep psychological significance in human perception:
- Completeness: Circles represent wholeness, completion, and cycles in virtually every culture
- Natural Motion: Circular movement mirrors natural patterns - sun/moon cycles, seasons, life cycles
- Visual Focus: The circular shape naturally draws the eye to the center, creating focal hierarchy
- Progress Intuition: The "filled" portion immediately communicates how much work is complete
- Anticipation Building: Seeing progress approach completion creates positive anticipation
// A circular indicator at 75% completion immediately communicates:
// - 3/4 of the task is done (mathematical understanding)
// - "Almost there!" (emotional response)
// - The remaining effort is manageable (psychological comfort)
auto indicator = CircularIndicator(CircularIndicatorConfig(200, 150, 50)
.value(75.0f) // 75% complete
.range(0.0f, 100.0f));Circular indicators involve several fascinating mathematical concepts:
Arc Mathematics:
- A complete circle represents 360° or 2π radians
- Progress percentage maps to arc angle:
angle = (progress / 100) * 2π - Arc rendering requires trigonometric calculations for pixel placement
Coordinate Transformation:
// Converting circular coordinates to screen pixels:
// For a circle at center (cx, cy) with radius r and angle θ:
int pixelX = cx + r * cos(θ);
int pixelY = cy + r * sin(θ);
// This transformation happens continuously as the indicator fillsFern's circular indicators follow several design principles:
- Immediate Clarity: Progress should be instantly understandable at a glance
- Smooth Animation: Value changes should feel natural and continuous
- Configurable Aesthetics: Appearance should adapt to different design contexts
- Text Integration: Optional percentage display provides precise information
- Direction Control: Clockwise/counter-clockwise options for different cultural contexts
- Accessibility: Clear visual contrast and optional text for screen readers
Let's start with the most basic circular indicator to understand the core concepts:
#include <fern/fern.hpp>
using namespace Fern;
int main() {
setupFern();
// Create a simple loading indicator
auto loadingIndicator = CircularIndicator(CircularIndicatorConfig(200, 150, 50)
.value(0.0f) // Start at 0%
.range(0.0f, 100.0f) // Standard 0-100% range
.style(CircularIndicatorStyle()
.backgroundColor(Colors::DarkGray) // Unfilled portion
.fillColor(Colors::Blue) // Progress portion
.showPercentage(true) // Show percentage text
.thickness(8) // Arc width in pixels
.startAngle(-90.0f) // Start from top (12 o'clock)
.clockwise(true)), // Fill clockwise
true); // Add to widget manager
// Simulate loading progress
float progress = 0.0f;
while (shouldKeepRunning()) {
processInput();
// Update progress (simulate work being done)
progress += 0.5f; // Increase by 0.5% each frame
if (progress > 100.0f) progress = 0.0f; // Reset for demo
loadingIndicator->setValue(progress);
render();
}
return 0;
}What's happening here?
CircularIndicatorConfig(200, 150, 50): Creates indicator at position (200, 150) with radius 50 pixels.value(0.0f): Sets initial progress to 0%.range(0.0f, 100.0f): Defines the value range (0% to 100%).backgroundColor(Colors::DarkGray): Color for the unfilled background arc.fillColor(Colors::Blue): Color for the progress arc.thickness(8): Makes the arc 8 pixels thick.startAngle(-90.0f): Positions start at top of circle (12 o'clock position).clockwise(true): Progress fills in clockwise direction
This creates a smooth, animated loading indicator that visually represents progress in an immediately understandable way.
The CircularIndicatorConfig class defines the basic properties of your indicator:
// Complete configuration breakdown
auto config = CircularIndicatorConfig(x, y, radius)
.value(initialValue) // Starting progress value
.range(minValue, maxValue) // Value range (not always 0-100!)
.style(styleObject); // Visual appearance
// Real-world examples of different ranges:
// Standard percentage (0-100%)
auto percentIndicator = CircularIndicatorConfig(100, 100, 40)
.value(75.0f)
.range(0.0f, 100.0f);
// Temperature gauge (-20°C to 40°C)
auto tempIndicator = CircularIndicatorConfig(200, 100, 40)
.value(23.5f) // Current temperature
.range(-20.0f, 40.0f); // Temperature range
// Experience points (0 to next level)
auto xpIndicator = CircularIndicatorConfig(300, 100, 40)
.value(850.0f) // Current XP
.range(0.0f, 1000.0f); // XP needed for next level
// Volume control (0-11, because this one goes to eleven!)
auto volumeIndicator = CircularIndicatorConfig(400, 100, 40)
.value(7.5f)
.range(0.0f, 11.0f);The CircularIndicatorStyle class controls every aspect of the indicator's appearance:
CircularIndicatorStyle style;
style.backgroundColor(Colors::DarkGray) // Background arc color
.fillColor(Colors::Blue) // Progress arc color
.borderColor(Colors::Black) // Border around entire indicator
.borderWidth(2) // Border thickness in pixels
.thickness(8) // Arc thickness in pixels
.showPercentage(true) // Display percentage text in center
.textColor(Colors::White) // Text color
.fontSize(2) // Text size (bitmap font scaling)
.clockwise(true) // Progress direction
.startAngle(-90.0f) // Start position (degrees)
.useBitmapFont(); // Use bitmap fonts for text
// For higher quality text with TTF fonts:
style.useTTFFont("arial") // Use TTF font
.fontSize(16); // TTF font size (minimum 16)Understanding the Style Properties:
- backgroundColor: The "empty" portion of the circle that shows remaining work
- fillColor: The "filled" portion that represents completed work
- thickness: Thicker arcs are more prominent but take more space
- startAngle: -90° = top, 0° = right, 90° = bottom, 180° = left
- clockwise: true = fills like a clock, false = fills counter-clockwise
- showPercentage: When true, displays the percentage value in the center
auto indicator = CircularIndicator(CircularIndicatorConfig(200, 150, 50), true);
// Set the current progress value
indicator->setValue(65.0f);
// Get the current progress value
float currentValue = indicator->getValue();
std::cout << "Current progress: " << currentValue << std::endl;
// Get progress as percentage (useful for any range)
float percentage = indicator->getPercentage();
std::cout << "Percentage complete: " << percentage << "%" << std::endl;
// Change the value range dynamically
indicator->setRange(0.0f, 200.0f); // Now 65.0f represents 32.5% instead of 65%Understanding Value vs Percentage:
getValue()returns the raw value within your specified rangegetPercentage()always returns 0-100%, regardless of your range- This distinction is crucial for displaying meaningful information to users
// Position and size management
indicator->setPosition(300, 200); // Move the indicator
indicator->resize(120, 120); // Change size (diameter)
// Query current dimensions
int width = indicator->getWidth(); // Returns diameter (2 * radius)
int height = indicator->getHeight(); // Same as width (circles are square)
int x = indicator->getX(); // Current X position
int y = indicator->getY(); // Current Y positionCircular indicators support event-driven programming for responsive interfaces:
auto downloadIndicator = CircularIndicator(CircularIndicatorConfig(200, 100, 40), true);
// React to any progress change
downloadIndicator->onValueChanged.connect([](float value) {
std::cout << "Download progress: " << value << "%" << std::endl;
// Update UI based on progress
if (value < 25.0f) {
updateStatusText("Download starting...");
} else if (value < 75.0f) {
updateStatusText("Download in progress...");
} else if (value < 100.0f) {
updateStatusText("Download almost complete...");
}
});
// React to completion
downloadIndicator->onComplete.connect([]() {
std::cout << "Download finished!" << std::endl;
updateStatusText("Download complete!");
showCompletionAnimation();
playSuccessSound();
});
// Simulate download progress
void simulateDownload() {
static float progress = 0.0f;
progress += 0.3f; // Simulate download chunks
if (progress > 100.0f) progress = 100.0f;
downloadIndicator->setValue(progress);
}class ProgressiveInterface {
public:
ProgressiveInterface() {
setupIndicators();
}
private:
void setupIndicators() {
// Health indicator with color changes
healthIndicator_ = CircularIndicator(CircularIndicatorConfig(100, 100, 30), true);
healthIndicator_->onValueChanged.connect([this](float health) {
updateHealthVisualization(health);
});
// Experience indicator with level-up detection
xpIndicator_ = CircularIndicator(CircularIndicatorConfig(200, 100, 30), true);
xpIndicator_->onComplete.connect([this]() {
triggerLevelUp();
});
// Battery indicator with low-power warnings
batteryIndicator_ = CircularIndicator(CircularIndicatorConfig(300, 100, 30), true);
batteryIndicator_->onValueChanged.connect([this](float battery) {
if (battery < 20.0f && !lowBatteryWarningShown_) {
showLowBatteryWarning();
lowBatteryWarningShown_ = true;
} else if (battery >= 20.0f) {
lowBatteryWarningShown_ = false;
}
});
}
void updateHealthVisualization(float health) {
if (health > 75.0f) {
healthIndicator_->getStyle().fillColor(Colors::Green);
} else if (health > 50.0f) {
healthIndicator_->getStyle().fillColor(Colors::Yellow);
} else if (health > 25.0f) {
healthIndicator_->getStyle().fillColor(Colors::Orange);
} else {
healthIndicator_->getStyle().fillColor(Colors::Red);
if (health < 10.0f) {
triggerCriticalHealthEffect();
}
}
}
void triggerLevelUp() {
std::cout << "LEVEL UP!" << std::endl;
// Reset XP indicator for next level
xpIndicator_->setValue(0.0f);
// Play level-up animation
// Increase character stats
}
void showLowBatteryWarning() {
std::cout << "Low battery warning!" << std::endl;
// Show warning UI
// Suggest power-saving mode
}
void triggerCriticalHealthEffect() {
// Screen flash effect
// Warning sounds
// UI color changes
}
std::shared_ptr<CircularIndicatorWidget> healthIndicator_;
std::shared_ptr<CircularIndicatorWidget> xpIndicator_;
std::shared_ptr<CircularIndicatorWidget> batteryIndicator_;
bool lowBatteryWarningShown_ = false;
};Fern provides carefully designed presets for common use cases, embodying best practices for different types of progress indicators:
auto indicator = CircularIndicator(CircularIndicatorPresets::Default(100, 100, 50), true);Design Characteristics:
- Neutral gray background with blue progress
- 8-pixel thickness for good visibility
- Percentage display for precise feedback
- Starts from top (12 o'clock position)
- Clockwise progression (natural for most cultures)
auto loadingIndicator = CircularIndicator(CircularIndicatorPresets::Loading(200, 150, 40), true);Optimized for Loading Operations:
- Slightly smaller radius (40px) for compact UI integration
- Blue theme suggests progress and activity
- Percentage display provides user reassurance
- Smooth animation-friendly design
auto healthIndicator = CircularIndicator(CircularIndicatorPresets::Health(150, 100, 30), true);Designed for Status Display:
- Compact size (30px radius) for UI dashboards
- Green theme suggests vitality and success
- No percentage display (status more important than exact numbers)
- Quick visual assessment at a glance
auto batteryIndicator = CircularIndicator(CircularIndicatorPresets::Battery(250, 200, 25), true);Tailored for Battery/Resource Display:
- Very compact (25px radius) for status bars
- Includes percentage for precise remaining capacity
- Neutral styling that adapts to different themes
- Efficient rendering for always-visible elements
class FileTransferIndicator {
public:
FileTransferIndicator(const std::string& filename, size_t totalBytes)
: filename_(filename), totalBytes_(totalBytes), transferredBytes_(0) {
indicator_ = CircularIndicator(CircularIndicatorConfig(200, 150, 50)
.value(0.0f)
.range(0.0f, 100.0f)
.style(CircularIndicatorStyle()
.backgroundColor(Colors::LightGray)
.fillColor(Colors::Cyan)
.borderColor(Colors::DarkGray)
.borderWidth(2)
.thickness(8)
.showPercentage(true)
.textColor(Colors::Black)
.fontSize(2)), true);
// Connect to progress events for user feedback
indicator_->onValueChanged.connect([this](float progress) {
updateTransferStatus(progress);
});
indicator_->onComplete.connect([this]() {
onTransferComplete();
});
}
void updateProgress(size_t bytesTransferred) {
transferredBytes_ = bytesTransferred;
float percentage = (float(transferredBytes_) / totalBytes_) * 100.0f;
indicator_->setValue(percentage);
}
private:
void updateTransferStatus(float progress) {
size_t remainingBytes = totalBytes_ - transferredBytes_;
std::cout << filename_ << ": " << progress << "% complete ("
<< remainingBytes << " bytes remaining)" << std::endl;
// Estimate time remaining based on current transfer rate
// Update UI status text
// Adjust transfer priority if needed
}
void onTransferComplete() {
std::cout << "Transfer complete: " << filename_ << std::endl;
// Verify file integrity
// Update file manager
// Clean up resources
}
std::string filename_;
size_t totalBytes_;
size_t transferredBytes_;
std::shared_ptr<CircularIndicatorWidget> indicator_;
};class CharacterStatusDashboard {
public:
CharacterStatusDashboard() {
setupHealthIndicator();
setupManaIndicator();
setupExperienceIndicator();
setupStaminaIndicator();
}
void updateCharacterStats(float health, float mana, float experience, float stamina) {
// Each indicator uses appropriate range and styling
healthIndicator_->setValue(health); // 0-100 health points
manaIndicator_->setValue(mana); // 0-100 mana points
experienceIndicator_->setValue(experience); // 0-1000 XP to next level
staminaIndicator_->setValue(stamina); // 0-100 stamina points
}
private:
void setupHealthIndicator() {
healthIndicator_ = CircularIndicator(CircularIndicatorConfig(100, 100, 30)
.value(100.0f)
.range(0.0f, 100.0f)
.style(CircularIndicatorStyle()
.backgroundColor(Colors::DarkRed)
.fillColor(Colors::Green)
.thickness(6)
.showPercentage(false) // Visual status more important than numbers
.startAngle(-90.0f)), true);
healthIndicator_->onValueChanged.connect([this](float health) {
updateHealthEffects(health);
});
}
void setupManaIndicator() {
manaIndicator_ = CircularIndicator(CircularIndicatorConfig(200, 100, 30)
.value(100.0f)
.range(0.0f, 100.0f)
.style(CircularIndicatorStyle()
.backgroundColor(Colors::DarkBlue)
.fillColor(Colors::Cyan)
.thickness(6)
.showPercentage(false)), true);
manaIndicator_->onValueChanged.connect([this](float mana) {
updateManaEffects(mana);
});
}
void setupExperienceIndicator() {
experienceIndicator_ = CircularIndicator(CircularIndicatorConfig(300, 100, 30)
.value(0.0f)
.range(0.0f, 1000.0f) // XP needed for next level
.style(CircularIndicatorStyle()
.backgroundColor(Colors::DarkGray)
.fillColor(Colors::Gold)
.thickness(6)
.showPercentage(true)), true); // Show percentage for progression feedback
experienceIndicator_->onComplete.connect([this]() {
triggerLevelUp();
});
}
void setupStaminaIndicator() {
staminaIndicator_ = CircularIndicator(CircularIndicatorConfig(400, 100, 30)
.value(100.0f)
.range(0.0f, 100.0f)
.style(CircularIndicatorStyle()
.backgroundColor(Colors::DarkGreen)
.fillColor(Colors::Yellow)
.thickness(6)
.showPercentage(false)), true);
}
void updateHealthEffects(float health) {
if (health < 25.0f) {
// Screen edge flashing
// Character movement penalties
// Warning sounds
} else if (health < 50.0f) {
// Subtle visual effects
// Slight movement reduction
}
}
void updateManaEffects(float mana) {
if (mana < 10.0f) {
// Disable magic abilities
// Visual mana depletion effects
}
}
void triggerLevelUp() {
// Level-up animation
// Stat increases
// Reset experience indicator
experienceIndicator_->setValue(0.0f);
// Increase next level requirement
experienceIndicator_->setRange(0.0f, 1200.0f); // Harder next level
}
std::shared_ptr<CircularIndicatorWidget> healthIndicator_;
std::shared_ptr<CircularIndicatorWidget> manaIndicator_;
std::shared_ptr<CircularIndicatorWidget> experienceIndicator_;
std::shared_ptr<CircularIndicatorWidget> staminaIndicator_;
};class SystemMonitor {
public:
SystemMonitor() {
setupPerformanceIndicators();
startMonitoring();
}
private:
void setupPerformanceIndicators() {
// CPU usage (0-100%)
cpuIndicator_ = CircularIndicator(CircularIndicatorConfig(100, 100, 35)
.value(0.0f)
.range(0.0f, 100.0f)
.style(CircularIndicatorStyle()
.backgroundColor(Colors::DarkGray)
.fillColor(Colors::Blue)
.thickness(6)
.showPercentage(true)
.fontSize(1)), true);
// Memory usage (0-100%)
memoryIndicator_ = CircularIndicator(CircularIndicatorConfig(200, 100, 35)
.value(0.0f)
.range(0.0f, 100.0f)
.style(CircularIndicatorStyle()
.backgroundColor(Colors::DarkGray)
.fillColor(Colors::Orange)
.thickness(6)
.showPercentage(true)
.fontSize(1)), true);
// Network usage (0-100 Mbps)
networkIndicator_ = CircularIndicator(CircularIndicatorConfig(300, 100, 35)
.value(0.0f)
.range(0.0f, 100.0f)
.style(CircularIndicatorStyle()
.backgroundColor(Colors::DarkGray)
.fillColor(Colors::Green)
.thickness(6)
.showPercentage(true)
.fontSize(1)), true);
// Setup warning thresholds
cpuIndicator_->onValueChanged.connect([](float cpu) {
if (cpu > 90.0f) {
std::cout << "WARNING: High CPU usage!" << std::endl;
}
});
memoryIndicator_->onValueChanged.connect([](float memory) {
if (memory > 85.0f) {
std::cout << "WARNING: High memory usage!" << std::endl;
}
});
}
void startMonitoring() {
// In a real application, this would be a separate thread
// updating these indicators with actual system metrics
}
void updateSystemMetrics(float cpu, float memory, float network) {
cpuIndicator_->setValue(cpu);
memoryIndicator_->setValue(memory);
networkIndicator_->setValue(network);
// Change colors based on load levels
updateIndicatorColor(cpuIndicator_, cpu);
updateIndicatorColor(memoryIndicator_, memory);
updateIndicatorColor(networkIndicator_, network);
}
void updateIndicatorColor(std::shared_ptr<CircularIndicatorWidget> indicator, float value) {
if (value > 90.0f) {
indicator->getStyle().fillColor(Colors::Red); // Critical
} else if (value > 75.0f) {
indicator->getStyle().fillColor(Colors::Orange); // High
} else if (value > 50.0f) {
indicator->getStyle().fillColor(Colors::Yellow); // Medium
} else {
indicator->getStyle().fillColor(Colors::Green); // Normal
}
}
std::shared_ptr<CircularIndicatorWidget> cpuIndicator_;
std::shared_ptr<CircularIndicatorWidget> memoryIndicator_;
std::shared_ptr<CircularIndicatorWidget> networkIndicator_;
};Different colors evoke different psychological responses in users:
// Success/Health: Green colors suggest completion, health, success
CircularIndicatorStyle healthStyle;
healthStyle.backgroundColor(0xFF2D4A2D) // Dark green background
.fillColor(0xFF4CAF50) // Material Design green
.textColor(Colors::White);
// Warning/Caution: Orange/Yellow for attention without alarm
CircularIndicatorStyle warningStyle;
warningStyle.backgroundColor(0xFF4A3D2D) // Dark orange background
.fillColor(0xFFFF9800) // Material Design orange
.textColor(Colors::Black);
// Critical/Error: Red for urgent attention
CircularIndicatorStyle criticalStyle;
criticalStyle.backgroundColor(0xFF4A2D2D) // Dark red background
.fillColor(0xFFF44336) // Material Design red
.textColor(Colors::White);
// Information/Progress: Blue for neutral information
CircularIndicatorStyle infoStyle;
infoStyle.backgroundColor(0xFF2D3A4A) // Dark blue background
.fillColor(0xFF2196F3) // Material Design blue
.textColor(Colors::White);
// Apply styles based on context
void updateIndicatorStyle(std::shared_ptr<CircularIndicatorWidget> indicator, float value) {
if (value < 25.0f) {
indicator->setStyle(criticalStyle);
} else if (value < 50.0f) {
indicator->setStyle(warningStyle);
} else if (value < 90.0f) {
indicator->setStyle(infoStyle);
} else {
indicator->setStyle(healthStyle);
}
}Different starting positions create different emotional contexts:
// Traditional clock position (12 o'clock) - most intuitive
CircularIndicatorStyle clockwiseTop;
clockwiseTop.startAngle(-90.0f).clockwise(true);
// Right side start (3 o'clock) - suggests moving forward
CircularIndicatorStyle clockwiseRight;
clockwiseRight.startAngle(0.0f).clockwise(true);
// Bottom start (6 o'clock) - suggests building up
CircularIndicatorStyle clockwiseBottom;
clockwiseBottom.startAngle(90.0f).clockwise(true);
// Counter-clockwise from top - less common, draws attention
CircularIndicatorStyle counterClockwiseTop;
counterClockwiseTop.startAngle(-90.0f).clockwise(false);
// Example: Countdown timer (counter-clockwise suggests time running out)
auto countdownTimer = CircularIndicator(CircularIndicatorConfig(200, 150, 40)
.value(60.0f) // 60 seconds remaining
.range(0.0f, 60.0f)
.style(CircularIndicatorStyle()
.backgroundColor(Colors::DarkGray)
.fillColor(Colors::Red) // Red for urgency
.startAngle(-90.0f) // Start at top
.clockwise(false) // Counter-clockwise for countdown
.showPercentage(false)), true);Create complex visualizations by layering multiple indicators:
class MultiLayerIndicator {
public:
MultiLayerIndicator(int x, int y, int radius) {
// Outer ring: Overall progress
outerRing_ = CircularIndicator(CircularIndicatorConfig(x, y, radius)
.value(0.0f)
.range(0.0f, 100.0f)
.style(CircularIndicatorStyle()
.backgroundColor(Colors::DarkGray)
.fillColor(Colors::Blue)
.thickness(8)
.showPercentage(false)), true);
// Middle ring: Sub-task progress
middleRing_ = CircularIndicator(CircularIndicatorConfig(x, y, radius - 15)
.value(0.0f)
.range(0.0f, 100.0f)
.style(CircularIndicatorStyle()
.backgroundColor(Colors::DarkGray)
.fillColor(Colors::Green)
.thickness(6)
.showPercentage(false)), true);
// Inner ring: Current operation
innerRing_ = CircularIndicator(CircularIndicatorConfig(x, y, radius - 25)
.value(0.0f)
.range(0.0f, 100.0f)
.style(CircularIndicatorStyle()
.backgroundColor(Colors::DarkGray)
.fillColor(Colors::Orange)
.thickness(4)
.showPercentage(true)
.fontSize(1)), true);
}
void updateProgress(float overall, float subtask, float current) {
outerRing_->setValue(overall);
middleRing_->setValue(subtask);
innerRing_->setValue(current);
}
private:
std::shared_ptr<CircularIndicatorWidget> outerRing_;
std::shared_ptr<CircularIndicatorWidget> middleRing_;
std::shared_ptr<CircularIndicatorWidget> innerRing_;
};
// Usage: Complex installation progress
MultiLayerIndicator installer(300, 200, 60);
installer.updateProgress(45.0f, // Overall installation: 45%
78.0f, // Current component: 78%
23.0f); // Current file: 23%Create smooth, engaging animations:
class AnimatedIndicator {
public:
AnimatedIndicator(int x, int y, int radius)
: currentValue_(0.0f), targetValue_(0.0f), animationSpeed_(2.0f) {
indicator_ = CircularIndicator(CircularIndicatorConfig(x, y, radius)
.value(0.0f)
.range(0.0f, 100.0f)
.style(CircularIndicatorStyle()
.backgroundColor(Colors::DarkGray)
.fillColor(Colors::Blue)
.thickness(8)
.showPercentage(true)), true);
}
void setTargetValue(float target) {
targetValue_ = target;
}
void update(float deltaTime) {
// Smooth animation towards target
if (abs(currentValue_ - targetValue_) > 0.1f) {
float direction = (targetValue_ > currentValue_) ? 1.0f : -1.0f;
currentValue_ += direction * animationSpeed_ * deltaTime;
// Clamp to target
if (direction > 0 && currentValue_ > targetValue_) {
currentValue_ = targetValue_;
} else if (direction < 0 && currentValue_ < targetValue_) {
currentValue_ = targetValue_;
}
indicator_->setValue(currentValue_);
}
}
void setAnimationSpeed(float speed) { animationSpeed_ = speed; }
private:
std::shared_ptr<CircularIndicatorWidget> indicator_;
float currentValue_;
float targetValue_;
float animationSpeed_;
};
// Usage in game loop
AnimatedIndicator animatedHealth(200, 150, 40);
void gameLoop() {
float deltaTime = getDeltaTime();
// Health changed from combat
if (playerTookDamage) {
animatedHealth.setTargetValue(newHealthValue);
playerTookDamage = false;
}
// Smooth animation
animatedHealth.update(deltaTime);
}class OptimizedIndicatorManager {
public:
void addIndicator(std::shared_ptr<CircularIndicatorWidget> indicator) {
indicators_.push_back(indicator);
}
void updateValues(const std::vector<float>& newValues) {
// Batch update to minimize redraws
for (size_t i = 0; i < indicators_.size() && i < newValues.size(); ++i) {
float currentValue = indicators_[i]->getValue();
// Only update if value actually changed
if (abs(currentValue - newValues[i]) > 0.01f) {
indicators_[i]->setValue(newValues[i]);
}
}
}
void setUpdateFrequency(float maxUpdatesPerSecond) {
updateInterval_ = 1.0f / maxUpdatesPerSecond;
}
private:
std::vector<std::shared_ptr<CircularIndicatorWidget>> indicators_;
float updateInterval_ = 1.0f / 60.0f; // 60 FPS max
float lastUpdateTime_ = 0.0f;
};class IndicatorLifecycleManager {
public:
std::shared_ptr<CircularIndicatorWidget> createTemporaryIndicator(
int x, int y, int radius, float duration) {
auto indicator = CircularIndicator(CircularIndicatorConfig(x, y, radius), true);
// Auto-cleanup after duration
temporaryIndicators_.push_back({indicator, getCurrentTime() + duration});
return indicator;
}
void update() {
float currentTime = getCurrentTime();
// Remove expired temporary indicators
temporaryIndicators_.erase(
std::remove_if(temporaryIndicators_.begin(), temporaryIndicators_.end(),
[currentTime](const TempIndicator& temp) {
if (currentTime > temp.expiryTime) {
// Indicator automatically cleaned up when shared_ptr goes out of scope
return true;
}
return false;
}),
temporaryIndicators_.end()
);
}
private:
struct TempIndicator {
std::shared_ptr<CircularIndicatorWidget> indicator;
float expiryTime;
};
std::vector<TempIndicator> temporaryIndicators_;
float getCurrentTime() {
return 0.0f; // Placeholder - use actual time function
}
};namespace IndicatorDesignGuidelines {
// Recommended sizes for different contexts
constexpr int ICON_SIZE = 16; // Tiny status indicators
constexpr int COMPACT_SIZE = 24; // Toolbar/status bar
constexpr int STANDARD_SIZE = 40; // General UI elements
constexpr int PROMINENT_SIZE = 60; // Important progress displays
constexpr int HERO_SIZE = 100; // Main focal points
// Thickness recommendations based on size
int recommendedThickness(int radius) {
if (radius <= 20) return 3;
if (radius <= 40) return 6;
if (radius <= 60) return 8;
return 10;
}
// Spacing recommendations
int recommendedSpacing(int radius) {
return radius * 2 + 20; // Diameter plus padding
}
// Font size recommendations
int recommendedFontSize(int radius) {
if (radius <= 20) return 1;
if (radius <= 40) return 2;
return 3;
}
}
// Apply design guidelines
auto createWellDesignedIndicator(int x, int y, int radius) {
return CircularIndicator(CircularIndicatorConfig(x, y, radius)
.value(0.0f)
.range(0.0f, 100.0f)
.style(CircularIndicatorStyle()
.thickness(IndicatorDesignGuidelines::recommendedThickness(radius))
.fontSize(IndicatorDesignGuidelines::recommendedFontSize(radius))
.backgroundColor(Colors::DarkGray)
.fillColor(Colors::Blue)
.showPercentage(radius >= IndicatorDesignGuidelines::STANDARD_SIZE)), true);
}// Indicators work well in layout containers
auto statusPanel = Column({
Text(TextPresets::Heading(0, 0, "System Status")),
Row({
Text(TextPresets::Body(0, 0, "CPU:")),
CircularIndicator(CircularIndicatorPresets::Default(0, 0, 20), true)
}),
Row({
Text(TextPresets::Body(0, 0, "Memory:")),
CircularIndicator(CircularIndicatorPresets::Default(0, 0, 20), true)
}),
Row({
Text(TextPresets::Body(0, 0, "Disk:")),
CircularIndicator(CircularIndicatorPresets::Default(0, 0, 20), true)
})
});class StatefulProgressManager {
public:
enum class ProgressState {
Idle,
Starting,
InProgress,
Completing,
Done,
Error
};
void setState(ProgressState state) {
currentState_ = state;
updateIndicatorAppearance();
}
void updateProgress(float value) {
if (currentState_ == ProgressState::InProgress) {
indicator_->setValue(value);
if (value >= 100.0f) {
setState(ProgressState::Completing);
}
}
}
private:
void updateIndicatorAppearance() {
switch (currentState_) {
case ProgressState::Idle:
indicator_->getStyle()
.fillColor(Colors::Gray)
.backgroundColor(Colors::DarkGray);
break;
case ProgressState::Starting:
indicator_->getStyle()
.fillColor(Colors::Yellow)
.backgroundColor(Colors::DarkGray);
break;
case ProgressState::InProgress:
indicator_->getStyle()
.fillColor(Colors::Blue)
.backgroundColor(Colors::DarkGray);
break;
case ProgressState::Completing:
indicator_->getStyle()
.fillColor(Colors::Green)
.backgroundColor(Colors::DarkGray);
break;
case ProgressState::Done:
indicator_->getStyle()
.fillColor(Colors::Green)
.backgroundColor(Colors::DarkGreen);
break;
case ProgressState::Error:
indicator_->getStyle()
.fillColor(Colors::Red)
.backgroundColor(Colors::DarkRed);
break;
}
}
ProgressState currentState_ = ProgressState::Idle;
std::shared_ptr<CircularIndicatorWidget> indicator_;
};Check these common problems:
- Position outside screen bounds:
// Bad: Indicator positioned completely off-screen
auto offScreen = CircularIndicator(CircularIndicatorConfig(-100, -100, 50), true);
// Good: Ensure indicator is within visible area
auto onScreen = CircularIndicator(CircularIndicatorConfig(50, 50, 50), true);- Colors match background:
// Invisible: Same color as background
auto invisible = CircularIndicatorConfig(200, 150, 50)
.style(CircularIndicatorStyle()
.backgroundColor(Colors::Black) // On black background
.fillColor(Colors::Black)); // Also black - invisible!
// Visible: Use contrasting colors
auto visible = CircularIndicatorConfig(200, 150, 50)
.style(CircularIndicatorStyle()
.backgroundColor(Colors::DarkGray)
.fillColor(Colors::Blue));- Zero thickness or radius:
// Won't show: Zero thickness
auto noThickness = CircularIndicatorConfig(200, 150, 50)
.style(CircularIndicatorStyle().thickness(0));
// Won't show: Zero radius
auto noRadius = CircularIndicatorConfig(200, 150, 0);
// Visible: Adequate thickness and radius
auto visible = CircularIndicatorConfig(200, 150, 30)
.style(CircularIndicatorStyle().thickness(6));Understanding value mapping:
// Common mistake: Misunderstanding value ranges
auto indicator = CircularIndicator(CircularIndicatorConfig(200, 150, 50)
.value(50.0f)
.range(0.0f, 200.0f), true); // Range is 0-200, not 0-100!
// This shows 25% progress (50/200), not 50%
std::cout << "Progress: " << indicator->getPercentage() << "%" << std::endl; // Prints 25%
// If you want 50% progress with 0-200 range:
indicator->setValue(100.0f); // 100 out of 200 = 50%// Text too small to read
auto tinyText = CircularIndicatorConfig(200, 150, 20)
.style(CircularIndicatorStyle()
.fontSize(1) // Tiny on small indicator
.showPercentage(true));
// Better: Scale font with indicator size
auto readableText = CircularIndicatorConfig(200, 150, 20)
.style(CircularIndicatorStyle()
.fontSize(1) // Appropriate for size
.showPercentage(false)); // Or hide text for small indicators
// For larger indicators, larger font
auto largeWithText = CircularIndicatorConfig(200, 150, 60)
.style(CircularIndicatorStyle()
.fontSize(3) // Readable on large indicator
.showPercentage(true));// Inefficient: Updating too frequently
void inefficientUpdate() {
// Don't update every single frame unless necessary
for (int i = 0; i < 1000; ++i) {
indicator->setValue(i * 0.1f); // 1000 updates per frame!
}
}
// Efficient: Update only when value actually changes
void efficientUpdate(float newValue) {
float currentValue = indicator->getValue();
if (abs(currentValue - newValue) > 0.1f) { // Only update if significant change
indicator->setValue(newValue);
}
}
// Efficient: Batch updates for multiple indicators
void batchUpdate(const std::vector<float>& values) {
for (size_t i = 0; i < indicators.size() && i < values.size(); ++i) {
if (abs(indicators[i]->getValue() - values[i]) > 0.1f) {
indicators[i]->setValue(values[i]);
}
}
}// Choppy animation: Jumping values
void choppyAnimation() {
// Bad: Large jumps
indicator->setValue(10.0f);
indicator->setValue(60.0f);
indicator->setValue(90.0f);
}
// Smooth animation: Gradual changes
class SmoothProgressUpdater {
public:
void setTargetValue(float target) { targetValue_ = target; }
void update(float deltaTime) {
float current = indicator_->getValue();
if (abs(current - targetValue_) > 0.1f) {
float step = (targetValue_ - current) * 3.0f * deltaTime; // 3x speed factor
indicator_->setValue(current + step);
}
}
private:
std::shared_ptr<CircularIndicatorWidget> indicator_;
float targetValue_ = 0.0f;
};// Event not firing: Check value actually changed
auto indicator = CircularIndicator(CircularIndicatorConfig(200, 150, 50), true);
indicator->onValueChanged.connect([](float value) {
std::cout << "Value changed: " << value << std::endl;
});
// This won't trigger event if value is already 50.0f
indicator->setValue(50.0f);
indicator->setValue(50.0f); // No event - same value
// Force event trigger by setting different value first
indicator->setValue(49.9f);
indicator->setValue(50.0f); // Now event fires
// Better: Check if you need to force updates
void forceValueUpdate(float newValue) {
float current = indicator->getValue();
if (current == newValue) {
// Force change by setting slightly different value first
indicator->setValue(newValue - 0.001f);
}
indicator->setValue(newValue);
}// Works well with animation systems
class ProgressAnimation {
public:
void animateToValue(float target, float duration) {
startValue_ = indicator_->getValue();
targetValue_ = target;
animationTime_ = 0.0f;
animationDuration_ = duration;
isAnimating_ = true;
}
void update(float deltaTime) {
if (!isAnimating_) return;
animationTime_ += deltaTime;
float progress = animationTime_ / animationDuration_;
if (progress >= 1.0f) {
indicator_->setValue(targetValue_);
isAnimating_ = false;
} else {
// Smooth easing function
float easedProgress = easeInOutQuad(progress);
float currentValue = startValue_ + (targetValue_ - startValue_) * easedProgress;
indicator_->setValue(currentValue);
}
}
private:
float easeInOutQuad(float t) {
return t < 0.5f ? 2.0f * t * t : -1.0f + (4.0f - 2.0f * t) * t;
}
std::shared_ptr<CircularIndicatorWidget> indicator_;
float startValue_, targetValue_;
float animationTime_, animationDuration_;
bool isAnimating_ = false;
};// Automatic updates from data sources
template<typename T>
class DataBoundIndicator {
public:
DataBoundIndicator(std::shared_ptr<CircularIndicatorWidget> indicator,
std::function<float()> valueGetter,
float minVal, float maxVal)
: indicator_(indicator), getValue_(valueGetter) {
indicator_->setRange(minVal, maxVal);
}
void update() {
float newValue = getValue_();
float currentValue = indicator_->getValue();
if (abs(newValue - currentValue) > 0.01f) {
indicator_->setValue(newValue);
}
}
private:
std::shared_ptr<CircularIndicatorWidget> indicator_;
std::function<float()> getValue_;
};
// Usage with game data
struct GameCharacter {
float health = 100.0f;
float mana = 100.0f;
float experience = 0.0f;
};
GameCharacter player;
// Bind indicators to player data
DataBoundIndicator healthIndicator(
CircularIndicator(CircularIndicatorPresets::Health(100, 100, 30), true),
[&player]() { return player.health; },
0.0f, 100.0f
);
DataBoundIndicator manaIndicator(
CircularIndicator(CircularIndicatorPresets::Default(200, 100, 30), true),
[&player]() { return player.mana; },
0.0f, 100.0f
);
// Automatic updates in game loop
void gameLoop() {
// Game logic updates player stats
player.health -= damageThisFrame;
player.mana -= spellCost;
// Indicators automatically reflect changes
healthIndicator.update();
manaIndicator.update();
}- Size Appropriately: Use design guidelines for consistent sizing
- Color Meaningfully: Follow color psychology principles
- Animate Smoothly: Gradual changes feel more natural
- Provide Context: Use labels and descriptions when needed
- Test Accessibility: Ensure visibility across different displays
- Update Efficiently: Only update when values actually change
- Batch Operations: Group multiple updates together
- Limit Frequency: Don't update more than necessary
- Cache Values: Store frequently accessed values
- Clean Up Events: Disconnect event handlers when done
- Immediate Feedback: Show progress changes immediately
- Clear Purpose: Make the indicator's meaning obvious
- Appropriate Precision: Match precision to user needs
- Consistent Behavior: Use similar patterns throughout your app
- Meaningful Events: Connect progress to user actions
Circular indicators in Fern represent the perfect fusion of mathematical precision, psychological understanding, and practical functionality. They transform abstract numerical values into intuitive visual representations that users understand instinctively.
Key takeaways:
- Psychological Power: Circles naturally represent completion and progress in human perception
- Mathematical Foundation: Arc rendering involves trigonometry and coordinate transformation
- Flexible Configuration: Extensive customization options for different use cases
- Event-Driven Design: Reactive programming support for responsive interfaces
- Performance Optimized: Efficient rendering and update mechanisms
- Integration Ready: Works seamlessly with layouts, animations, and data binding
Circular indicators exemplify Fern's philosophy: take a simple concept (showing progress), provide deep customization capabilities, but make the common cases effortless. Whether you're building a file transfer dialog, a game character sheet, or a system monitoring dashboard, circular indicators give you the tools to create engaging, informative user interfaces that feel both professional and intuitive.
Master circular indicators, and you'll have a powerful tool for transforming numerical data into compelling visual stories that engage users and provide immediate understanding of system state and progress.