@@ -51,6 +51,7 @@ pub struct Theme {
5151
5252impl Theme {
5353 /// Dark default theme.
54+ #[ allow( dead_code) ]
5455 pub fn dark ( ) -> Self {
5556 Self {
5657 text : Color :: Gray ,
@@ -65,6 +66,144 @@ impl Theme {
6566 highlight_bg : Color :: Reset ,
6667 }
6768 }
69+
70+ /// Catppuccin Mocha theme defaults.
71+ pub fn mocha ( ) -> Self {
72+ // Palette reference: https://github.com/catppuccin/catppuccin
73+ Self {
74+ // text & neutrals
75+ text : Color :: Rgb ( 0xcd , 0xd6 , 0xf4 ) , // text
76+ _muted : Color :: Rgb ( 0x7f , 0x84 , 0x9c ) , // overlay1
77+ // accents and chrome
78+ title : Color :: Rgb ( 0xcb , 0xa6 , 0xf7 ) , // mauve
79+ border : Color :: Rgb ( 0x58 , 0x5b , 0x70 ) , // surface2
80+ header_bg : Color :: Rgb ( 0x31 , 0x32 , 0x44 ) , // surface0
81+ header_fg : Color :: Rgb ( 0xb4 , 0xbe , 0xfe ) , // lavender
82+ status_bg : Color :: Rgb ( 0x45 , 0x47 , 0x5a ) , // surface1
83+ status_fg : Color :: Rgb ( 0xcd , 0xd6 , 0xf4 ) , // text
84+ highlight_fg : Color :: Rgb ( 0xf9 , 0xe2 , 0xaf ) , // yellow
85+ highlight_bg : Color :: Rgb ( 0x45 , 0x47 , 0x5a ) , // surface1
86+ }
87+ }
88+
89+ /// Load theme from a simple key=value file. Unknown or missing keys fall back to `mocha`.
90+ pub fn from_file ( path : & str ) -> Option < Self > {
91+ let contents = std:: fs:: read_to_string ( path) . ok ( ) ?;
92+ let mut theme = Self :: mocha ( ) ;
93+
94+ for raw_line in contents. lines ( ) {
95+ let line = raw_line. trim ( ) ;
96+ if line. is_empty ( ) || line. starts_with ( '#' ) {
97+ continue ;
98+ }
99+ let mut parts = line. splitn ( 2 , '=' ) ;
100+ let key = parts. next ( ) . map ( |s| s. trim ( ) ) . unwrap_or ( "" ) ;
101+ let val = parts. next ( ) . map ( |s| s. trim ( ) ) . unwrap_or ( "" ) ;
102+ if key. is_empty ( ) || val. is_empty ( ) {
103+ continue ;
104+ }
105+ if let Some ( color) = Self :: parse_color ( val) {
106+ match key {
107+ "text" => theme. text = color,
108+ "muted" | "_muted" => theme. _muted = color,
109+ "title" => theme. title = color,
110+ "border" => theme. border = color,
111+ "header_bg" => theme. header_bg = color,
112+ "header_fg" => theme. header_fg = color,
113+ "status_bg" => theme. status_bg = color,
114+ "status_fg" => theme. status_fg = color,
115+ "highlight_fg" => theme. highlight_fg = color,
116+ "highlight_bg" => theme. highlight_bg = color,
117+ _ => { }
118+ }
119+ }
120+ }
121+
122+ Some ( theme)
123+ }
124+
125+ /// Parse a color from hex ("#RRGGBB" or "RRGGBB") or special names: "reset".
126+ fn parse_color ( s : & str ) -> Option < Color > {
127+ let t = s. trim ( ) ;
128+ let lower = t. to_ascii_lowercase ( ) ;
129+ if lower == "reset" {
130+ return Some ( Color :: Reset ) ;
131+ }
132+ let hex = if let Some ( h) = lower. strip_prefix ( '#' ) { h } else { lower. as_str ( ) } ;
133+ if hex. len ( ) == 6 {
134+ if let ( Ok ( r) , Ok ( g) , Ok ( b) ) = (
135+ u8:: from_str_radix ( & hex[ 0 ..2 ] , 16 ) ,
136+ u8:: from_str_radix ( & hex[ 2 ..4 ] , 16 ) ,
137+ u8:: from_str_radix ( & hex[ 4 ..6 ] , 16 ) ,
138+ ) {
139+ return Some ( Color :: Rgb ( r, g, b) ) ;
140+ }
141+ }
142+ None
143+ }
144+
145+ /// Persist the theme to a config file in key=value format.
146+ pub fn write_file ( & self , path : & str ) -> std:: io:: Result < ( ) > {
147+ use std:: fmt:: Write as _;
148+ let mut buf = String :: new ( ) ;
149+ // Minimal header
150+ buf. push_str ( "# usrgrp-manager theme configuration\n " ) ;
151+ buf. push_str ( "# Colors: hex as #RRGGBB or RRGGBB, or 'reset'\n \n " ) ;
152+
153+ fn color_to_str ( c : Color ) -> String {
154+ match c {
155+ Color :: Rgb ( r, g, b) => format ! ( "#{:02X}{:02X}{:02X}" , r, g, b) ,
156+ Color :: Reset => "reset" . to_string ( ) ,
157+ // For named colors, emit a best-effort hex approximation
158+ Color :: Black => "#000000" . to_string ( ) ,
159+ Color :: Red => "#FF0000" . to_string ( ) ,
160+ Color :: Green => "#00FF00" . to_string ( ) ,
161+ Color :: Yellow => "#FFFF00" . to_string ( ) ,
162+ Color :: Blue => "#0000FF" . to_string ( ) ,
163+ Color :: Magenta => "#FF00FF" . to_string ( ) ,
164+ Color :: Cyan => "#00FFFF" . to_string ( ) ,
165+ Color :: Gray => "#B3B3B3" . to_string ( ) ,
166+ Color :: DarkGray => "#4D4D4D" . to_string ( ) ,
167+ Color :: LightRed => "#FF6666" . to_string ( ) ,
168+ Color :: LightGreen => "#66FF66" . to_string ( ) ,
169+ Color :: LightYellow => "#FFFF66" . to_string ( ) ,
170+ Color :: LightBlue => "#6666FF" . to_string ( ) ,
171+ Color :: LightMagenta => "#FF66FF" . to_string ( ) ,
172+ Color :: LightCyan => "#66FFFF" . to_string ( ) ,
173+ Color :: White => "#FFFFFF" . to_string ( ) ,
174+ Color :: Indexed ( i) => format ! ( "index:{}" , i) ,
175+ }
176+ }
177+
178+ let mut kv = |k : & str , v : Color | {
179+ let _ = writeln ! ( & mut buf, "{} = {}" , k, color_to_str( v) ) ;
180+ } ;
181+
182+ kv ( "text" , self . text ) ;
183+ kv ( "muted" , self . _muted ) ;
184+ kv ( "title" , self . title ) ;
185+ kv ( "border" , self . border ) ;
186+ kv ( "header_bg" , self . header_bg ) ;
187+ kv ( "header_fg" , self . header_fg ) ;
188+ kv ( "status_bg" , self . status_bg ) ;
189+ kv ( "status_fg" , self . status_fg ) ;
190+ kv ( "highlight_fg" , self . highlight_fg ) ;
191+ kv ( "highlight_bg" , self . highlight_bg ) ;
192+
193+ std:: fs:: write ( path, buf)
194+ }
195+
196+ /// Ensure a config file exists; if missing, write one with the current default theme and return it.
197+ /// If present, load from it; on parse errors, return `mocha`.
198+ pub fn load_or_init ( path : & str ) -> Self {
199+ let p = std:: path:: Path :: new ( path) ;
200+ if p. exists ( ) {
201+ return Self :: from_file ( path) . unwrap_or_else ( Self :: mocha) ;
202+ }
203+ let t = Self :: mocha ( ) ;
204+ let _ = t. write_file ( path) ;
205+ t
206+ }
68207}
69208
70209/// Modal dialog states for user and group actions.
@@ -73,6 +212,9 @@ pub enum ModalState {
73212 Actions {
74213 selected : usize ,
75214 } ,
215+ FilterMenu {
216+ selected : usize ,
217+ } ,
76218 ModifyMenu {
77219 selected : usize ,
78220 } ,
@@ -167,6 +309,18 @@ pub enum ModifyField {
167309 Fullname ,
168310}
169311
312+ #[ derive( Clone , Copy , Debug , PartialEq , Eq ) ]
313+ pub enum UsersFilter {
314+ OnlyUserIds , // uid >= 1000
315+ OnlySystemIds , // uid < 1000
316+ }
317+
318+ #[ derive( Clone , Copy , Debug , PartialEq , Eq ) ]
319+ pub enum GroupsFilter {
320+ OnlyUserGids , // gid >= 1000
321+ OnlySystemGids , // gid < 1000
322+ }
323+
170324/// Actions that require privileged changes, executed via `sys::SystemAdapter`.
171325#[ derive( Clone , Debug ) ]
172326pub enum PendingAction {
@@ -254,6 +408,8 @@ pub struct AppState {
254408 pub modal : Option < ModalState > ,
255409 pub users_focus : UsersFocus ,
256410 pub sudo_password : Option < String > ,
411+ pub users_filter : Option < UsersFilter > ,
412+ pub groups_filter : Option < GroupsFilter > ,
257413}
258414
259415impl AppState {
@@ -277,10 +433,12 @@ impl AppState {
277433 _table_state : TableState :: default ( ) ,
278434 input_mode : InputMode :: Normal ,
279435 search_query : String :: new ( ) ,
280- theme : Theme :: dark ( ) ,
436+ theme : Theme :: load_or_init ( "theme.conf" ) ,
281437 modal : None ,
282438 users_focus : UsersFocus :: UsersList ,
283439 sudo_password : None ,
440+ users_filter : None ,
441+ groups_filter : None ,
284442 }
285443 }
286444}
0 commit comments