Skip to main content

claude_code_rust/app/
focus.rs

1// Claude Code Rust - A native Rust terminal interface for Claude Code
2// Copyright (C) 2025  Simon Peter Rothgang
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU Affero General Public License as
6// published by the Free Software Foundation, either version 3 of the
7// License, or (at your option) any later version.
8//
9// This program is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU Affero General Public License for more details.
13//
14// You should have received a copy of the GNU Affero General Public License
15// along with this program.  If not, see <https://www.gnu.org/licenses/>.
16
17/// Logical focus target that can claim directional key navigation.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum FocusTarget {
20    TodoList,
21    Mention,
22    Permission,
23    Help,
24}
25
26/// Effective owner of directional/navigation keys.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum FocusOwner {
29    Input,
30    TodoList,
31    Mention,
32    Permission,
33    Help,
34}
35
36#[derive(Debug, Clone, Copy)]
37#[allow(clippy::struct_excessive_bools)]
38pub struct FocusContext {
39    pub todo_focus_available: bool,
40    pub mention_active: bool,
41    pub permission_active: bool,
42    pub help_active: bool,
43}
44
45impl FocusContext {
46    #[must_use]
47    pub const fn new(
48        todo_focus_available: bool,
49        mention_active: bool,
50        permission_active: bool,
51    ) -> Self {
52        Self { todo_focus_available, mention_active, permission_active, help_active: false }
53    }
54
55    #[must_use]
56    #[allow(clippy::fn_params_excessive_bools)]
57    pub const fn with_help(
58        todo_focus_available: bool,
59        mention_active: bool,
60        permission_active: bool,
61        help_active: bool,
62    ) -> Self {
63        Self { todo_focus_available, mention_active, permission_active, help_active }
64    }
65
66    #[must_use]
67    pub const fn supports(self, target: FocusTarget) -> bool {
68        match target {
69            FocusTarget::TodoList => self.todo_focus_available,
70            FocusTarget::Mention => self.mention_active,
71            FocusTarget::Permission => self.permission_active,
72            FocusTarget::Help => self.help_active,
73        }
74    }
75}
76
77impl From<FocusTarget> for FocusOwner {
78    fn from(value: FocusTarget) -> Self {
79        match value {
80            FocusTarget::TodoList => Self::TodoList,
81            FocusTarget::Mention => Self::Mention,
82            FocusTarget::Permission => Self::Permission,
83            FocusTarget::Help => Self::Help,
84        }
85    }
86}
87
88/// Focus claim manager:
89/// latest valid claim wins; invalid claims are dropped during normalization.
90#[derive(Debug, Clone, Default)]
91pub struct FocusManager {
92    stack: Vec<FocusTarget>,
93}
94
95impl FocusManager {
96    /// Resolve the current focus owner for key routing.
97    #[must_use]
98    pub fn owner(&self, context: FocusContext) -> FocusOwner {
99        for target in self.stack.iter().rev().copied() {
100            if context.supports(target) {
101                return target.into();
102            }
103        }
104        FocusOwner::Input
105    }
106
107    /// Claim focus for the target. Latest valid claim wins.
108    pub fn claim(&mut self, target: FocusTarget, context: FocusContext) {
109        self.stack.retain(|t| *t != target);
110        self.stack.push(target);
111        self.normalize(context);
112    }
113
114    /// Release focus claim for the target.
115    pub fn release(&mut self, target: FocusTarget, context: FocusContext) {
116        if let Some(idx) = self.stack.iter().rposition(|t| *t == target) {
117            self.stack.remove(idx);
118        }
119        self.normalize(context);
120    }
121
122    /// Remove claims no longer valid in the current context.
123    pub fn normalize(&mut self, context: FocusContext) {
124        self.stack.retain(|target| context.supports(*target));
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::{FocusContext, FocusManager, FocusOwner, FocusTarget};
131
132    #[test]
133    fn owner_defaults_to_input_without_claims() {
134        let mgr = FocusManager::default();
135        let ctx = FocusContext::new(false, false, false);
136        assert_eq!(mgr.owner(ctx), FocusOwner::Input);
137    }
138
139    #[test]
140    fn latest_valid_claim_wins() {
141        let mut mgr = FocusManager::default();
142        let ctx = FocusContext::new(true, true, true);
143        mgr.claim(FocusTarget::TodoList, ctx);
144        mgr.claim(FocusTarget::Permission, ctx);
145        mgr.claim(FocusTarget::Mention, ctx);
146        assert_eq!(mgr.owner(ctx), FocusOwner::Mention);
147    }
148
149    #[test]
150    fn invalid_claims_are_normalized_out() {
151        let mut mgr = FocusManager::default();
152        let valid_ctx = FocusContext::new(true, false, false);
153        let invalid_ctx = FocusContext::new(false, false, false);
154        mgr.claim(FocusTarget::TodoList, valid_ctx);
155        assert_eq!(mgr.owner(valid_ctx), FocusOwner::TodoList);
156        mgr.normalize(invalid_ctx);
157        assert_eq!(mgr.owner(invalid_ctx), FocusOwner::Input);
158    }
159
160    #[test]
161    fn help_focus_target_works_when_enabled() {
162        let mut mgr = FocusManager::default();
163        let ctx = FocusContext::with_help(false, false, false, true);
164        mgr.claim(FocusTarget::Help, ctx);
165        assert_eq!(mgr.owner(ctx), FocusOwner::Help);
166    }
167}