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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ Semantic Versioning.

## [Unreleased]

### Changed
- Refined Hardware Control settings so unavailable hardware control modes are disabled with clearer fallback guidance.
- Clarified per-display DDC/CI support status and tightened the advanced polling and write-delay controls.

### Fixed
- Fixed hardware brightness control fallback so DDC/CI control is only used when hardware control is enabled and available.
- Fixed display schedules so missed events between app checks are applied in chronological order.
- Fixed keyboard shortcut recording so the recorder reliably receives focus before capturing keys.

## [1.0.2] - 2026-07-01

### Fixed
Expand Down
5 changes: 3 additions & 2 deletions Dimmerly/Services/BrightnessManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -891,8 +891,9 @@ class BrightnessManager {
return true
}

let mode = HardwareBrightnessManager.shared.controlMode
return mode != .softwareOnly && HardwareBrightnessManager.shared.supportsDDC(for: display.id)
let hardwareManager = HardwareBrightnessManager.shared
let mode = hardwareManager.controlMode
return hardwareManager.isEnabled && mode != .softwareOnly && hardwareManager.supportsDDC(for: display.id)
}

private func setExternalHardwareBrightness(for displayID: CGDirectDisplayID, to value: Double) {
Expand Down
74 changes: 56 additions & 18 deletions Dimmerly/Services/ScheduleManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ class ScheduleManager {
/// Cached enabled state to detect changes and avoid redundant timer restarts.
private var lastEnabled: Bool?

/// A concrete schedule trigger that falls inside a polling catch-up window.
private struct ScheduleFireCandidate {
let triggerDate: Date
let scheduleIndex: Int
let schedule: DimmingSchedule
let dayString: String
}

/// UserDefaults key for persisting schedules as JSON.
private static let schedulesKey = "dimmerlyDimmingSchedules"

Expand Down Expand Up @@ -135,24 +143,10 @@ class ScheduleManager {
let todayString = Self.dateString(for: now)
let previousCheck = effectivePreviousCheckDate(for: now)

for schedule in schedules where schedule.isEnabled {
// Skip if already fired today (prevents firing same schedule multiple times per day)
if firedToday[schedule.id] == todayString {
continue
}

// Resolve the trigger to a concrete date/time for today
// (sunrise/sunset times vary by date, so we must resolve each check)
guard let triggerDate = resolveTriggerDate(schedule.trigger, on: now) else {
continue
}

// Fire once if the trigger time falls within the check window (previousCheck, now]
// This half-open interval ensures we fire exactly once when crossing the trigger time
if triggerDate > previousCheck, triggerDate <= now {
firedToday[schedule.id] = todayString
onScheduleTriggered?(schedule.presetID)
}
for candidate in fireCandidates(previousCheck: previousCheck, now: now) {
guard firedToday[candidate.schedule.id] != candidate.dayString else { continue }
firedToday[candidate.schedule.id] = candidate.dayString
onScheduleTriggered?(candidate.schedule.presetID)
}

// Clean up stale entries from firedToday (schedules fired on previous days)
Expand Down Expand Up @@ -180,6 +174,50 @@ class ScheduleManager {
return lastCheckDate
}

/// Builds all schedule triggers crossed in `(previousCheck, now]`, sorted by trigger time.
private func fireCandidates(previousCheck: Date, now: Date) -> [ScheduleFireCandidate] {
let daysToCheck = Self.daysInRange(from: previousCheck, through: now)
var candidates: [ScheduleFireCandidate] = []

for (scheduleIndex, schedule) in schedules.enumerated() where schedule.isEnabled {
for day in daysToCheck {
let dayString = Self.dateString(for: day)
guard firedToday[schedule.id] != dayString else { continue }
guard let triggerDate = resolveTriggerDate(schedule.trigger, on: day) else { continue }
guard triggerDate > previousCheck, triggerDate <= now else { continue }

candidates.append(ScheduleFireCandidate(
triggerDate: triggerDate,
scheduleIndex: scheduleIndex,
schedule: schedule,
dayString: dayString
))
}
}

return candidates.sorted {
if $0.triggerDate == $1.triggerDate {
return $0.scheduleIndex < $1.scheduleIndex
}
return $0.triggerDate < $1.triggerDate
}
}

private static func daysInRange(from start: Date, through end: Date) -> [Date] {
let calendar = Calendar.current
var day = calendar.startOfDay(for: start)
let endDay = calendar.startOfDay(for: end)
var days: [Date] = []

while day <= endDay {
days.append(day)
guard let nextDay = calendar.date(byAdding: .day, value: 1, to: day) else { break }
day = nextDay
}

return days
}

/// Converts a ScheduleTrigger to a concrete Date for a given day.
///
/// Trigger types:
Expand Down
3 changes: 3 additions & 0 deletions Dimmerly/Views/KeyboardShortcutRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ private struct ShortcutRecorderView: NSViewRepresentable {
if let captureView = nsView as? ShortcutCaptureView {
captureView.isActive = isRecording
}
if isRecording {
nsView.window?.makeFirstResponder(nsView)
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion Dimmerly/Views/MenuBarPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1116,7 +1116,7 @@ struct DisplayBrightnessRow: View {
guard !isBuiltIn else { return self }

var copy = self
let hasDDC = hardwareManager.supportsDDC(for: displayID)
let hasDDC = hardwareManager.isEnabled && hardwareManager.supportsDDC(for: displayID)
copy.hasDDC = hasDDC

if hasDDC {
Expand Down
Loading
Loading