alt-tab
This commit is contained in:
@@ -37,6 +37,7 @@ is the one thing the rofi version had that this doesn't — see the roadmap.
|
|||||||
```
|
```
|
||||||
wl-pick [--format tsv|json|portal] [--live all|current|none] [--fps N]
|
wl-pick [--format tsv|json|portal] [--live all|current|none] [--fps N]
|
||||||
[--outputs|--no-outputs] [--labels|--no-labels]
|
[--outputs|--no-outputs] [--labels|--no-labels]
|
||||||
|
[--order mru|tree] [--alt-tab|--no-alt-tab]
|
||||||
[--font FAMILY] [--font-size PX]
|
[--font FAMILY] [--font-size PX]
|
||||||
[--timeout SECS] [--verbose]
|
[--timeout SECS] [--verbose]
|
||||||
```
|
```
|
||||||
@@ -46,9 +47,13 @@ wl-pick [--format tsv|json|portal] [--live all|current|none] [--fps N]
|
|||||||
are always a single snapshot)
|
are always a single snapshot)
|
||||||
- `--fps N` cap on live updates per tile per second (default 12)
|
- `--fps N` cap on live updates per tile per second (default 12)
|
||||||
- `--outputs` / `--no-outputs` whether whole displays are tiles too (default
|
- `--outputs` / `--no-outputs` whether whole displays are tiles too (default
|
||||||
on). Both directions exist so either can override the config file
|
off). Both directions exist so either can override the config file
|
||||||
- `--labels` / `--no-labels` whether a label is drawn under each thumbnail
|
- `--labels` / `--no-labels` whether a label is drawn under each thumbnail
|
||||||
(default on); `--hide-labels` is the old spelling and still works
|
(default on); `--hide-labels` is the old spelling and still works
|
||||||
|
- `--order mru|tree` window ordering: Most-Recently-Used focus order or tree
|
||||||
|
layout order (default `mru`)
|
||||||
|
- `--alt-tab` / `--no-alt-tab` switcher mode: automatically pick on modifier
|
||||||
|
release (default `auto`, enabled whenever a modifier was held on enter)
|
||||||
- `--font FAMILY` label font family (default: the system monospace font)
|
- `--font FAMILY` label font family (default: the system monospace font)
|
||||||
- `--font-size PX` label size in logical px
|
- `--font-size PX` label size in logical px
|
||||||
- `--config PATH` config file (default `~/.config/wl-pick/config`)
|
- `--config PATH` config file (default `~/.config/wl-pick/config`)
|
||||||
@@ -109,7 +114,7 @@ grid still works.
|
|||||||
| `→` `←` / `l` `h` / `Tab` `Shift+Tab` | next / previous tile |
|
| `→` `←` / `l` `h` / `Tab` `Shift+Tab` | next / previous tile |
|
||||||
| `↓` `↑` / `j` `k` | move a row |
|
| `↓` `↑` / `j` `k` | move a row |
|
||||||
| `Home` `End` / `PgUp` `PgDn` | first / last, or a screen at a time |
|
| `Home` `End` / `PgUp` `PgDn` | first / last, or a screen at a time |
|
||||||
| `Enter` | pick the selection |
|
| `Enter` / release modifier | pick the selection (release Alt/Super in Alt+Tab mode) |
|
||||||
| `Escape` / `q` | cancel |
|
| `Escape` / `q` | cancel |
|
||||||
| click | pick that tile |
|
| click | pick that tile |
|
||||||
| scroll | next / previous tile |
|
| scroll | next / previous tile |
|
||||||
@@ -171,7 +176,9 @@ max-rows = 4
|
|||||||
font = monospace
|
font = monospace
|
||||||
font-size = 13.3
|
font-size = 13.3
|
||||||
labels = yes
|
labels = yes
|
||||||
outputs = yes
|
outputs = no # include whole displays as tiles (default no)
|
||||||
|
order = mru # mru (default) or tree
|
||||||
|
alt-tab = auto # auto (default), yes or no
|
||||||
live = all
|
live = all
|
||||||
fps = 12
|
fps = 12
|
||||||
format = tsv
|
format = tsv
|
||||||
|
|||||||
+43
-2
@@ -37,12 +37,17 @@ use wayland_protocols::wp::cursor_shape::v1::client::{
|
|||||||
wp_cursor_shape_device_v1::WpCursorShapeDeviceV1,
|
wp_cursor_shape_device_v1::WpCursorShapeDeviceV1,
|
||||||
wp_cursor_shape_manager_v1::WpCursorShapeManagerV1,
|
wp_cursor_shape_manager_v1::WpCursorShapeManagerV1,
|
||||||
};
|
};
|
||||||
|
use wayland_protocols::wp::keyboard_shortcuts_inhibit::zv1::client::{
|
||||||
|
zwp_keyboard_shortcuts_inhibit_manager_v1::ZwpKeyboardShortcutsInhibitManagerV1,
|
||||||
|
zwp_keyboard_shortcuts_inhibitor_v1::ZwpKeyboardShortcutsInhibitorV1,
|
||||||
|
};
|
||||||
use wayland_protocols::wp::viewporter::client::{
|
use wayland_protocols::wp::viewporter::client::{
|
||||||
wp_viewport::WpViewport, wp_viewporter::WpViewporter,
|
wp_viewport::WpViewport, wp_viewporter::WpViewporter,
|
||||||
};
|
};
|
||||||
use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_shell_v1::ZwlrLayerShellV1;
|
use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_shell_v1::ZwlrLayerShellV1;
|
||||||
|
|
||||||
use crate::capture::{Live, Tile};
|
use crate::capture::{Live, Tile};
|
||||||
|
use crate::config::AltTabMode;
|
||||||
use crate::overlay;
|
use crate::overlay;
|
||||||
use crate::shm;
|
use crate::shm;
|
||||||
use crate::target::Target;
|
use crate::target::Target;
|
||||||
@@ -60,6 +65,7 @@ pub struct Settings {
|
|||||||
/// the overlay maps there rather than wherever the compositor would put it.
|
/// the overlay maps there rather than wherever the compositor would put it.
|
||||||
pub scale: i32,
|
pub scale: i32,
|
||||||
pub output: String,
|
pub output: String,
|
||||||
|
pub alt_tab: AltTabMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
@@ -113,6 +119,14 @@ pub struct App {
|
|||||||
pub(crate) focused: bool,
|
pub(crate) focused: bool,
|
||||||
pub(crate) picked: Option<Target>,
|
pub(crate) picked: Option<Target>,
|
||||||
pub(crate) stats: Stats,
|
pub(crate) stats: Stats,
|
||||||
|
|
||||||
|
pub(crate) seat: Option<WlSeat>,
|
||||||
|
pub(crate) inhibit_mgr: Option<ZwpKeyboardShortcutsInhibitManagerV1>,
|
||||||
|
pub(crate) inhibitor: Option<ZwpKeyboardShortcutsInhibitorV1>,
|
||||||
|
pub(crate) alt_tab: AltTabMode,
|
||||||
|
pub(crate) is_alt_tab: bool,
|
||||||
|
pub(crate) latched_modifiers: std::collections::BTreeSet<u32>,
|
||||||
|
pub(crate) initial_stepped: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Counters worth reporting with --verbose. Live capture is easy to get subtly
|
/// Counters worth reporting with --verbose. Live capture is easy to get subtly
|
||||||
@@ -166,7 +180,15 @@ impl App {
|
|||||||
fps,
|
fps,
|
||||||
scale,
|
scale,
|
||||||
output,
|
output,
|
||||||
|
alt_tab,
|
||||||
} = settings;
|
} = settings;
|
||||||
|
let focused_idx = targets.iter().position(|t| t.focused).unwrap_or(0);
|
||||||
|
let sel = if alt_tab == AltTabMode::Yes && targets.len() > 1 {
|
||||||
|
(focused_idx + 1) % targets.len()
|
||||||
|
} else {
|
||||||
|
focused_idx
|
||||||
|
};
|
||||||
|
let is_alt_tab = alt_tab == AltTabMode::Yes;
|
||||||
// Bind everything up front so a compositor missing a protocol fails
|
// Bind everything up front so a compositor missing a protocol fails
|
||||||
// here, with a name, rather than halfway through a capture.
|
// here, with a name, rather than halfway through a capture.
|
||||||
let mut app = Self {
|
let mut app = Self {
|
||||||
@@ -187,7 +209,7 @@ impl App {
|
|||||||
live,
|
live,
|
||||||
fps,
|
fps,
|
||||||
scale,
|
scale,
|
||||||
sel: 0,
|
sel,
|
||||||
scroll: 0,
|
scroll: 0,
|
||||||
shift: false,
|
shift: false,
|
||||||
hover: None,
|
hover: None,
|
||||||
@@ -204,6 +226,13 @@ impl App {
|
|||||||
focused: false,
|
focused: false,
|
||||||
picked: None,
|
picked: None,
|
||||||
stats: Stats::default(),
|
stats: Stats::default(),
|
||||||
|
seat: None,
|
||||||
|
inhibit_mgr: None,
|
||||||
|
inhibitor: None,
|
||||||
|
alt_tab,
|
||||||
|
is_alt_tab,
|
||||||
|
latched_modifiers: std::collections::BTreeSet::new(),
|
||||||
|
initial_stepped: alt_tab == AltTabMode::Yes,
|
||||||
};
|
};
|
||||||
let _: ExtForeignToplevelListV1 = globals.bind(qh, 1..=1, ())?;
|
let _: ExtForeignToplevelListV1 = globals.bind(qh, 1..=1, ())?;
|
||||||
// One wl_output per display, bound at v4 so it tells us its name.
|
// One wl_output per display, bound at v4 so it tells us its name.
|
||||||
@@ -216,7 +245,9 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _: WlSeat = globals.bind(qh, 1..=7, ())?;
|
let seat: WlSeat = globals.bind(qh, 1..=7, ())?;
|
||||||
|
app.seat = Some(seat);
|
||||||
|
app.inhibit_mgr = globals.bind(qh, 1..=1, ()).ok();
|
||||||
Ok(app)
|
Ok(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,3 +418,13 @@ delegate_noop!(App: ignore WlSurface);
|
|||||||
delegate_noop!(App: WpCursorShapeManagerV1);
|
delegate_noop!(App: WpCursorShapeManagerV1);
|
||||||
delegate_noop!(App: WpCursorShapeDeviceV1);
|
delegate_noop!(App: WpCursorShapeDeviceV1);
|
||||||
delegate_noop!(App: ignore WlBuffer);
|
delegate_noop!(App: ignore WlBuffer);
|
||||||
|
delegate_noop!(App: ZwpKeyboardShortcutsInhibitManagerV1);
|
||||||
|
delegate_noop!(App: ignore ZwpKeyboardShortcutsInhibitorV1);
|
||||||
|
|
||||||
|
impl Drop for App {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(inhibitor) = self.inhibitor.take() {
|
||||||
|
inhibitor.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+41
-8
@@ -5,8 +5,8 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use crate::app::Settings;
|
use crate::app::Settings;
|
||||||
use crate::capture::Live;
|
use crate::capture::Live;
|
||||||
use crate::config::{Config, Length};
|
use crate::config::{AltTabMode, Config, Length};
|
||||||
use crate::sway::Display;
|
use crate::sway::{Display, Order};
|
||||||
use crate::target::Format;
|
use crate::target::Format;
|
||||||
use crate::theme::Theme;
|
use crate::theme::Theme;
|
||||||
|
|
||||||
@@ -20,8 +20,11 @@ usage: wl-pick [options]
|
|||||||
--live all|current|none which tiles keep updating live [all]
|
--live all|current|none which tiles keep updating live [all]
|
||||||
(displays are always a single snapshot)
|
(displays are always a single snapshot)
|
||||||
--fps N cap on live updates per tile per second [12]
|
--fps N cap on live updates per tile per second [12]
|
||||||
--outputs, --no-outputs include whole displays as tiles [yes]
|
--outputs, --no-outputs include whole displays as tiles [no]
|
||||||
--labels, --no-labels a label under each thumbnail [yes]
|
--labels, --no-labels a label under each thumbnail [yes]
|
||||||
|
--order mru|tree window ordering: mru or layout tree [mru]
|
||||||
|
--alt-tab, --no-alt-tab alt-tab switcher mode (commit on release) [auto]
|
||||||
|
--focus, --no-focus focus the picked target in sway directly [no]
|
||||||
--font FAMILY label font family [the system monospace font]
|
--font FAMILY label font family [the system monospace font]
|
||||||
--font-size PX label size in logical px [13.3]
|
--font-size PX label size in logical px [13.3]
|
||||||
--timeout SECS exit anyway after SECS, in case the keyboard
|
--timeout SECS exit anyway after SECS, in case the keyboard
|
||||||
@@ -30,7 +33,8 @@ usage: wl-pick [options]
|
|||||||
-h, --help this
|
-h, --help this
|
||||||
|
|
||||||
keys: arrows, hjkl or Tab/Shift+Tab move; PgUp/PgDn and Home/End jump;
|
keys: arrows, hjkl or Tab/Shift+Tab move; PgUp/PgDn and Home/End jump;
|
||||||
Enter picks; Escape or q cancels
|
Enter picks; Escape or q cancels; in Alt+Tab mode, releasing
|
||||||
|
the modifier (Alt/Super) picks the selection.
|
||||||
mouse: click a tile to pick it, scroll to move. Hovering does not move the
|
mouse: click a tile to pick it, scroll to move. Hovering does not move the
|
||||||
selection, and a click outside a tile does nothing.
|
selection, and a click outside a tile does nothing.
|
||||||
|
|
||||||
@@ -60,7 +64,10 @@ config:
|
|||||||
font = monospace # also --font
|
font = monospace # also --font
|
||||||
font-size = 13.3
|
font-size = 13.3
|
||||||
labels = yes
|
labels = yes
|
||||||
outputs = yes # include whole displays as tiles
|
outputs = no # include whole displays as tiles
|
||||||
|
order = mru # mru or tree
|
||||||
|
alt-tab = auto # auto, yes or no
|
||||||
|
focus = no # focus picked target in sway directly
|
||||||
live = all
|
live = all
|
||||||
fps = 12
|
fps = 12
|
||||||
format = tsv
|
format = tsv
|
||||||
@@ -89,6 +96,8 @@ formats:
|
|||||||
|
|
||||||
focusing on sway:
|
focusing on sway:
|
||||||
|
|
||||||
|
wl-pick --focus or:
|
||||||
|
|
||||||
IFS=$'\\t' read -r type id toplevel app title < <(wl-pick) &&
|
IFS=$'\\t' read -r type id toplevel app title < <(wl-pick) &&
|
||||||
case $type in
|
case $type in
|
||||||
window) swaymsg \"[con_id=$id] focus\" ;;
|
window) swaymsg \"[con_id=$id] focus\" ;;
|
||||||
@@ -110,6 +119,9 @@ pub struct Args {
|
|||||||
pub(crate) live: Option<Live>,
|
pub(crate) live: Option<Live>,
|
||||||
pub(crate) fps: Option<u32>,
|
pub(crate) fps: Option<u32>,
|
||||||
pub(crate) timeout: Option<Duration>,
|
pub(crate) timeout: Option<Duration>,
|
||||||
|
pub(crate) order: Option<Order>,
|
||||||
|
pub(crate) alt_tab: Option<AltTabMode>,
|
||||||
|
pub(crate) focus: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every setting resolved, with sizes turned into pixels for the display the
|
/// Every setting resolved, with sizes turned into pixels for the display the
|
||||||
@@ -119,6 +131,8 @@ pub struct Options {
|
|||||||
pub format: Format,
|
pub format: Format,
|
||||||
pub outputs: bool,
|
pub outputs: bool,
|
||||||
pub timeout: Option<Duration>,
|
pub timeout: Option<Duration>,
|
||||||
|
pub order: Order,
|
||||||
|
pub focus: bool,
|
||||||
/// The logical size of the display the grid will be laid out for.
|
/// The logical size of the display the grid will be laid out for.
|
||||||
pub display: (i32, i32),
|
pub display: (i32, i32),
|
||||||
pub settings: Settings,
|
pub settings: Settings,
|
||||||
@@ -183,8 +197,10 @@ impl Args {
|
|||||||
Options {
|
Options {
|
||||||
verbose: self.verbose,
|
verbose: self.verbose,
|
||||||
format: self.format.or(cfg.format).unwrap_or(Format::Tsv),
|
format: self.format.or(cfg.format).unwrap_or(Format::Tsv),
|
||||||
outputs: self.outputs.or(cfg.outputs).unwrap_or(true),
|
outputs: self.outputs.or(cfg.outputs).unwrap_or(false),
|
||||||
timeout: self.timeout.or(cfg.timeout),
|
timeout: self.timeout.or(cfg.timeout),
|
||||||
|
order: self.order.or(cfg.order).unwrap_or(Order::Mru),
|
||||||
|
focus: self.focus.or(cfg.focus).unwrap_or(false),
|
||||||
display: (display.width, display.height),
|
display: (display.width, display.height),
|
||||||
settings: Settings {
|
settings: Settings {
|
||||||
theme,
|
theme,
|
||||||
@@ -192,6 +208,7 @@ impl Args {
|
|||||||
fps: self.fps.or(cfg.fps).unwrap_or(12),
|
fps: self.fps.or(cfg.fps).unwrap_or(12),
|
||||||
scale: display.scale,
|
scale: display.scale,
|
||||||
output: display.name.clone(),
|
output: display.name.clone(),
|
||||||
|
alt_tab: self.alt_tab.or(cfg.alt_tab).unwrap_or(AltTabMode::Auto),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,6 +229,14 @@ fn parse(it: impl Iterator<Item = String>) -> Result<Args, String> {
|
|||||||
}
|
}
|
||||||
"--outputs" => args.outputs = Some(true),
|
"--outputs" => args.outputs = Some(true),
|
||||||
"--no-outputs" => args.outputs = Some(false),
|
"--no-outputs" => args.outputs = Some(false),
|
||||||
|
"--order" => {
|
||||||
|
let v = it.next().ok_or("--order needs mru|tree")?;
|
||||||
|
args.order = Some(Order::parse(&v)?);
|
||||||
|
}
|
||||||
|
"--alt-tab" => args.alt_tab = Some(AltTabMode::Yes),
|
||||||
|
"--no-alt-tab" => args.alt_tab = Some(AltTabMode::No),
|
||||||
|
"--focus" => args.focus = Some(true),
|
||||||
|
"--no-focus" => args.focus = Some(false),
|
||||||
"--config" => {
|
"--config" => {
|
||||||
args.config = Some(PathBuf::from(it.next().ok_or("--config needs a path")?))
|
args.config = Some(PathBuf::from(it.next().ok_or("--config needs a path")?))
|
||||||
}
|
}
|
||||||
@@ -282,6 +307,8 @@ mod tests {
|
|||||||
assert_eq!(args(&["--labels"]).labels, Some(true));
|
assert_eq!(args(&["--labels"]).labels, Some(true));
|
||||||
assert_eq!(args(&["--no-labels"]).labels, Some(false));
|
assert_eq!(args(&["--no-labels"]).labels, Some(false));
|
||||||
assert_eq!(args(&["--hide-labels"]).labels, Some(false), "old spelling");
|
assert_eq!(args(&["--hide-labels"]).labels, Some(false), "old spelling");
|
||||||
|
assert_eq!(args(&["--alt-tab"]).alt_tab, Some(AltTabMode::Yes));
|
||||||
|
assert_eq!(args(&["--no-alt-tab"]).alt_tab, Some(AltTabMode::No));
|
||||||
// Unset is what lets the file have its say.
|
// Unset is what lets the file have its say.
|
||||||
assert_eq!(args(&[]).outputs, None);
|
assert_eq!(args(&[]).outputs, None);
|
||||||
assert_eq!(args(&[]).labels, None);
|
assert_eq!(args(&[]).labels, None);
|
||||||
@@ -300,8 +327,14 @@ mod tests {
|
|||||||
assert!(!off.outputs);
|
assert!(!off.outputs);
|
||||||
assert!(!off.settings.theme.labels);
|
assert!(!off.settings.theme.labels);
|
||||||
|
|
||||||
// With no flag the file decides, and with no file either, the default.
|
// With no flag the file decides, and with no file either, the default (outputs: false).
|
||||||
assert!(!args(&[]).resolve(&file(false), &display()).outputs);
|
assert!(!args(&[]).resolve(&file(false), &display()).outputs);
|
||||||
assert!(args(&[]).resolve(&Config::default(), &display()).outputs);
|
assert!(!args(&[]).resolve(&Config::default(), &display()).outputs);
|
||||||
|
assert!(args(&["--outputs"]).resolve(&Config::default(), &display()).outputs);
|
||||||
|
assert_eq!(args(&[]).resolve(&Config::default(), &display()).order, Order::Mru);
|
||||||
|
assert_eq!(
|
||||||
|
args(&["--order", "tree"]).resolve(&Config::default(), &display()).order,
|
||||||
|
Order::Tree
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,29 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::capture::Live;
|
use crate::capture::Live;
|
||||||
|
use crate::sway::Order;
|
||||||
use crate::target::Format;
|
use crate::target::Format;
|
||||||
use crate::theme::Argb;
|
use crate::theme::Argb;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||||
|
pub enum AltTabMode {
|
||||||
|
#[default]
|
||||||
|
Auto,
|
||||||
|
Yes,
|
||||||
|
No,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AltTabMode {
|
||||||
|
pub fn parse(s: &str) -> Result<Self, String> {
|
||||||
|
match s.trim() {
|
||||||
|
"auto" => Ok(AltTabMode::Auto),
|
||||||
|
"yes" | "true" | "on" | "1" => Ok(AltTabMode::Yes),
|
||||||
|
"no" | "false" | "off" | "0" => Ok(AltTabMode::No),
|
||||||
|
other => Err(format!("{other:?} is not auto, yes or no")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A size, either absolute or relative to the display it will be shown on.
|
/// A size, either absolute or relative to the display it will be shown on.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub enum Length {
|
pub enum Length {
|
||||||
@@ -98,6 +118,9 @@ pub struct Config {
|
|||||||
pub fps: Option<u32>,
|
pub fps: Option<u32>,
|
||||||
pub format: Option<Format>,
|
pub format: Option<Format>,
|
||||||
pub timeout: Option<Duration>,
|
pub timeout: Option<Duration>,
|
||||||
|
pub order: Option<Order>,
|
||||||
|
pub alt_tab: Option<AltTabMode>,
|
||||||
|
pub focus: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -157,6 +180,9 @@ impl Config {
|
|||||||
let secs: f64 = number(value)?;
|
let secs: f64 = number(value)?;
|
||||||
self.timeout = (secs > 0.0).then(|| Duration::from_secs_f64(secs));
|
self.timeout = (secs > 0.0).then(|| Duration::from_secs_f64(secs));
|
||||||
}
|
}
|
||||||
|
"order" => self.order = Some(Order::parse(value)?),
|
||||||
|
"alt-tab" => self.alt_tab = Some(AltTabMode::parse(value)?),
|
||||||
|
"focus" => self.focus = Some(boolean(value)?),
|
||||||
other => return Err(format!("unknown setting {other:?}")),
|
other => return Err(format!("unknown setting {other:?}")),
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -242,6 +268,8 @@ live = current
|
|||||||
fps = 30
|
fps = 30
|
||||||
labels = no
|
labels = no
|
||||||
timeout = 0
|
timeout = 0
|
||||||
|
order = mru
|
||||||
|
alt-tab = yes
|
||||||
",
|
",
|
||||||
)
|
)
|
||||||
.expect("should parse");
|
.expect("should parse");
|
||||||
@@ -254,6 +282,8 @@ timeout = 0
|
|||||||
assert_eq!(cfg.fps, Some(30));
|
assert_eq!(cfg.fps, Some(30));
|
||||||
assert_eq!(cfg.labels, Some(false));
|
assert_eq!(cfg.labels, Some(false));
|
||||||
assert_eq!(cfg.timeout, None, "zero means no timeout");
|
assert_eq!(cfg.timeout, None, "zero means no timeout");
|
||||||
|
assert_eq!(cfg.order, Some(Order::Mru));
|
||||||
|
assert_eq!(cfg.alt_tab, Some(AltTabMode::Yes));
|
||||||
assert!(cfg.live.is_some());
|
assert!(cfg.live.is_some());
|
||||||
// Untouched settings stay unset, so defaults survive.
|
// Untouched settings stay unset, so defaults survive.
|
||||||
assert_eq!(cfg.foreground, None);
|
assert_eq!(cfg.foreground, None);
|
||||||
@@ -294,4 +324,16 @@ timeout = 0
|
|||||||
unsafe { std::env::set_var("XDG_CONFIG_HOME", "/nonexistent") };
|
unsafe { std::env::set_var("XDG_CONFIG_HOME", "/nonexistent") };
|
||||||
assert!(Config::load(None).is_ok());
|
assert!(Config::load(None).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alt_tab_mode_parses() {
|
||||||
|
assert_eq!(AltTabMode::parse("auto"), Ok(AltTabMode::Auto));
|
||||||
|
assert_eq!(AltTabMode::parse("yes"), Ok(AltTabMode::Yes));
|
||||||
|
assert_eq!(AltTabMode::parse("true"), Ok(AltTabMode::Yes));
|
||||||
|
assert_eq!(AltTabMode::parse("1"), Ok(AltTabMode::Yes));
|
||||||
|
assert_eq!(AltTabMode::parse("no"), Ok(AltTabMode::No));
|
||||||
|
assert_eq!(AltTabMode::parse("false"), Ok(AltTabMode::No));
|
||||||
|
assert_eq!(AltTabMode::parse("0"), Ok(AltTabMode::No));
|
||||||
|
assert!(AltTabMode::parse("maybe").is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-1
@@ -82,7 +82,7 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
|
|||||||
let displays = sway::displays(&mut sway)?;
|
let displays = sway::displays(&mut sway)?;
|
||||||
let display = sway::focused(&displays).ok_or("sway reports no active display")?;
|
let display = sway::focused(&displays).ok_or("sway reports no active display")?;
|
||||||
let opts = args.resolve(&config, display);
|
let opts = args.resolve(&config, display);
|
||||||
let mut targets = sway::windows(&mut sway)?;
|
let mut targets = sway::windows(&mut sway, opts.order)?;
|
||||||
if opts.outputs {
|
if opts.outputs {
|
||||||
// Displays go last, after the windows, so window positions are
|
// Displays go last, after the windows, so window positions are
|
||||||
// stable as windows come and go.
|
// stable as windows come and go.
|
||||||
@@ -198,6 +198,20 @@ fn run() -> Result<ExitCode, Box<dyn Error>> {
|
|||||||
let Some(target) = app.picked() else {
|
let Some(target) = app.picked() else {
|
||||||
return Ok(ExitCode::FAILURE); // cancelled: nothing on stdout
|
return Ok(ExitCode::FAILURE); // cancelled: nothing on stdout
|
||||||
};
|
};
|
||||||
|
if opts.focus {
|
||||||
|
if let Ok(mut sway) = sway::connect() {
|
||||||
|
match target.kind {
|
||||||
|
target::Kind::Window => {
|
||||||
|
if let Some(con_id) = target.con_id {
|
||||||
|
let _ = sway.run_command(format!("[con_id={con_id}] focus"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
target::Kind::Output => {
|
||||||
|
let _ = sway.run_command(format!("focus output {}", target.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
match target.render(opts.format) {
|
match target.render(opts.format) {
|
||||||
Some(line) => println!("{line}"),
|
Some(line) => println!("{line}"),
|
||||||
// Only the portal format can fail to name something: it identifies a
|
// Only the portal format can fail to name something: it identifies a
|
||||||
|
|||||||
+85
-8
@@ -24,6 +24,7 @@ use wayland_protocols_wlr::layer_shell::v1::client::{
|
|||||||
use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape;
|
use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape;
|
||||||
|
|
||||||
use crate::app::{App, Ending};
|
use crate::app::{App, Ending};
|
||||||
|
use crate::config::AltTabMode;
|
||||||
use crate::shm;
|
use crate::shm;
|
||||||
use crate::theme::{Rect, fit_centred};
|
use crate::theme::{Rect, fit_centred};
|
||||||
|
|
||||||
@@ -32,23 +33,41 @@ use crate::theme::{Rect, fit_centred};
|
|||||||
const KEY_ESC: u32 = 1;
|
const KEY_ESC: u32 = 1;
|
||||||
const KEY_TAB: u32 = 15;
|
const KEY_TAB: u32 = 15;
|
||||||
const KEY_Q: u32 = 16;
|
const KEY_Q: u32 = 16;
|
||||||
|
const KEY_ENTER: u32 = 28;
|
||||||
|
const KEY_LEFTCTRL: u32 = 29;
|
||||||
// hjkl, by physical position: the same keys as vim on a qwerty layout.
|
// hjkl, by physical position: the same keys as vim on a qwerty layout.
|
||||||
const KEY_H: u32 = 35;
|
const KEY_H: u32 = 35;
|
||||||
const KEY_J: u32 = 36;
|
const KEY_J: u32 = 36;
|
||||||
const KEY_K: u32 = 37;
|
const KEY_K: u32 = 37;
|
||||||
const KEY_L: u32 = 38;
|
const KEY_L: u32 = 38;
|
||||||
const KEY_ENTER: u32 = 28;
|
|
||||||
const KEY_LEFTSHIFT: u32 = 42;
|
const KEY_LEFTSHIFT: u32 = 42;
|
||||||
const KEY_RIGHTSHIFT: u32 = 54;
|
const KEY_RIGHTSHIFT: u32 = 54;
|
||||||
|
const KEY_LEFTALT: u32 = 56;
|
||||||
const KEY_KPENTER: u32 = 96;
|
const KEY_KPENTER: u32 = 96;
|
||||||
|
const KEY_RIGHTCTRL: u32 = 97;
|
||||||
|
const KEY_RIGHTALT: u32 = 100;
|
||||||
const KEY_HOME: u32 = 102;
|
const KEY_HOME: u32 = 102;
|
||||||
const KEY_UP: u32 = 103;
|
const KEY_UP: u32 = 103;
|
||||||
|
const KEY_PGUP: u32 = 104;
|
||||||
const KEY_LEFT: u32 = 105;
|
const KEY_LEFT: u32 = 105;
|
||||||
const KEY_RIGHT: u32 = 106;
|
const KEY_RIGHT: u32 = 106;
|
||||||
const KEY_END: u32 = 107;
|
const KEY_END: u32 = 107;
|
||||||
const KEY_DOWN: u32 = 108;
|
const KEY_DOWN: u32 = 108;
|
||||||
const KEY_PGUP: u32 = 104;
|
|
||||||
const KEY_PGDN: u32 = 109;
|
const KEY_PGDN: u32 = 109;
|
||||||
|
const KEY_LEFTMETA: u32 = 125;
|
||||||
|
const KEY_RIGHTMETA: u32 = 126;
|
||||||
|
|
||||||
|
fn is_trigger_modifier(code: u32) -> bool {
|
||||||
|
matches!(
|
||||||
|
code,
|
||||||
|
KEY_LEFTALT
|
||||||
|
| KEY_RIGHTALT
|
||||||
|
| KEY_LEFTMETA
|
||||||
|
| KEY_RIGHTMETA
|
||||||
|
| KEY_LEFTCTRL
|
||||||
|
| KEY_RIGHTCTRL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// evdev button code, as wl_pointer reports it.
|
/// evdev button code, as wl_pointer reports it.
|
||||||
const BTN_LEFT: u32 = 0x110;
|
const BTN_LEFT: u32 = 0x110;
|
||||||
@@ -105,6 +124,9 @@ impl App {
|
|||||||
}
|
}
|
||||||
pool.destroy();
|
pool.destroy();
|
||||||
self.chrome = Some(shm::Chrome::new(&file, pw, ph)?);
|
self.chrome = Some(shm::Chrome::new(&file, pw, ph)?);
|
||||||
|
if let (Some(mgr), Some(seat)) = (&self.inhibit_mgr, &self.seat) {
|
||||||
|
self.inhibitor = Some(mgr.inhibit_shortcuts(&surface, seat, qh, ()));
|
||||||
|
}
|
||||||
self.surface = Some(surface);
|
self.surface = Some(surface);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -296,6 +318,9 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn key(&mut self, code: u32, qh: &QueueHandle<Self>) {
|
fn key(&mut self, code: u32, qh: &QueueHandle<Self>) {
|
||||||
|
if self.is_alt_tab && is_trigger_modifier(code) {
|
||||||
|
self.latched_modifiers.insert(code);
|
||||||
|
}
|
||||||
match code {
|
match code {
|
||||||
KEY_LEFTSHIFT | KEY_RIGHTSHIFT => self.shift = true,
|
KEY_LEFTSHIFT | KEY_RIGHTSHIFT => self.shift = true,
|
||||||
KEY_ESC | KEY_Q => self.ending = Ending::Cancelled,
|
KEY_ESC | KEY_Q => self.ending = Ending::Cancelled,
|
||||||
@@ -315,6 +340,62 @@ impl App {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn key_up(&mut self, code: u32) {
|
||||||
|
if code == KEY_LEFTSHIFT || code == KEY_RIGHTSHIFT {
|
||||||
|
self.shift = false;
|
||||||
|
}
|
||||||
|
if self.is_alt_tab
|
||||||
|
&& self.latched_modifiers.remove(&code)
|
||||||
|
&& self.latched_modifiers.is_empty()
|
||||||
|
{
|
||||||
|
if self.ending == Ending::Running {
|
||||||
|
self.picked = self.tiles.get(self.sel).map(|t| t.target.clone());
|
||||||
|
self.ending = Ending::Picked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn keyboard_enter(&mut self, keys: Vec<u8>, qh: &QueueHandle<Self>) {
|
||||||
|
self.focused = true;
|
||||||
|
let held_keys: Vec<u32> = keys
|
||||||
|
.chunks_exact(4)
|
||||||
|
.map(|chunk| u32::from_ne_bytes(chunk.try_into().unwrap()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if held_keys.iter().any(|&k| k == KEY_LEFTSHIFT || k == KEY_RIGHTSHIFT) {
|
||||||
|
self.shift = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let held_modifiers: Vec<u32> = held_keys
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|&k| is_trigger_modifier(k))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !held_modifiers.is_empty() && self.alt_tab != AltTabMode::No {
|
||||||
|
self.is_alt_tab = true;
|
||||||
|
for &m in &held_modifiers {
|
||||||
|
self.latched_modifiers.insert(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.is_alt_tab && !self.initial_stepped {
|
||||||
|
self.initial_stepped = true;
|
||||||
|
if self.alt_tab == AltTabMode::Yes && self.latched_modifiers.is_empty() {
|
||||||
|
// In explicit alt-tab mode, if no modifier was held on enter,
|
||||||
|
// the modifier was released before focus was acquired: commit immediately!
|
||||||
|
if self.ending == Ending::Running {
|
||||||
|
self.picked = self.tiles.get(self.sel).map(|t| t.target.clone());
|
||||||
|
self.ending = Ending::Picked;
|
||||||
|
}
|
||||||
|
} else if self.alt_tab == AltTabMode::Auto {
|
||||||
|
// In auto mode, step selection now that we know a modifier was held:
|
||||||
|
let step = if self.shift { -1 } else { 1 };
|
||||||
|
self.move_sel(step, qh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- event plumbing -------------------------------------------------------
|
// --- event plumbing -------------------------------------------------------
|
||||||
@@ -379,18 +460,14 @@ impl Dispatch<WlKeyboard, ()> for App {
|
|||||||
match event {
|
match event {
|
||||||
wl_keyboard::Event::Key { key, state, .. } => match state {
|
wl_keyboard::Event::Key { key, state, .. } => match state {
|
||||||
WEnum::Value(wl_keyboard::KeyState::Pressed) => app.key(key, qh),
|
WEnum::Value(wl_keyboard::KeyState::Pressed) => app.key(key, qh),
|
||||||
WEnum::Value(wl_keyboard::KeyState::Released)
|
WEnum::Value(wl_keyboard::KeyState::Released) => app.key_up(key),
|
||||||
if key == KEY_LEFTSHIFT || key == KEY_RIGHTSHIFT =>
|
|
||||||
{
|
|
||||||
app.shift = false
|
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
// Focus is only tracked here. sway sends leave immediately
|
// Focus is only tracked here. sway sends leave immediately
|
||||||
// followed by enter on the same surface when the pointer crosses
|
// followed by enter on the same surface when the pointer crosses
|
||||||
// it, so whether the grab is really gone is decided by the main
|
// it, so whether the grab is really gone is decided by the main
|
||||||
// loop, once the event batch has been dispatched.
|
// loop, once the event batch has been dispatched.
|
||||||
wl_keyboard::Event::Enter { .. } => app.focused = true,
|
wl_keyboard::Event::Enter { keys, .. } => app.keyboard_enter(keys, qh),
|
||||||
wl_keyboard::Event::Leave { .. } => app.focused = false,
|
wl_keyboard::Event::Leave { .. } => app.focused = false,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|||||||
+72
-10
@@ -99,32 +99,87 @@ fn live_sockets() -> Vec<PathBuf> {
|
|||||||
found
|
found
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every view in the tree, in tree order (the same traversal the jq filter did,
|
/// How to order windows in the grid.
|
||||||
/// so the grid keeps the ordering the muscle memory expects).
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||||
pub fn windows(conn: &mut Connection) -> Result<Vec<Target>, swayipc::Error> {
|
pub enum Order {
|
||||||
|
/// Most-Recently-Used (MRU) focus order: current window first, then previous, etc.
|
||||||
|
#[default]
|
||||||
|
Mru,
|
||||||
|
/// Traversal of sway's layout tree (workspace by workspace).
|
||||||
|
Tree,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Order {
|
||||||
|
pub fn parse(s: &str) -> Result<Self, String> {
|
||||||
|
match s.trim() {
|
||||||
|
"mru" => Ok(Order::Mru),
|
||||||
|
"tree" => Ok(Order::Tree),
|
||||||
|
other => Err(format!("{other:?} is not mru or tree")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Views in the tree, either in MRU focus order or tree layout order.
|
||||||
|
pub fn windows(conn: &mut Connection, order: Order) -> Result<Vec<Target>, swayipc::Error> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
collect(&conn.get_tree()?, &mut out);
|
let tree = conn.get_tree()?;
|
||||||
|
match order {
|
||||||
|
Order::Mru => {
|
||||||
|
collect_mru(&tree, &mut out);
|
||||||
|
// Ensure the currently focused window is at index 0
|
||||||
|
if let Some(pos) = out.iter().position(|t| t.focused) {
|
||||||
|
if pos > 0 {
|
||||||
|
let focused = out.remove(pos);
|
||||||
|
out.insert(0, focused);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Order::Tree => collect_tree(&tree, &mut out),
|
||||||
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect(node: &Node, out: &mut Vec<Target>) {
|
fn collect_target(node: &Node) -> Option<Target> {
|
||||||
let is_con = matches!(node.node_type, NodeType::Con | NodeType::FloatingCon);
|
let is_con = matches!(node.node_type, NodeType::Con | NodeType::FloatingCon);
|
||||||
let class = node
|
let class = node
|
||||||
.window_properties
|
.window_properties
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|p| p.class.clone());
|
.and_then(|p| p.class.clone());
|
||||||
if is_con && (node.app_id.is_some() || class.is_some()) {
|
if is_con && (node.app_id.is_some() || class.is_some()) {
|
||||||
// A view with no identifier can't be captured, but it still belongs in
|
Some(Target::window(
|
||||||
// the list: it gets a tile with no thumbnail.
|
|
||||||
out.push(Target::window(
|
|
||||||
node.id,
|
node.id,
|
||||||
node.foreign_toplevel_identifier.clone().unwrap_or_default(),
|
node.foreign_toplevel_identifier.clone().unwrap_or_default(),
|
||||||
node.app_id.clone().or(class).unwrap_or_default(),
|
node.app_id.clone().or(class).unwrap_or_default(),
|
||||||
node.name.clone().unwrap_or_default(),
|
node.name.clone().unwrap_or_default(),
|
||||||
));
|
node.focused,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_tree(node: &Node, out: &mut Vec<Target>) {
|
||||||
|
if let Some(target) = collect_target(node) {
|
||||||
|
out.push(target);
|
||||||
}
|
}
|
||||||
for child in node.nodes.iter().chain(node.floating_nodes.iter()) {
|
for child in node.nodes.iter().chain(node.floating_nodes.iter()) {
|
||||||
collect(child, out);
|
collect_tree(child, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_mru(node: &Node, out: &mut Vec<Target>) {
|
||||||
|
if let Some(target) = collect_target(node) {
|
||||||
|
out.push(target);
|
||||||
|
}
|
||||||
|
let mut children: Vec<&Node> = node.nodes.iter().chain(node.floating_nodes.iter()).collect();
|
||||||
|
children.sort_by_key(|child| {
|
||||||
|
node.focus
|
||||||
|
.iter()
|
||||||
|
.position(|&id| id == child.id)
|
||||||
|
.unwrap_or(usize::MAX)
|
||||||
|
});
|
||||||
|
for child in children {
|
||||||
|
collect_mru(child, out);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,4 +239,11 @@ mod tests {
|
|||||||
assert_eq!(socket_pid("sway-ipc.1000..sock"), None);
|
assert_eq!(socket_pid("sway-ipc.1000..sock"), None);
|
||||||
assert_eq!(socket_pid("sway-ipc.1000.notapid.sock"), None);
|
assert_eq!(socket_pid("sway-ipc.1000.notapid.sock"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn order_parses() {
|
||||||
|
assert_eq!(super::Order::parse("mru"), Ok(super::Order::Mru));
|
||||||
|
assert_eq!(super::Order::parse("tree"), Ok(super::Order::Tree));
|
||||||
|
assert!(super::Order::parse("invalid").is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-3
@@ -55,10 +55,12 @@ pub struct Target {
|
|||||||
pub ft_id: String,
|
pub ft_id: String,
|
||||||
pub app: String,
|
pub app: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
/// Whether this window was the focused container when sway was queried.
|
||||||
|
pub focused: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Target {
|
impl Target {
|
||||||
pub fn window(con_id: i64, ft_id: String, app: String, title: String) -> Self {
|
pub fn window(con_id: i64, ft_id: String, app: String, title: String, focused: bool) -> Self {
|
||||||
Self {
|
Self {
|
||||||
kind: Kind::Window,
|
kind: Kind::Window,
|
||||||
id: con_id.to_string(),
|
id: con_id.to_string(),
|
||||||
@@ -66,6 +68,7 @@ impl Target {
|
|||||||
ft_id,
|
ft_id,
|
||||||
app,
|
app,
|
||||||
title,
|
title,
|
||||||
|
focused,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +82,7 @@ impl Target {
|
|||||||
ft_id: String::new(),
|
ft_id: String::new(),
|
||||||
app: "display".to_string(),
|
app: "display".to_string(),
|
||||||
title: name,
|
title: name,
|
||||||
|
focused: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +198,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn win() -> Target {
|
fn win() -> Target {
|
||||||
Target::window(42, "abc123".into(), "kitty".into(), "zsh\tin\na tab".into())
|
Target::window(42, "abc123".into(), "kitty".into(), "zsh\tin\na tab".into(), false)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -228,7 +232,7 @@ mod tests {
|
|||||||
Some("Monitor: DP-1")
|
Some("Monitor: DP-1")
|
||||||
);
|
);
|
||||||
// No identifier means the portal cannot be told about this window.
|
// No identifier means the portal cannot be told about this window.
|
||||||
let anon = Target::window(7, String::new(), "x".into(), "y".into());
|
let anon = Target::window(7, String::new(), "x".into(), "y".into(), false);
|
||||||
assert_eq!(anon.portal(), None);
|
assert_eq!(anon.portal(), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user