markdown · 18222 bytes Raw Blame History

Sprint 3: Greeter UI

Goal: Build a sleek, centered login interface with blurred background using Cairo/Pango rendering.

Objectives

  • Create X11 window that covers the full screen
  • Render blurred background image
  • Implement centered login form (username, password inputs)
  • Add session selector dropdown
  • Add power buttons (shutdown, reboot, suspend)
  • Handle keyboard input and focus
  • Connect UI to daemon via IPC

Design Reference

┌─────────────────────────────────────────────────────────────────────┐
│                                                                     │
│                         [Blurred Background]                        │
│                                                                     │
│                     ┌───────────────────────┐                       │
│                     │       User Avatar     │                       │
│                     │          👤           │                       │
│                     ├───────────────────────┤                       │
│                     │  Username: [       ]  │                       │
│                     │  Password: [*******]  │                       │
│                     │                       │                       │
│                     │  Session:  [gar    ▼] │                       │
│                     │                       │                       │
│                     │      [ Login ]        │                       │
│                     │                       │                       │
│                     │   Error message here  │                       │
│                     └───────────────────────┘                       │
│                                                                     │
│  12:34 PM                                          [⏻] [↻] [⏾]     │
│  January 14, 2026                                                   │
└─────────────────────────────────────────────────────────────────────┘

Tasks

3.1 Window Setup

// gardm-greeter/src/window.rs

use x11rb::connection::Connection;
use x11rb::protocol::xproto::*;
use x11rb::wrapper::ConnectionExt;

pub struct GreeterWindow {
    conn: x11rb::rust_connection::RustConnection,
    screen_num: usize,
    window: Window,
    width: u16,
    height: u16,
    gc: Gcontext,
}

impl GreeterWindow {
    pub fn new() -> anyhow::Result<Self> {
        let (conn, screen_num) = x11rb::connect(None)?;
        let screen = &conn.setup().roots[screen_num];

        let width = screen.width_in_pixels;
        let height = screen.height_in_pixels;
        let root = screen.root;
        let depth = screen.root_depth;
        let visual = screen.root_visual;

        // Create window
        let window = conn.generate_id()?;
        conn.create_window(
            depth,
            window,
            root,
            0, 0,
            width, height,
            0,
            WindowClass::INPUT_OUTPUT,
            visual,
            &CreateWindowAux::new()
                .background_pixel(screen.black_pixel)
                .event_mask(
                    EventMask::EXPOSURE
                        | EventMask::KEY_PRESS
                        | EventMask::BUTTON_PRESS
                        | EventMask::STRUCTURE_NOTIFY
                ),
        )?;

        // Make it fullscreen (bypass WM)
        let net_wm_state = conn.intern_atom(false, b"_NET_WM_STATE")?.reply()?.atom;
        let fullscreen = conn.intern_atom(false, b"_NET_WM_STATE_FULLSCREEN")?.reply()?.atom;
        conn.change_property32(
            PropMode::REPLACE,
            window,
            net_wm_state,
            AtomEnum::ATOM,
            &[fullscreen],
        )?;

        // Override redirect for greeter (no WM decorations)
        conn.change_window_attributes(
            window,
            &ChangeWindowAttributesAux::new().override_redirect(1),
        )?;

        // Create graphics context
        let gc = conn.generate_id()?;
        conn.create_gc(gc, window, &CreateGCAux::new())?;

        conn.map_window(window)?;
        conn.flush()?;

        Ok(Self {
            conn,
            screen_num,
            window,
            width,
            height,
            gc,
        })
    }

    pub fn width(&self) -> u16 { self.width }
    pub fn height(&self) -> u16 { self.height }
    pub fn window(&self) -> Window { self.window }
    pub fn conn(&self) -> &x11rb::rust_connection::RustConnection { &self.conn }
}

3.2 Cairo Rendering Surface

// gardm-greeter/src/render.rs

use cairo::{Context, ImageSurface, Format};
use x11rb::protocol::xproto::*;

pub struct Renderer {
    surface: ImageSurface,
    width: i32,
    height: i32,
}

impl Renderer {
    pub fn new(width: u16, height: u16) -> anyhow::Result<Self> {
        let surface = ImageSurface::create(Format::ARgb32, width as i32, height as i32)?;
        Ok(Self {
            surface,
            width: width as i32,
            height: height as i32,
        })
    }

    pub fn context(&self) -> anyhow::Result<Context> {
        Ok(Context::new(&self.surface)?)
    }

    /// Get raw pixel data for X11
    pub fn data(&self) -> Vec<u8> {
        let stride = self.surface.stride() as usize;
        let height = self.height as usize;
        let data = self.surface.data().unwrap();

        // Cairo uses ARGB, X11 uses BGRA - but with same byte order on little-endian
        data[..stride * height].to_vec()
    }

    pub fn width(&self) -> i32 { self.width }
    pub fn height(&self) -> i32 { self.height }
}

3.3 Background with Blur

// gardm-greeter/src/background.rs

use image::{RgbaImage, imageops};

/// Load and blur a background image
pub fn load_blurred_background(
    path: &str,
    width: u32,
    height: u32,
    blur_radius: f32,
    brightness: f32,
) -> anyhow::Result<RgbaImage> {
    // Load image
    let img = image::open(path)?.to_rgba8();

    // Scale to screen size (cover mode)
    let scaled = scale_to_cover(&img, width, height);

    // Apply gaussian blur
    let blurred = imageops::blur(&scaled, blur_radius);

    // Adjust brightness (darken for better text contrast)
    let adjusted = adjust_brightness(&blurred, brightness);

    Ok(adjusted)
}

fn scale_to_cover(img: &RgbaImage, target_w: u32, target_h: u32) -> RgbaImage {
    let (src_w, src_h) = img.dimensions();
    let scale = (target_w as f32 / src_w as f32)
        .max(target_h as f32 / src_h as f32);

    let new_w = (src_w as f32 * scale) as u32;
    let new_h = (src_h as f32 * scale) as u32;

    let resized = imageops::resize(img, new_w, new_h, imageops::FilterType::Lanczos3);

    // Crop to center
    let x = (new_w - target_w) / 2;
    let y = (new_h - target_h) / 2;

    imageops::crop_imm(&resized, x, y, target_w, target_h).to_image()
}

fn adjust_brightness(img: &RgbaImage, factor: f32) -> RgbaImage {
    let mut result = img.clone();
    for pixel in result.pixels_mut() {
        pixel[0] = (pixel[0] as f32 * factor).min(255.0) as u8;
        pixel[1] = (pixel[1] as f32 * factor).min(255.0) as u8;
        pixel[2] = (pixel[2] as f32 * factor).min(255.0) as u8;
    }
    result
}

3.4 Login Form Widget

// gardm-greeter/src/widgets/login_form.rs

use cairo::Context;
use pango::{FontDescription, Layout};

pub struct LoginForm {
    pub username: String,
    pub password: String,
    pub focused_field: FocusedField,
    pub error_message: Option<String>,
    pub is_loading: bool,

    // Layout
    x: f64,
    y: f64,
    width: f64,
    height: f64,
}

#[derive(Clone, Copy, PartialEq)]
pub enum FocusedField {
    Username,
    Password,
}

impl LoginForm {
    pub fn new(screen_width: f64, screen_height: f64) -> Self {
        let width = 400.0;
        let height = 300.0;

        Self {
            username: String::new(),
            password: String::new(),
            focused_field: FocusedField::Username,
            error_message: None,
            is_loading: false,
            x: (screen_width - width) / 2.0,
            y: (screen_height - height) / 2.0,
            width,
            height,
        }
    }

    pub fn render(&self, ctx: &Context, pango_ctx: &pango::Context) -> anyhow::Result<()> {
        // Background panel (semi-transparent)
        ctx.set_source_rgba(0.1, 0.1, 0.1, 0.85);
        rounded_rectangle(ctx, self.x, self.y, self.width, self.height, 16.0);
        ctx.fill()?;

        // Title
        let title_layout = Layout::new(pango_ctx);
        let mut font = FontDescription::new();
        font.set_family("Sans");
        font.set_size(24 * pango::SCALE);
        font.set_weight(pango::Weight::Bold);
        title_layout.set_font_description(Some(&font));
        title_layout.set_text("Welcome");

        ctx.set_source_rgb(1.0, 1.0, 1.0);
        ctx.move_to(self.x + self.width / 2.0 - 50.0, self.y + 30.0);
        pangocairo::show_layout(ctx, &title_layout);

        // Username field
        self.render_input_field(
            ctx, pango_ctx,
            "Username",
            &self.username,
            self.y + 100.0,
            self.focused_field == FocusedField::Username,
            false,
        )?;

        // Password field
        self.render_input_field(
            ctx, pango_ctx,
            "Password",
            &"•".repeat(self.password.len()),
            self.y + 160.0,
            self.focused_field == FocusedField::Password,
            true,
        )?;

        // Error message
        if let Some(ref msg) = self.error_message {
            ctx.set_source_rgb(1.0, 0.3, 0.3);
            let err_layout = Layout::new(pango_ctx);
            font.set_size(12 * pango::SCALE);
            font.set_weight(pango::Weight::Normal);
            err_layout.set_font_description(Some(&font));
            err_layout.set_text(msg);
            ctx.move_to(self.x + 30.0, self.y + 230.0);
            pangocairo::show_layout(ctx, &err_layout);
        }

        // Login button
        self.render_button(ctx, pango_ctx, "Login", self.y + 260.0)?;

        Ok(())
    }

    fn render_input_field(
        &self,
        ctx: &Context,
        pango_ctx: &pango::Context,
        label: &str,
        value: &str,
        y: f64,
        focused: bool,
        _is_password: bool,
    ) -> anyhow::Result<()> {
        let field_x = self.x + 30.0;
        let field_width = self.width - 60.0;
        let field_height = 40.0;

        // Label
        let mut font = FontDescription::new();
        font.set_family("Sans");
        font.set_size(11 * pango::SCALE);

        let label_layout = Layout::new(pango_ctx);
        label_layout.set_font_description(Some(&font));
        label_layout.set_text(label);

        ctx.set_source_rgba(0.8, 0.8, 0.8, 1.0);
        ctx.move_to(field_x, y - 18.0);
        pangocairo::show_layout(ctx, &label_layout);

        // Input box
        if focused {
            ctx.set_source_rgba(0.3, 0.5, 0.8, 1.0);
        } else {
            ctx.set_source_rgba(0.3, 0.3, 0.3, 1.0);
        }
        rounded_rectangle(ctx, field_x, y, field_width, field_height, 8.0);
        ctx.fill()?;

        // Text value
        ctx.set_source_rgb(1.0, 1.0, 1.0);
        font.set_size(14 * pango::SCALE);
        let value_layout = Layout::new(pango_ctx);
        value_layout.set_font_description(Some(&font));
        value_layout.set_text(if value.is_empty() { " " } else { value });
        ctx.move_to(field_x + 10.0, y + 10.0);
        pangocairo::show_layout(ctx, &value_layout);

        // Cursor
        if focused {
            let (text_width, _) = value_layout.pixel_size();
            ctx.set_source_rgb(1.0, 1.0, 1.0);
            ctx.rectangle(field_x + 10.0 + text_width as f64, y + 8.0, 2.0, 24.0);
            ctx.fill()?;
        }

        Ok(())
    }

    fn render_button(
        &self,
        ctx: &Context,
        pango_ctx: &pango::Context,
        text: &str,
        y: f64,
    ) -> anyhow::Result<()> {
        let btn_width = 120.0;
        let btn_height = 36.0;
        let btn_x = self.x + (self.width - btn_width) / 2.0;

        // Button background
        ctx.set_source_rgba(0.2, 0.5, 0.8, 1.0);
        rounded_rectangle(ctx, btn_x, y, btn_width, btn_height, 8.0);
        ctx.fill()?;

        // Button text
        ctx.set_source_rgb(1.0, 1.0, 1.0);
        let mut font = FontDescription::new();
        font.set_family("Sans");
        font.set_size(14 * pango::SCALE);
        font.set_weight(pango::Weight::Bold);

        let layout = Layout::new(pango_ctx);
        layout.set_font_description(Some(&font));
        layout.set_text(text);

        let (text_w, _) = layout.pixel_size();
        ctx.move_to(btn_x + (btn_width - text_w as f64) / 2.0, y + 8.0);
        pangocairo::show_layout(ctx, &layout);

        Ok(())
    }

    pub fn handle_key(&mut self, key: char) {
        match self.focused_field {
            FocusedField::Username => self.username.push(key),
            FocusedField::Password => self.password.push(key),
        }
    }

    pub fn handle_backspace(&mut self) {
        match self.focused_field {
            FocusedField::Username => { self.username.pop(); }
            FocusedField::Password => { self.password.pop(); }
        }
    }

    pub fn handle_tab(&mut self) {
        self.focused_field = match self.focused_field {
            FocusedField::Username => FocusedField::Password,
            FocusedField::Password => FocusedField::Username,
        };
    }
}

fn rounded_rectangle(ctx: &Context, x: f64, y: f64, w: f64, h: f64, r: f64) {
    let degrees = std::f64::consts::PI / 180.0;
    ctx.new_sub_path();
    ctx.arc(x + w - r, y + r, r, -90.0 * degrees, 0.0 * degrees);
    ctx.arc(x + w - r, y + h - r, r, 0.0 * degrees, 90.0 * degrees);
    ctx.arc(x + r, y + h - r, r, 90.0 * degrees, 180.0 * degrees);
    ctx.arc(x + r, y + r, r, 180.0 * degrees, 270.0 * degrees);
    ctx.close_path();
}

3.5 Main Event Loop

// gardm-greeter/src/main.rs

use x11rb::protocol::Event;

fn main() -> anyhow::Result<()> {
    let window = GreeterWindow::new()?;
    let renderer = Renderer::new(window.width(), window.height())?;
    let mut form = LoginForm::new(window.width() as f64, window.height() as f64);

    // Load background
    let background = load_blurred_background(
        "/usr/share/gardm/backgrounds/default.jpg",
        window.width() as u32,
        window.height() as u32,
        20.0,
        0.7,
    )?;

    // Connect to daemon
    let mut daemon = DaemonClient::connect()?;

    loop {
        // Render frame
        {
            let ctx = renderer.context()?;
            render_background(&ctx, &background)?;
            form.render(&ctx, &pango_ctx)?;
        }

        // Copy to X11
        window.put_image(&renderer.data())?;

        // Handle events
        let event = window.conn().wait_for_event()?;
        match event {
            Event::Expose(_) => {
                // Redraw handled above
            }
            Event::KeyPress(e) => {
                match e.detail {
                    9 => break,  // Escape - exit (for testing)
                    36 => {      // Enter - submit
                        if form.focused_field == FocusedField::Password {
                            // Authenticate
                            daemon.create_session(&form.username)?;
                            match daemon.authenticate(&form.password)? {
                                Response::Success => {
                                    daemon.start_session(&["gar-session.sh"])?;
                                    break;
                                }
                                Response::AuthError { message } => {
                                    form.error_message = Some(message);
                                }
                                _ => {}
                            }
                        } else {
                            form.handle_tab();
                        }
                    }
                    23 => form.handle_tab(),  // Tab
                    22 => form.handle_backspace(),  // Backspace
                    _ => {
                        // Regular key
                        if let Some(c) = keycode_to_char(e.detail, e.state) {
                            form.handle_key(c);
                        }
                    }
                }
            }
            _ => {}
        }
    }

    Ok(())
}

Acceptance Criteria

  1. Greeter displays fullscreen with blurred background
  2. Login form is centered and styled
  3. Keyboard input works for username/password
  4. Tab switches between fields
  5. Enter submits form
  6. Error messages display correctly
  7. Successful auth triggers session start

Pitfalls to Avoid

  1. X11 keycodes vary by layout - use XKB for proper key mapping
  2. Cairo surface format - ensure ARGB matches X11 expectations
  3. Font rendering - initialize Pango context correctly
  4. Fullscreen on all monitors - may need per-monitor handling
  5. Input focus - greeter window must grab keyboard
  6. Memory leaks - Cairo contexts need proper cleanup

Testing

# Test in Xephyr
Xephyr -br -ac -noreset -screen 1280x720 :1 &
DISPLAY=:1 ./target/release/gardm-greeter

# Test keyboard input
# Test form submission
# Test error display

Dependencies for This Sprint

# gardm-greeter/Cargo.toml
[dependencies]
x11rb = "0.13"
cairo-rs = { version = "0.18", features = ["png"] }
pango = "0.18"
pangocairo = "0.18"
image = "0.24"

Next Sprint

Sprint 4 will add garbg integration for seamless wallpaper sync.

View source
1 # Sprint 3: Greeter UI
2
3 **Goal:** Build a sleek, centered login interface with blurred background using Cairo/Pango rendering.
4
5 ## Objectives
6
7 - Create X11 window that covers the full screen
8 - Render blurred background image
9 - Implement centered login form (username, password inputs)
10 - Add session selector dropdown
11 - Add power buttons (shutdown, reboot, suspend)
12 - Handle keyboard input and focus
13 - Connect UI to daemon via IPC
14
15 ## Design Reference
16
17 ```
18 ┌─────────────────────────────────────────────────────────────────────┐
19 │ │
20 │ [Blurred Background] │
21 │ │
22 │ ┌───────────────────────┐ │
23 │ │ User Avatar │ │
24 │ │ 👤 │ │
25 │ ├───────────────────────┤ │
26 │ │ Username: [ ] │ │
27 │ │ Password: [*******] │ │
28 │ │ │ │
29 │ │ Session: [gar ▼] │ │
30 │ │ │ │
31 │ │ [ Login ] │ │
32 │ │ │ │
33 │ │ Error message here │ │
34 │ └───────────────────────┘ │
35 │ │
36 │ 12:34 PM [⏻] [↻] [⏾] │
37 │ January 14, 2026 │
38 └─────────────────────────────────────────────────────────────────────┘
39 ```
40
41 ## Tasks
42
43 ### 3.1 Window Setup
44
45 ```rust
46 // gardm-greeter/src/window.rs
47
48 use x11rb::connection::Connection;
49 use x11rb::protocol::xproto::*;
50 use x11rb::wrapper::ConnectionExt;
51
52 pub struct GreeterWindow {
53 conn: x11rb::rust_connection::RustConnection,
54 screen_num: usize,
55 window: Window,
56 width: u16,
57 height: u16,
58 gc: Gcontext,
59 }
60
61 impl GreeterWindow {
62 pub fn new() -> anyhow::Result<Self> {
63 let (conn, screen_num) = x11rb::connect(None)?;
64 let screen = &conn.setup().roots[screen_num];
65
66 let width = screen.width_in_pixels;
67 let height = screen.height_in_pixels;
68 let root = screen.root;
69 let depth = screen.root_depth;
70 let visual = screen.root_visual;
71
72 // Create window
73 let window = conn.generate_id()?;
74 conn.create_window(
75 depth,
76 window,
77 root,
78 0, 0,
79 width, height,
80 0,
81 WindowClass::INPUT_OUTPUT,
82 visual,
83 &CreateWindowAux::new()
84 .background_pixel(screen.black_pixel)
85 .event_mask(
86 EventMask::EXPOSURE
87 | EventMask::KEY_PRESS
88 | EventMask::BUTTON_PRESS
89 | EventMask::STRUCTURE_NOTIFY
90 ),
91 )?;
92
93 // Make it fullscreen (bypass WM)
94 let net_wm_state = conn.intern_atom(false, b"_NET_WM_STATE")?.reply()?.atom;
95 let fullscreen = conn.intern_atom(false, b"_NET_WM_STATE_FULLSCREEN")?.reply()?.atom;
96 conn.change_property32(
97 PropMode::REPLACE,
98 window,
99 net_wm_state,
100 AtomEnum::ATOM,
101 &[fullscreen],
102 )?;
103
104 // Override redirect for greeter (no WM decorations)
105 conn.change_window_attributes(
106 window,
107 &ChangeWindowAttributesAux::new().override_redirect(1),
108 )?;
109
110 // Create graphics context
111 let gc = conn.generate_id()?;
112 conn.create_gc(gc, window, &CreateGCAux::new())?;
113
114 conn.map_window(window)?;
115 conn.flush()?;
116
117 Ok(Self {
118 conn,
119 screen_num,
120 window,
121 width,
122 height,
123 gc,
124 })
125 }
126
127 pub fn width(&self) -> u16 { self.width }
128 pub fn height(&self) -> u16 { self.height }
129 pub fn window(&self) -> Window { self.window }
130 pub fn conn(&self) -> &x11rb::rust_connection::RustConnection { &self.conn }
131 }
132 ```
133
134 ### 3.2 Cairo Rendering Surface
135
136 ```rust
137 // gardm-greeter/src/render.rs
138
139 use cairo::{Context, ImageSurface, Format};
140 use x11rb::protocol::xproto::*;
141
142 pub struct Renderer {
143 surface: ImageSurface,
144 width: i32,
145 height: i32,
146 }
147
148 impl Renderer {
149 pub fn new(width: u16, height: u16) -> anyhow::Result<Self> {
150 let surface = ImageSurface::create(Format::ARgb32, width as i32, height as i32)?;
151 Ok(Self {
152 surface,
153 width: width as i32,
154 height: height as i32,
155 })
156 }
157
158 pub fn context(&self) -> anyhow::Result<Context> {
159 Ok(Context::new(&self.surface)?)
160 }
161
162 /// Get raw pixel data for X11
163 pub fn data(&self) -> Vec<u8> {
164 let stride = self.surface.stride() as usize;
165 let height = self.height as usize;
166 let data = self.surface.data().unwrap();
167
168 // Cairo uses ARGB, X11 uses BGRA - but with same byte order on little-endian
169 data[..stride * height].to_vec()
170 }
171
172 pub fn width(&self) -> i32 { self.width }
173 pub fn height(&self) -> i32 { self.height }
174 }
175 ```
176
177 ### 3.3 Background with Blur
178
179 ```rust
180 // gardm-greeter/src/background.rs
181
182 use image::{RgbaImage, imageops};
183
184 /// Load and blur a background image
185 pub fn load_blurred_background(
186 path: &str,
187 width: u32,
188 height: u32,
189 blur_radius: f32,
190 brightness: f32,
191 ) -> anyhow::Result<RgbaImage> {
192 // Load image
193 let img = image::open(path)?.to_rgba8();
194
195 // Scale to screen size (cover mode)
196 let scaled = scale_to_cover(&img, width, height);
197
198 // Apply gaussian blur
199 let blurred = imageops::blur(&scaled, blur_radius);
200
201 // Adjust brightness (darken for better text contrast)
202 let adjusted = adjust_brightness(&blurred, brightness);
203
204 Ok(adjusted)
205 }
206
207 fn scale_to_cover(img: &RgbaImage, target_w: u32, target_h: u32) -> RgbaImage {
208 let (src_w, src_h) = img.dimensions();
209 let scale = (target_w as f32 / src_w as f32)
210 .max(target_h as f32 / src_h as f32);
211
212 let new_w = (src_w as f32 * scale) as u32;
213 let new_h = (src_h as f32 * scale) as u32;
214
215 let resized = imageops::resize(img, new_w, new_h, imageops::FilterType::Lanczos3);
216
217 // Crop to center
218 let x = (new_w - target_w) / 2;
219 let y = (new_h - target_h) / 2;
220
221 imageops::crop_imm(&resized, x, y, target_w, target_h).to_image()
222 }
223
224 fn adjust_brightness(img: &RgbaImage, factor: f32) -> RgbaImage {
225 let mut result = img.clone();
226 for pixel in result.pixels_mut() {
227 pixel[0] = (pixel[0] as f32 * factor).min(255.0) as u8;
228 pixel[1] = (pixel[1] as f32 * factor).min(255.0) as u8;
229 pixel[2] = (pixel[2] as f32 * factor).min(255.0) as u8;
230 }
231 result
232 }
233 ```
234
235 ### 3.4 Login Form Widget
236
237 ```rust
238 // gardm-greeter/src/widgets/login_form.rs
239
240 use cairo::Context;
241 use pango::{FontDescription, Layout};
242
243 pub struct LoginForm {
244 pub username: String,
245 pub password: String,
246 pub focused_field: FocusedField,
247 pub error_message: Option<String>,
248 pub is_loading: bool,
249
250 // Layout
251 x: f64,
252 y: f64,
253 width: f64,
254 height: f64,
255 }
256
257 #[derive(Clone, Copy, PartialEq)]
258 pub enum FocusedField {
259 Username,
260 Password,
261 }
262
263 impl LoginForm {
264 pub fn new(screen_width: f64, screen_height: f64) -> Self {
265 let width = 400.0;
266 let height = 300.0;
267
268 Self {
269 username: String::new(),
270 password: String::new(),
271 focused_field: FocusedField::Username,
272 error_message: None,
273 is_loading: false,
274 x: (screen_width - width) / 2.0,
275 y: (screen_height - height) / 2.0,
276 width,
277 height,
278 }
279 }
280
281 pub fn render(&self, ctx: &Context, pango_ctx: &pango::Context) -> anyhow::Result<()> {
282 // Background panel (semi-transparent)
283 ctx.set_source_rgba(0.1, 0.1, 0.1, 0.85);
284 rounded_rectangle(ctx, self.x, self.y, self.width, self.height, 16.0);
285 ctx.fill()?;
286
287 // Title
288 let title_layout = Layout::new(pango_ctx);
289 let mut font = FontDescription::new();
290 font.set_family("Sans");
291 font.set_size(24 * pango::SCALE);
292 font.set_weight(pango::Weight::Bold);
293 title_layout.set_font_description(Some(&font));
294 title_layout.set_text("Welcome");
295
296 ctx.set_source_rgb(1.0, 1.0, 1.0);
297 ctx.move_to(self.x + self.width / 2.0 - 50.0, self.y + 30.0);
298 pangocairo::show_layout(ctx, &title_layout);
299
300 // Username field
301 self.render_input_field(
302 ctx, pango_ctx,
303 "Username",
304 &self.username,
305 self.y + 100.0,
306 self.focused_field == FocusedField::Username,
307 false,
308 )?;
309
310 // Password field
311 self.render_input_field(
312 ctx, pango_ctx,
313 "Password",
314 &"•".repeat(self.password.len()),
315 self.y + 160.0,
316 self.focused_field == FocusedField::Password,
317 true,
318 )?;
319
320 // Error message
321 if let Some(ref msg) = self.error_message {
322 ctx.set_source_rgb(1.0, 0.3, 0.3);
323 let err_layout = Layout::new(pango_ctx);
324 font.set_size(12 * pango::SCALE);
325 font.set_weight(pango::Weight::Normal);
326 err_layout.set_font_description(Some(&font));
327 err_layout.set_text(msg);
328 ctx.move_to(self.x + 30.0, self.y + 230.0);
329 pangocairo::show_layout(ctx, &err_layout);
330 }
331
332 // Login button
333 self.render_button(ctx, pango_ctx, "Login", self.y + 260.0)?;
334
335 Ok(())
336 }
337
338 fn render_input_field(
339 &self,
340 ctx: &Context,
341 pango_ctx: &pango::Context,
342 label: &str,
343 value: &str,
344 y: f64,
345 focused: bool,
346 _is_password: bool,
347 ) -> anyhow::Result<()> {
348 let field_x = self.x + 30.0;
349 let field_width = self.width - 60.0;
350 let field_height = 40.0;
351
352 // Label
353 let mut font = FontDescription::new();
354 font.set_family("Sans");
355 font.set_size(11 * pango::SCALE);
356
357 let label_layout = Layout::new(pango_ctx);
358 label_layout.set_font_description(Some(&font));
359 label_layout.set_text(label);
360
361 ctx.set_source_rgba(0.8, 0.8, 0.8, 1.0);
362 ctx.move_to(field_x, y - 18.0);
363 pangocairo::show_layout(ctx, &label_layout);
364
365 // Input box
366 if focused {
367 ctx.set_source_rgba(0.3, 0.5, 0.8, 1.0);
368 } else {
369 ctx.set_source_rgba(0.3, 0.3, 0.3, 1.0);
370 }
371 rounded_rectangle(ctx, field_x, y, field_width, field_height, 8.0);
372 ctx.fill()?;
373
374 // Text value
375 ctx.set_source_rgb(1.0, 1.0, 1.0);
376 font.set_size(14 * pango::SCALE);
377 let value_layout = Layout::new(pango_ctx);
378 value_layout.set_font_description(Some(&font));
379 value_layout.set_text(if value.is_empty() { " " } else { value });
380 ctx.move_to(field_x + 10.0, y + 10.0);
381 pangocairo::show_layout(ctx, &value_layout);
382
383 // Cursor
384 if focused {
385 let (text_width, _) = value_layout.pixel_size();
386 ctx.set_source_rgb(1.0, 1.0, 1.0);
387 ctx.rectangle(field_x + 10.0 + text_width as f64, y + 8.0, 2.0, 24.0);
388 ctx.fill()?;
389 }
390
391 Ok(())
392 }
393
394 fn render_button(
395 &self,
396 ctx: &Context,
397 pango_ctx: &pango::Context,
398 text: &str,
399 y: f64,
400 ) -> anyhow::Result<()> {
401 let btn_width = 120.0;
402 let btn_height = 36.0;
403 let btn_x = self.x + (self.width - btn_width) / 2.0;
404
405 // Button background
406 ctx.set_source_rgba(0.2, 0.5, 0.8, 1.0);
407 rounded_rectangle(ctx, btn_x, y, btn_width, btn_height, 8.0);
408 ctx.fill()?;
409
410 // Button text
411 ctx.set_source_rgb(1.0, 1.0, 1.0);
412 let mut font = FontDescription::new();
413 font.set_family("Sans");
414 font.set_size(14 * pango::SCALE);
415 font.set_weight(pango::Weight::Bold);
416
417 let layout = Layout::new(pango_ctx);
418 layout.set_font_description(Some(&font));
419 layout.set_text(text);
420
421 let (text_w, _) = layout.pixel_size();
422 ctx.move_to(btn_x + (btn_width - text_w as f64) / 2.0, y + 8.0);
423 pangocairo::show_layout(ctx, &layout);
424
425 Ok(())
426 }
427
428 pub fn handle_key(&mut self, key: char) {
429 match self.focused_field {
430 FocusedField::Username => self.username.push(key),
431 FocusedField::Password => self.password.push(key),
432 }
433 }
434
435 pub fn handle_backspace(&mut self) {
436 match self.focused_field {
437 FocusedField::Username => { self.username.pop(); }
438 FocusedField::Password => { self.password.pop(); }
439 }
440 }
441
442 pub fn handle_tab(&mut self) {
443 self.focused_field = match self.focused_field {
444 FocusedField::Username => FocusedField::Password,
445 FocusedField::Password => FocusedField::Username,
446 };
447 }
448 }
449
450 fn rounded_rectangle(ctx: &Context, x: f64, y: f64, w: f64, h: f64, r: f64) {
451 let degrees = std::f64::consts::PI / 180.0;
452 ctx.new_sub_path();
453 ctx.arc(x + w - r, y + r, r, -90.0 * degrees, 0.0 * degrees);
454 ctx.arc(x + w - r, y + h - r, r, 0.0 * degrees, 90.0 * degrees);
455 ctx.arc(x + r, y + h - r, r, 90.0 * degrees, 180.0 * degrees);
456 ctx.arc(x + r, y + r, r, 180.0 * degrees, 270.0 * degrees);
457 ctx.close_path();
458 }
459 ```
460
461 ### 3.5 Main Event Loop
462
463 ```rust
464 // gardm-greeter/src/main.rs
465
466 use x11rb::protocol::Event;
467
468 fn main() -> anyhow::Result<()> {
469 let window = GreeterWindow::new()?;
470 let renderer = Renderer::new(window.width(), window.height())?;
471 let mut form = LoginForm::new(window.width() as f64, window.height() as f64);
472
473 // Load background
474 let background = load_blurred_background(
475 "/usr/share/gardm/backgrounds/default.jpg",
476 window.width() as u32,
477 window.height() as u32,
478 20.0,
479 0.7,
480 )?;
481
482 // Connect to daemon
483 let mut daemon = DaemonClient::connect()?;
484
485 loop {
486 // Render frame
487 {
488 let ctx = renderer.context()?;
489 render_background(&ctx, &background)?;
490 form.render(&ctx, &pango_ctx)?;
491 }
492
493 // Copy to X11
494 window.put_image(&renderer.data())?;
495
496 // Handle events
497 let event = window.conn().wait_for_event()?;
498 match event {
499 Event::Expose(_) => {
500 // Redraw handled above
501 }
502 Event::KeyPress(e) => {
503 match e.detail {
504 9 => break, // Escape - exit (for testing)
505 36 => { // Enter - submit
506 if form.focused_field == FocusedField::Password {
507 // Authenticate
508 daemon.create_session(&form.username)?;
509 match daemon.authenticate(&form.password)? {
510 Response::Success => {
511 daemon.start_session(&["gar-session.sh"])?;
512 break;
513 }
514 Response::AuthError { message } => {
515 form.error_message = Some(message);
516 }
517 _ => {}
518 }
519 } else {
520 form.handle_tab();
521 }
522 }
523 23 => form.handle_tab(), // Tab
524 22 => form.handle_backspace(), // Backspace
525 _ => {
526 // Regular key
527 if let Some(c) = keycode_to_char(e.detail, e.state) {
528 form.handle_key(c);
529 }
530 }
531 }
532 }
533 _ => {}
534 }
535 }
536
537 Ok(())
538 }
539 ```
540
541 ## Acceptance Criteria
542
543 1. Greeter displays fullscreen with blurred background
544 2. Login form is centered and styled
545 3. Keyboard input works for username/password
546 4. Tab switches between fields
547 5. Enter submits form
548 6. Error messages display correctly
549 7. Successful auth triggers session start
550
551 ## Pitfalls to Avoid
552
553 1. **X11 keycodes vary by layout** - use XKB for proper key mapping
554 2. **Cairo surface format** - ensure ARGB matches X11 expectations
555 3. **Font rendering** - initialize Pango context correctly
556 4. **Fullscreen on all monitors** - may need per-monitor handling
557 5. **Input focus** - greeter window must grab keyboard
558 6. **Memory leaks** - Cairo contexts need proper cleanup
559
560 ## Testing
561
562 ```bash
563 # Test in Xephyr
564 Xephyr -br -ac -noreset -screen 1280x720 :1 &
565 DISPLAY=:1 ./target/release/gardm-greeter
566
567 # Test keyboard input
568 # Test form submission
569 # Test error display
570 ```
571
572 ## Dependencies for This Sprint
573
574 ```toml
575 # gardm-greeter/Cargo.toml
576 [dependencies]
577 x11rb = "0.13"
578 cairo-rs = { version = "0.18", features = ["png"] }
579 pango = "0.18"
580 pangocairo = "0.18"
581 image = "0.24"
582 ```
583
584 ## Next Sprint
585
586 Sprint 4 will add garbg integration for seamless wallpaper sync.