Rebrand to Galaxy, major improvements to Bedrock support, still needs some TLC though

This commit is contained in:
Ryan Ward
2026-05-07 11:29:34 -05:00
parent f4e2475c60
commit a41cbd8cc7
2433 changed files with 14208 additions and 9409 deletions
+78
View File
@@ -0,0 +1,78 @@
[package]
name = "galaxy_core"
edition = "2021"
authors = ["Warp Team <dev@warp.dev>"]
publish.workspace = true
license.workspace = true
[features]
crash_reporting = ["dep:sentry", "dep:sentry-log"]
integration_tests = []
local_fs = []
release_bundle = []
test-util = ["dep:mockito", "galaxy_features/test-util"]
[dependencies]
anyhow.workspace = true
cfg-if.workspace = true
command-corrections.workspace = true
dashmap.workspace = true
dirs.workspace = true
directories.workspace = true
shellexpand.workspace = true
enum-iterator.workspace = true
galaxy_features.workspace = true
itertools.workspace = true
getset.workspace = true
http.workspace = true
lazy_static.workspace = true
log.workspace = true
parking_lot.workspace = true
reqwest.workspace = true
serde.workspace = true
settings.workspace = true
serde_json.workspace = true
serde_with.workspace = true
sentry = { workspace = true, optional = true }
sentry-log = { workspace = true, optional = true }
strum.workspace = true
sysinfo.workspace = true
thiserror.workspace = true
concat-idents.workspace = true
url.workspace = true
regex.workspace = true
websocket.workspace = true
instant.workspace = true
schemars = "1"
settings_value = { workspace = true, features = ["derive"] }
async-broadcast.workspace = true
futures.workspace = true
line-ending.workspace = true
galaxyui.workspace = true
galaxyui_extras = { workspace = true, features = ["default"] }
mockito = { workspace = true, optional = true }
asset_macro.workspace = true
string-offset.workspace = true
inventory = "0.3.20"
chrono.workspace = true
[target.'cfg(not(target_family = "wasm"))'.dependencies]
tokio.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
cocoa.workspace = true
objc.workspace = true
objc2-foundation = { workspace = true, features = [
"NSString",
"NSURL",
"NSFileManager",
"NSBundle",
] }
tempfile.workspace = true
[dev-dependencies]
rand.workspace = true
serde_yaml.workspace = true
[build-dependencies]
anyhow.workspace = true
+11
View File
@@ -0,0 +1,11 @@
use anyhow::Result;
fn main() -> Result<()> {
let target_family = std::env::var("CARGO_CFG_TARGET_FAMILY")?;
if target_family != "wasm" {
println!("cargo:rustc-cfg=feature=\"local_fs\"");
}
Ok(())
}
+90
View File
@@ -0,0 +1,90 @@
use std::borrow::Cow;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
/// An application's canonical identifier.
#[derive(Debug, Clone)]
pub struct AppId {
qualifier: Cow<'static, str>,
organization: Cow<'static, str>,
application_name: Cow<'static, str>,
}
impl AppId {
/// Constructs a new [`AppId`] from its constituent parts.
pub fn new(
qualifier: impl Into<Cow<'static, str>>,
organization: impl Into<Cow<'static, str>>,
application_name: impl Into<Cow<'static, str>>,
) -> Self {
Self {
qualifier: qualifier.into(),
organization: organization.into(),
application_name: application_name.into(),
}
}
/// Parses an three-component app identifier string (e.g.: com.example.App)
/// into an [`AppId`].
pub fn parse(app_id: &str) -> anyhow::Result<Self> {
let &[qualifier, organization, application_name] =
app_id.splitn(4, '.').collect_vec().as_slice()
else {
anyhow::bail!("App ID does not contain three components, separated by periods.");
};
Ok(Self {
qualifier: Cow::Owned(qualifier.to_owned()),
organization: Cow::Owned(organization.to_owned()),
application_name: Cow::Owned(application_name.to_owned()),
})
}
/// Returns the qualifier component of the app ID.
pub fn qualifier(&self) -> &str {
&self.qualifier
}
/// Returns the organization component of the app ID.
pub fn organization(&self) -> &str {
&self.organization
}
/// Returns the name of the application.
pub fn application_name(&self) -> &str {
&self.application_name
}
}
impl<'de> Deserialize<'de> for AppId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = <&str>::deserialize(deserializer)?;
Self::parse(s).map_err(serde::de::Error::custom)
}
}
impl Serialize for AppId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl std::fmt::Display for AppId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}.{}.{}",
self.qualifier, self.organization, self.application_name
)
}
}
#[cfg(test)]
#[path = "app_id_test.rs"]
mod tests;
+23
View File
@@ -0,0 +1,23 @@
use super::*;
#[test]
fn test_parse_valid_app_id() {
let app_id_string = "com.example.App";
let app_id = AppId::parse(app_id_string).expect("should not fail to parse");
assert_eq!(app_id.qualifier(), "com");
assert_eq!(app_id.organization(), "example");
assert_eq!(app_id.application_name(), "App");
assert_eq!(app_id_string, &app_id.to_string());
}
#[test]
fn test_parse_invalid_app_id() {
assert!(
AppId::parse("com.example").is_err(),
"should fail to parse two-part app ID string"
);
assert!(
AppId::parse("com.example.App.Blah").is_err(),
"should fail to parse four-part app ID string"
);
}
+61
View File
@@ -0,0 +1,61 @@
/// Asserts that a condition is true, logging an error if it is not.
///
/// This macro is similar to the standard `debug_assert!` macro, but it logs
/// an error if the condition is not met. This should generally be preferred
/// over `debug_assert!`, as it will log in production, though should not be
/// used in codepaths where the error log could be produced with high volume.
#[macro_export]
macro_rules! safe_assert {
($cond:expr $(,)?) => {{
debug_assert!($cond);
match &$cond {
(cond) => {
if !(*cond) {
log::error!("Assertion `{}` failed", stringify!($cond));
}
}
}
}};
($cond:expr, $($arg:tt)+) => {{
debug_assert!($cond, $($arg)+);
match &$cond {
(cond) => {
if !(*cond) {
log::error!("Assertion `{}` failed: {}", stringify!($cond), format_args!($($arg)+));
}
}
}
}};
}
pub use safe_assert;
/// Asserts that two expressions are equal, logging an error if they are not.
///
/// This macro is similar to the standard `debug_assert_eq!` macro, but it logs
/// an error if the values are not equal. This should generally be preferred
/// over `debug_assert_eq!`, as it will log in production, though should not be
/// used in codepaths where the error log could be produced with high volume.
#[macro_export]
macro_rules! safe_assert_eq {
($left:expr, $right:expr $(,)?) => {{
debug_assert_eq!($left, $right);
match (&$left, &$right) {
(left_val, right_val) => {
if !(*left_val == *right_val) {
log::error!("Assertion `{} == {}` failed: expected {left_val}, found {right_val}.", stringify!($left), stringify!($right));
}
}
}
}};
($left:expr, $right:expr, $($arg:tt)+) => {{
debug_assert_eq!($left, $right, $($arg)+);
match (&$left, &$right) {
(left_val, right_val) => {
if !(*left_val == *right_val) {
log::error!("Assertion `{} == {}` failed: expected {left_val}, found {right_val}. {}", stringify!($left), stringify!($right), format_args!($($arg)+));
}
}
}
}};
}
pub use safe_assert_eq;
+144
View File
@@ -0,0 +1,144 @@
use std::borrow::Cow;
use serde::{Deserialize, Serialize};
use crate::AppId;
#[derive(Debug, Deserialize, Serialize)]
pub struct ChannelConfig {
/// The application ID for this channel.
pub app_id: AppId,
/// The name of the file to which logs should be written.
pub logfile_name: Cow<'static, str>,
/// Configuration for talking to Warp's servers.
pub server_config: WarpServerConfig,
/// Configuration for Oz/ambient agents.
pub oz_config: OzConfig,
/// Configuration for telemetry sending, or [`None`] if telemetry should be
/// disabled for this build.
pub telemetry_config: Option<TelemetryConfig>,
/// Configuration for autoupdate functionality.
pub autoupdate_config: Option<AutoupdateConfig>,
/// Configuration for crash reporting.
pub crash_reporting_config: Option<CrashReportingConfig>,
/// Configuration for statically-bundled MCP OAuth credentials.
pub mcp_static_config: Option<McpStaticConfig>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct WarpServerConfig {
/// The root URL for the standard server pool.
pub server_root_url: Cow<'static, str>,
/// The URL for the RTC server, which serves real-time updates for Warp Drive objects.
pub rtc_server_url: Cow<'static, str>,
/// The URL for the session sharing server, or [`None`] if session sharing is not
/// supported.
pub session_sharing_server_url: Option<Cow<'static, str>>,
/// The API key to use when making requests to Firebase Authentication endpoints.
pub firebase_auth_api_key: Cow<'static, str>,
}
impl WarpServerConfig {
pub fn production() -> Self {
Self {
server_root_url: "".into(),
rtc_server_url: "".into(),
session_sharing_server_url: None,
firebase_auth_api_key: "".into(),
}
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct OzConfig {
/// Root URL for the Oz (ambient agent management) dashboard.
pub oz_root_url: Cow<'static, str>,
/// URL to use as the audience when issuing workload identity tokens. If [`None`], falls back
/// to [`WarpServerConfig::server_root_url`]. This exists so the audience is not overridden
/// when a custom server root URL is provided (e.g. an ngrok URL for local development).
pub workload_audience_url: Option<Cow<'static, str>>,
}
impl OzConfig {
pub fn production() -> Self {
Self {
oz_root_url: "".into(),
workload_audience_url: None,
}
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct TelemetryConfig {
/// The name of the file in which not-yet-sent telemetry events will be stored.
pub telemetry_file_name: Cow<'static, str>,
/// Configuration for Rudderstack, for reporting telemetry events.
pub rudderstack_config: Option<RudderStackConfig>,
}
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct RudderStackConfig {
pub write_key: Cow<'static, str>,
pub root_url: Cow<'static, str>,
pub ugc_write_key: Cow<'static, str>,
}
impl RudderStackConfig {
pub fn non_ugc_destination(&self) -> RudderStackDestination {
RudderStackDestination {
root_url: self.root_url.clone(),
write_key: self.write_key.clone(),
}
}
pub fn ugc_destination(&self) -> RudderStackDestination {
RudderStackDestination {
root_url: self.root_url.clone(),
write_key: self.ugc_write_key.clone(),
}
}
}
#[derive(Default)]
pub struct RudderStackDestination {
pub root_url: Cow<'static, str>,
pub write_key: Cow<'static, str>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct AutoupdateConfig {
/// The base URL for fetching autoupdate versions and updated release bundles.
pub releases_base_url: Cow<'static, str>,
/// Whether or not to display menu items relating to autoupdate.
pub show_autoupdate_menu_items: bool,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct CrashReportingConfig {
/// The URL/DSN for sending error logs and crash reports to Sentry.
pub sentry_url: Cow<'static, str>,
}
/// Configuration for statically-bundled MCP OAuth credentials.
///
/// These are credentials for OAuth providers where dynamic client registration
/// is not supported and we instead ship pre-registered client IDs and secrets.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct McpStaticConfig {
/// Per-provider OAuth credentials.
pub providers: Vec<McpOAuthProviderConfig>,
}
/// A single OAuth provider's credentials for MCP authentication.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct McpOAuthProviderConfig {
/// The issuer URL of the OAuth provider (e.g. `https://github.com/login/oauth`).
pub issuer: Cow<'static, str>,
/// The OAuth client ID registered for this channel.
pub client_id: Cow<'static, str>,
/// The OAuth client secret registered for this channel.
pub client_secret: Cow<'static, str>,
}
+74
View File
@@ -0,0 +1,74 @@
mod config;
mod state;
use std::fmt;
pub use config::*;
pub use state::*;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Channel {
/// The official/first-party stable release.
Stable,
/// The official/first-party feature preview release.
Preview,
/// The internal-only nightly build.
Dev,
/// The internal-only HEAD build.
Local,
/// The open-source build of Warp.
Oss,
/// The integration test build.
Integration,
}
impl Channel {
/// Whether or not this channel is for internal use only
pub fn is_dogfood(&self) -> bool {
match self {
Channel::Dev | Channel::Local => true,
Channel::Stable | Channel::Preview | Channel::Integration | Channel::Oss => false,
}
}
/// Whether this channel honors the `--server-root-url` / `--ws-server-url` /
/// `--session-sharing-server-url` flags (and their `WARP_*` env-var equivalents).
///
/// Release channels (`Stable`, `Preview`, `Oss`) ignore these overrides so shipped
/// builds can't be redirected away from their baked-in server URLs. Internal-only channels
/// (`Dev`, `Local`, `Integration`) continue to honor them for local development and testing.
pub fn allows_server_url_overrides(&self) -> bool {
match self {
Channel::Dev | Channel::Local | Channel::Integration => true,
Channel::Stable | Channel::Preview | Channel::Oss => false,
}
}
/// Returns the CLI command name corresponding to this channel.
pub fn cli_command_name(&self) -> &'static str {
match self {
Channel::Stable => "galaxy-ai",
Channel::Dev => "galaxy-ai-dev",
Channel::Preview => "galaxy-ai-preview",
Channel::Local => "galaxy-ai-local",
Channel::Integration => "galaxy-ai-integration",
Channel::Oss => "galaxy-ai-oss",
}
}
}
impl fmt::Display for Channel {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(match self {
Channel::Stable => "stable",
Channel::Preview => "preview",
Channel::Dev => "dev",
Channel::Integration => "integration",
Channel::Local => "local",
Channel::Oss => "warp-oss",
})
}
}
+452
View File
@@ -0,0 +1,452 @@
use lazy_static::lazy_static;
use parking_lot::Mutex;
use std::{borrow::Cow, collections::HashSet};
use url::{Origin, ParseError, Url};
use crate::AppId;
use crate::{
channel::config::{
ChannelConfig, McpOAuthProviderConfig, OzConfig, RudderStackDestination, WarpServerConfig,
},
features::FeatureFlag,
};
use super::Channel;
lazy_static! {
static ref CHANNEL_STATE: Mutex<ChannelState> = Mutex::new(ChannelState::init());
}
#[cfg(feature = "test-util")]
lazy_static! {
static ref MOCK_SERVER: mockito::ServerGuard = mockito::Server::new();
static ref MOCK_SERVER_URL: String = MOCK_SERVER.url();
static ref APP_VERSION: Mutex<Option<&'static str>> = Mutex::new(None);
}
#[derive(Debug)]
pub struct ChannelState {
channel: Channel,
/// The set of additional features to enable (on top of default-enabled ones).
additional_features: HashSet<FeatureFlag>,
config: ChannelConfig,
}
impl ChannelState {
pub fn init() -> Self {
let channel = Channel::Oss;
let app_id = AppId::new("dev", "warp", "WarpOss");
Self {
channel,
additional_features: Default::default(),
config: ChannelConfig {
app_id,
logfile_name: "".into(),
server_config: WarpServerConfig::production(),
oz_config: OzConfig::production(),
telemetry_config: None,
autoupdate_config: None,
crash_reporting_config: None,
mcp_static_config: None,
},
}
}
pub fn new(channel: Channel, mut config: ChannelConfig) -> Self {
if let Some(app_id) = app_id_from_bundle() {
config.app_id = app_id;
}
Self {
channel,
additional_features: Default::default(),
config,
}
}
pub fn with_additional_features(mut self, overrides: &[FeatureFlag]) -> Self {
self.additional_features.extend(overrides);
self
}
pub fn set(state: ChannelState) {
*CHANNEL_STATE.lock() = state;
}
pub fn is_release_bundle() -> bool {
cfg!(feature = "release_bundle")
}
pub fn enable_debug_features() -> bool {
cfg!(debug_assertions) || matches!(Self::channel(), Channel::Local | Channel::Dev)
}
pub fn override_server_root_url(url: impl Into<Cow<'static, str>>) -> Result<(), ParseError> {
let url = url.into();
Url::parse(&url)?;
CHANNEL_STATE.lock().config.server_config.server_root_url = url;
Ok(())
}
pub fn override_ws_server_url(url: impl Into<Cow<'static, str>>) -> Result<(), ParseError> {
let url = url.into();
Url::parse(&url)?;
CHANNEL_STATE.lock().config.server_config.rtc_server_url = url;
Ok(())
}
pub fn override_session_sharing_server_url(
url: impl Into<Cow<'static, str>>,
) -> Result<(), ParseError> {
let url = url.into();
Url::parse(&url)?;
CHANNEL_STATE
.lock()
.config
.server_config
.session_sharing_server_url = Some(url);
Ok(())
}
pub fn uses_staging_server() -> bool {
let Ok(url) = Url::parse(Self::server_root_url().as_ref()) else {
return false;
};
url.host_str() == Some("staging.warp.dev")
}
/// Returns the canonical identifier for the application.
///
/// This should not be used for namespacing persisted data - such use cases
/// should make use of [`Self::data_domain`] instead.
pub fn app_id() -> AppId {
CHANNEL_STATE.lock().config.app_id.clone()
}
/// Returns a profile name for isolating user data. This should be used to
/// sandbox how user data is stored.
///
/// This is a debugging tool for isolating development instances of Warp, and is not
/// supported in release builds.
pub fn data_profile() -> Option<String> {
if cfg!(debug_assertions) {
std::env::var("WARP_DATA_PROFILE").ok()
} else {
None
}
}
/// Returns a value that should be used for namespacing persisted data.
///
/// In release builds, this is identical to the app ID; in debug builds,
/// it optionally includes a suffix derived from the `WARP_DATA_PROFILE`
/// environment variable.
pub fn data_domain() -> String {
match Self::data_profile() {
Some(profile) => format!("{}-{profile}", Self::app_id()),
None => Self::app_id().to_string(),
}
}
/// Returns the data domain if overridden from the default, otherwise None.
pub fn data_domain_if_not_default() -> Option<String> {
Self::data_profile().map(|_| Self::data_domain())
}
pub fn additional_features() -> HashSet<FeatureFlag> {
CHANNEL_STATE
.lock()
.additional_features
.iter()
.cloned()
.collect()
}
pub fn debug_str() -> String {
format!("{:?}", *CHANNEL_STATE.lock())
}
pub fn logfile_name() -> Cow<'static, str> {
CHANNEL_STATE.lock().config.logfile_name.clone()
}
pub fn telemetry_file_name() -> Cow<'static, str> {
CHANNEL_STATE
.lock()
.config
.telemetry_config
.as_ref()
.map(|tc| tc.telemetry_file_name.clone())
.unwrap_or_default()
}
/// Returns whether this build has a telemetry config and can therefore ship
/// telemetry events. Builds like OpenWarp intentionally ship with
/// `telemetry_config: None`, in which case UI that controls telemetry
/// should be hidden since the toggle has no effect.
pub fn is_telemetry_available() -> bool {
CHANNEL_STATE.lock().config.telemetry_config.is_some()
}
/// Returns whether this build has a crash reporting config and can therefore
/// ship crash reports. Builds like OpenWarp intentionally ship with
/// `crash_reporting_config: None`, in which case UI that controls crash
/// reporting should be hidden since the toggle has no effect.
pub fn is_crash_reporting_available() -> bool {
CHANNEL_STATE.lock().config.crash_reporting_config.is_some()
}
pub fn releases_base_url() -> Cow<'static, str> {
CHANNEL_STATE
.lock()
.config
.autoupdate_config
.as_ref()
.map(|ac| ac.releases_base_url.clone())
.unwrap_or_default()
}
pub fn firebase_api_key() -> Cow<'static, str> {
CHANNEL_STATE
.lock()
.config
.server_config
.firebase_auth_api_key
.clone()
}
pub fn ws_server_url() -> Cow<'static, str> {
CHANNEL_STATE
.lock()
.config
.server_config
.rtc_server_url
.clone()
}
/// Returns the HTTP(S) root URL for the RTC server. Used for HTTP endpoints
/// served by warp-server-rtc (e.g. the agent event SSE stream).
///
/// Derived from [`ws_server_url`] by rewriting the scheme (`wss`→`https`,
/// `ws`→`http`) and stripping the path. Falls back to [`server_root_url`]
/// when the WS URL cannot be parsed or uses an unexpected scheme — this
/// keeps override paths (e.g. `WARP_WS_SERVER_URL=...`) working without a
/// separate override for the HTTP variant.
pub fn rtc_http_url() -> Cow<'static, str> {
cfg_if::cfg_if! {
if #[cfg(feature = "test-util")] {
Cow::Owned(MOCK_SERVER_URL.clone())
} else {
match derive_http_origin_from_ws_url(&Self::ws_server_url()) {
Some(origin) => Cow::Owned(origin),
None => Self::server_root_url(),
}
}
}
}
pub fn session_sharing_server_url() -> Option<Cow<'static, str>> {
cfg_if::cfg_if! {
if #[cfg(feature = "test-util")] {
Some(Cow::Borrowed("fake_session_sharing_url"))
} else {
CHANNEL_STATE.lock().config.server_config.session_sharing_server_url.clone()
}
}
}
pub fn oz_root_url() -> Cow<'static, str> {
CHANNEL_STATE.lock().config.oz_config.oz_root_url.clone()
}
pub fn server_root_url() -> Cow<'static, str> {
cfg_if::cfg_if! {
if #[cfg(feature = "test-util")] {
Cow::Owned(MOCK_SERVER_URL.clone())
} else {
CHANNEL_STATE.lock().config.server_config.server_root_url.clone()
}
}
}
pub fn workload_audience_url() -> Cow<'static, str> {
let state = CHANNEL_STATE.lock();
match &state.config.oz_config.workload_audience_url {
Some(url) => url.clone(),
None => {
drop(state);
Self::server_root_url()
}
}
}
// Returns the origin url, with scheme, domain, and ports (if any)
pub fn server_root_domain() -> Origin {
Url::parse(&Self::server_root_url())
.unwrap_or_else(|_| Url::parse("http://localhost").unwrap())
.origin()
}
/// Returns the rudderstack destination for all events that don't contain user-generated content.
pub fn rudderstack_non_ugc_destination() -> RudderStackDestination {
let state = CHANNEL_STATE.lock();
state
.config
.telemetry_config
.as_ref()
.and_then(|tc| tc.rudderstack_config.as_ref())
.map(|rs| rs.non_ugc_destination())
.unwrap_or_default()
}
/// Returns the rudderstack destination for all events that contain user-generated content.
pub fn rudderstack_ugc_destination() -> RudderStackDestination {
let state = CHANNEL_STATE.lock();
state
.config
.telemetry_config
.as_ref()
.and_then(|tc| tc.rudderstack_config.as_ref())
.map(|rs| rs.ugc_destination())
.unwrap_or_default()
}
pub fn channel() -> Channel {
CHANNEL_STATE.lock().channel
}
#[cfg(feature = "test-util")]
pub fn app_version() -> Option<&'static str> {
let version = APP_VERSION.lock();
version.or_else(|| option_env!("GIT_RELEASE_TAG"))
}
#[cfg(feature = "test-util")]
pub fn set_app_version(version: Option<&'static str>) {
*APP_VERSION.lock() = version;
}
#[cfg(not(feature = "test-util"))]
pub fn app_version() -> Option<&'static str> {
option_env!("GIT_RELEASE_TAG")
}
pub fn sentry_url() -> Cow<'static, str> {
CHANNEL_STATE
.lock()
.config
.crash_reporting_config
.as_ref()
.map(|crc| crc.sentry_url.clone())
.unwrap_or_default()
}
pub fn show_autoupdate_menu_items() -> bool {
CHANNEL_STATE
.lock()
.config
.autoupdate_config
.as_ref()
.map(|ac| ac.show_autoupdate_menu_items)
.unwrap_or_default()
}
/// Returns the MCP OAuth provider config matching the given client ID, if any.
pub fn mcp_oauth_provider_by_client_id(client_id: &str) -> Option<McpOAuthProviderConfig> {
CHANNEL_STATE
.lock()
.config
.mcp_static_config
.as_ref()
.and_then(|c| c.providers.iter().find(|p| p.client_id == client_id))
.cloned()
}
/// Returns the MCP OAuth provider config matching the given issuer URL, if any.
pub fn mcp_oauth_provider_by_issuer(issuer: &str) -> Option<McpOAuthProviderConfig> {
CHANNEL_STATE
.lock()
.config
.mcp_static_config
.as_ref()
.and_then(|c| c.providers.iter().find(|p| p.issuer == issuer))
.cloned()
}
pub fn url_scheme() -> &'static str {
match Self::channel() {
Channel::Stable => "warp",
Channel::Preview => "warppreview",
Channel::Dev => "warpdev",
// Dummy value--integration tests shouldn't support URL schemes.
Channel::Integration => "warpintegration",
Channel::Local => "warplocal",
Channel::Oss => "warposs",
}
}
}
/// Derives an HTTP(S) origin URL from a WebSocket URL by rewriting the scheme
/// (`wss`→`https`, `ws`→`http`) and stripping the path, query, and fragment.
/// Returns [`None`] when the input cannot be parsed as a URL or uses a scheme
/// other than `ws` or `wss`.
#[cfg(not(feature = "test-util"))]
fn derive_http_origin_from_ws_url(ws_url: &str) -> Option<String> {
let url = Url::parse(ws_url).ok()?;
let http_scheme = match url.scheme() {
"wss" => "https",
"ws" => "http",
_ => return None,
};
let host = url.host_str()?;
let mut origin = format!("{http_scheme}://{host}");
if let Some(port) = url.port() {
origin.push_str(&format!(":{port}"));
}
Some(origin)
}
#[cfg(all(test, not(feature = "test-util")))]
#[path = "state_tests.rs"]
mod tests;
fn app_id_from_bundle() -> Option<AppId> {
// On macOS, attempt to determine the app ID from the containing bundle,
// falling back to the channel-keyed "default" ID if we cannot retrieve
// bundle information.
//
// We skip this for tests, as the call to `mainBundle` can take 30+ms,
// which is a significant portion of the total test runtime.
#[cfg(all(target_os = "macos", not(feature = "test-util")))]
#[allow(deprecated)]
unsafe {
use cocoa::{
base::{id, nil},
foundation::NSBundle,
};
use objc::{msg_send, sel, sel_impl};
use galaxyui::platform::mac::utils::nsstring_as_str;
let bundle = id::mainBundle();
if bundle != nil {
let nsstring: id = msg_send![bundle, bundleIdentifier];
if nsstring != nil {
let app_id = nsstring_as_str(nsstring)
.expect("bundle IDs should always be valid UTF-8 strings");
if !app_id.is_empty() {
return Some(
AppId::parse(app_id)
.expect("macOS bundle identifier has an unexpected format"),
);
}
}
}
}
None
}
@@ -0,0 +1,19 @@
use super::derive_http_origin_from_ws_url;
#[test]
fn wss_becomes_https_and_strips_path() {
let got = derive_http_origin_from_ws_url("wss://rtc.app.warp.dev/graphql/v2");
assert_eq!(got.as_deref(), Some("https://rtc.app.warp.dev"));
}
#[test]
fn ws_becomes_http_and_preserves_port() {
let got = derive_http_origin_from_ws_url("ws://localhost:8080/graphql/v2");
assert_eq!(got.as_deref(), Some("http://localhost:8080"));
}
#[test]
fn unparseable_input_returns_none() {
assert!(derive_http_origin_from_ws_url("not a url").is_none());
assert!(derive_http_origin_from_ws_url("https://app.warp.dev").is_none());
}
+73
View File
@@ -0,0 +1,73 @@
use std::fmt;
use command_corrections::ExitCode as CommandCorrectionsExitCode;
use serde::{Deserialize, Serialize};
/// List of process exit codes that we consider to be "Success"
///
/// - 0 is the standard success exit code
/// - 130 is the exit code for when a process is quit by Ctrl-C
/// - 141 is for when a process is closed while piping output to a pager (e.g. `git log`)
/// - -1073741510 is exit code for when a process is aborted with `STATUS_CONTROL_C_EXIT` on
/// Windows. We don't gate this on OS because it's impossible to get a negative exit code in
/// Unix environments.
const SUCCESSFUL_EXIT_CODES: &[i32] = &[0, 130, 141, -1073741510];
/// This is a newtype for i32.
/// It is meant to cover
/// - POSIX systems where exit codes are u8
/// - Windows systems where exit codes are i32
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct ExitCode(i32);
impl ExitCode {
pub fn value(&self) -> i32 {
self.0
}
/// Returns true if the command exited due to SIGINT, typically via ctrl-c.
pub fn is_sigint(&self) -> bool {
self.0 == 130
}
/// Returns true if the exit code indicates "command not found".
/// - 127: Unix/Linux/macOS
/// - 9009: Windows CMD
pub fn was_command_not_found(&self) -> bool {
self.0 == 127 || self.0 == 9009
}
/// Returns true if the error code indicates that the error code
/// is successful from the perspective of us indicating in the
/// ui that it is not in error:
/// - 0 is the standard success exit code
/// - 130 is the exit code for when a process is quit by Ctrl-C
/// - 141 is for when a process is closed while piping output to a pager (e.g. `git log`)
pub fn was_successful(&self) -> bool {
SUCCESSFUL_EXIT_CODES.contains(&self.0)
}
}
impl From<i32> for ExitCode {
fn from(code: i32) -> Self {
Self(code)
}
}
impl From<CommandCorrectionsExitCode> for ExitCode {
fn from(code: CommandCorrectionsExitCode) -> Self {
Self::from(code.raw())
}
}
impl From<ExitCode> for CommandCorrectionsExitCode {
fn from(code: ExitCode) -> Self {
Self::from(code.0)
}
}
impl fmt::Display for ExitCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
+156
View File
@@ -0,0 +1,156 @@
//! ContextFlag flags are for behaviors that need to be conditionally enabled or disabled based
//! on where the app is being run and are a permanent part of the app.
use std::{
str::FromStr,
sync::atomic::{AtomicBool, Ordering},
};
use enum_iterator::{cardinality, Sequence};
use crate::channel::ChannelState;
/// All ContextFlag flag are enabled by default. Environments can conditionally disable flags.
///
/// Aside from manually setting specific flags in dogfood contexts, the complete list of contexts
/// this is used in is found in the ContextFlag impl.
#[derive(Copy, Clone, Hash, PartialEq, Eq, Debug, Sequence)]
pub enum ContextFlag {
CreateSharedSession,
CreateNewSession,
CloseWindow,
ForceSidePanelOpen,
ShowRewardModal,
HideOpenOnDesktopButton,
PromptForVersionUpdates,
NetworkLogConsole,
RunWorkflow,
LaunchConfigurations,
WarpEssentials,
AllowSettingsModalToClose,
ShowSlowShellStartupBanner,
DynamicBrowserUrl,
ShowMCPServers,
}
/// The enablement states for context flags. As mentioned in the documentation
/// for [`ContextFlag`], these are enabled by default.
static FLAG_STATES: [AtomicBool; cardinality::<ContextFlag>()] =
[const { AtomicBool::new(true) }; { cardinality::<ContextFlag>() }];
fn disable_flag(flag: ContextFlag) {
FLAG_STATES[flag as usize].store(false, Ordering::Relaxed);
}
impl ContextFlag {
pub fn is_enabled(&self) -> bool {
FLAG_STATES[*self as usize].load(Ordering::Relaxed)
}
/// Sets a ContextFlag flag. FOR DEBUG USE ONLY.
pub fn set(&self, value: bool) {
if !ChannelState::enable_debug_features() {
log::error!(
"Tried to set value of `ContextFlag` flag `{self:?}` in non-dogfood context."
);
}
FLAG_STATES[*self as usize].store(value, Ordering::Relaxed);
}
pub fn set_warp_home_link_only() {
disable_flag(Self::ForceSidePanelOpen);
disable_flag(Self::ShowRewardModal);
disable_flag(Self::HideOpenOnDesktopButton);
disable_flag(Self::RunWorkflow);
disable_flag(Self::CreateSharedSession);
disable_flag(Self::CreateNewSession);
disable_flag(Self::CloseWindow);
disable_flag(Self::PromptForVersionUpdates);
disable_flag(Self::WarpEssentials);
disable_flag(Self::NetworkLogConsole);
disable_flag(Self::ShowMCPServers);
}
pub fn set_settings_link_only() {
disable_flag(Self::ForceSidePanelOpen);
disable_flag(Self::ShowRewardModal);
disable_flag(Self::HideOpenOnDesktopButton);
disable_flag(Self::RunWorkflow);
disable_flag(Self::CreateSharedSession);
disable_flag(Self::CreateNewSession);
disable_flag(Self::CloseWindow);
disable_flag(Self::PromptForVersionUpdates);
disable_flag(Self::WarpEssentials);
disable_flag(Self::NetworkLogConsole);
disable_flag(Self::AllowSettingsModalToClose);
disable_flag(Self::ShowSlowShellStartupBanner);
disable_flag(Self::DynamicBrowserUrl);
disable_flag(Self::ShowMCPServers);
}
pub fn set_warp_drive_link_only() {
disable_flag(Self::ForceSidePanelOpen);
disable_flag(Self::ShowRewardModal);
disable_flag(Self::HideOpenOnDesktopButton);
disable_flag(Self::RunWorkflow);
disable_flag(Self::CreateSharedSession);
disable_flag(Self::CreateNewSession);
disable_flag(Self::CloseWindow);
disable_flag(Self::PromptForVersionUpdates);
disable_flag(Self::WarpEssentials);
disable_flag(Self::NetworkLogConsole);
disable_flag(Self::ShowMCPServers);
}
// ContextFlag flag sets:
pub fn set_shared_session_only() {
disable_flag(Self::CreateSharedSession);
disable_flag(Self::CreateNewSession);
disable_flag(Self::CloseWindow);
disable_flag(Self::ForceSidePanelOpen);
disable_flag(Self::ShowRewardModal);
disable_flag(Self::HideOpenOnDesktopButton);
disable_flag(Self::PromptForVersionUpdates);
disable_flag(Self::NetworkLogConsole);
disable_flag(Self::LaunchConfigurations);
disable_flag(Self::WarpEssentials);
disable_flag(Self::ShowMCPServers);
}
pub fn set_conversation_only() {
disable_flag(Self::CreateSharedSession);
disable_flag(Self::CreateNewSession);
disable_flag(Self::CloseWindow);
disable_flag(Self::ForceSidePanelOpen);
disable_flag(Self::ShowRewardModal);
disable_flag(Self::HideOpenOnDesktopButton);
disable_flag(Self::PromptForVersionUpdates);
disable_flag(Self::NetworkLogConsole);
disable_flag(Self::LaunchConfigurations);
disable_flag(Self::WarpEssentials);
disable_flag(Self::ShowMCPServers);
disable_flag(Self::RunWorkflow);
}
}
impl FromStr for ContextFlag {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"CreateSharedSession" => Ok(Self::CreateSharedSession),
"CreateNewSession" => Ok(Self::CreateNewSession),
"CloseWindow" => Ok(Self::CloseWindow),
"ForceSidePanelOpen" => Ok(Self::ForceSidePanelOpen),
"ShowRewardModal" => Ok(Self::ShowRewardModal),
"HideOpenOnDesktopButton" => Ok(Self::HideOpenOnDesktopButton),
"PromptForVersionUpdates" => Ok(Self::PromptForVersionUpdates),
"NetworkLogConsole" => Ok(Self::NetworkLogConsole),
"RunWorkflow" => Ok(Self::RunWorkflow),
"LaunchConfigurations" => Ok(Self::LaunchConfigurations),
"WarpEssentials" => Ok(Self::WarpEssentials),
_ => Err(()),
}
}
}
+80
View File
@@ -0,0 +1,80 @@
mod anyhow;
mod registration;
mod reqwest;
#[cfg(not(target_family = "wasm"))]
mod tokio;
#[cfg(not(target_family = "wasm"))]
mod websocket;
// Re-export for macro use.
#[doc(hidden)]
pub use inventory::submit;
pub use self::anyhow::AnyhowErrorExt;
pub use registration::{ErrorRegistration, RegisteredError};
pub use registration::register_error;
/// The `target` that is set by log entries from this module.
pub const LOG_TARGET: &str = "errors::report_error";
/// Reports an error encountered during execution.
///
/// This checks whether or not the error is actionable, and logs an error or
/// warning accordingly. (Logs at the Error level get reported back to us, so
/// we don't want to log anything at Error level that we aren't able to act
/// upon.)
#[macro_export]
macro_rules! report_error {
($err:expr) => {{
#[allow(unused_imports)]
use $crate::errors::{AnyhowErrorExt as _, ErrorExt as _, LOG_TARGET};
let err = $err;
let log_level = if err.is_actionable() {
err.report_error();
log::Level::Error
} else {
log::Level::Warn
};
log::log!(target: LOG_TARGET, log_level, "{:#}", err);
}};
}
pub use report_error;
/// Reports an error if the provided [`Result`] is [`Err`].
///
/// This checks whether or not the error is actionable, and logs an error or
/// warning accordingly. (Logs at the Error level get reported back to us, so
/// we don't want to log anything at Error level that we aren't able to act
/// upon.)
#[macro_export]
macro_rules! report_if_error {
($result:expr) => {{
if let Err(error) = &$result {
$crate::report_error!(error);
}
}};
}
pub use report_if_error;
/// Returns whether or not a log entry with the given metadata should be
/// ignored by Sentry.
#[cfg(feature = "crash_reporting")]
pub fn should_ignore_log_for_sentry(md: &log::Metadata) -> bool {
// Filter out any Error-level log entries generated by report_error!().
// report_error!() utilizes capture_anyhow() to report structured errors
// instead of simple string error messages, and we don't want to _also_
// report the Error-level log line to Sentry.
md.target() == LOG_TARGET && md.level() == log::Level::Error
}
pub trait ErrorExt: RegisteredError + std::error::Error {
/// Returns whether or not an error is something that is actionable by our
/// engineering team.
fn is_actionable(&self) -> bool;
fn report_error(&self) {
#[cfg(feature = "crash_reporting")]
sentry::capture_error(self);
}
}
+31
View File
@@ -0,0 +1,31 @@
use super::registration::AnyErrorRegistration;
/// A version of [`ErrorExt`] that works for [`anyhow::Error`] (which does not
/// implement [`std::error::Error`]).
pub trait AnyhowErrorExt {
/// Returns whether or not an error is something that is actionable by our
/// engineering team.
fn is_actionable(&self) -> bool;
/// Reports the error.
fn report_error(&self);
}
impl AnyhowErrorExt for anyhow::Error {
fn is_actionable(&self) -> bool {
for cause in self.chain() {
for imp in inventory::iter::<&'static dyn AnyErrorRegistration>() {
if imp.downcast_and_is_actionable(cause) == Some(false) {
return false;
}
}
}
true
}
fn report_error(&self) {
#[cfg(feature = "crash_reporting")]
sentry::integrations::anyhow::capture_anyhow(self);
}
}
@@ -0,0 +1,60 @@
use std::marker::PhantomData;
use super::ErrorExt;
#[macro_export]
macro_rules! register_error {
($error:ty) => {
impl $crate::errors::RegisteredError for $error {}
$crate::errors::submit! {
$crate::errors::ErrorRegistration::<$error>::adapt()
}
};
}
pub use register_error;
/// Marker trait for known error events. We rely on this to implement [`ErrorExt`] for [`anyhow::Error`]
/// in a way that delegates to errors in the context chain.
///
/// DO NOT implement this trait directly - use the [`register_error!`] macro instead.
pub trait RegisteredError {}
/// A type-erased version of [`ErrorRegistration`]. This is only used by the
/// [`register_error!`] macro implementation.
#[doc(hidden)]
pub trait AnyErrorRegistration: Sync {
// Returns true if
fn downcast_and_is_actionable(&self, error: &(dyn std::error::Error + 'static))
-> Option<bool>;
}
/// Adapter for statically registering all [`ErrorExt`] implementations.
#[doc(hidden)]
pub struct ErrorRegistration<T: ErrorExt + 'static> {
/// Marker that `ErrorRegistration` references `T`, but doesn't own a `T` value.
/// See https://doc.rust-lang.org/nomicon/phantom-data.html
_marker: PhantomData<fn(T) -> T>,
}
impl<T: ErrorExt + 'static> ErrorRegistration<T> {
pub const fn adapt() -> &'static dyn AnyErrorRegistration {
&Self {
_marker: PhantomData,
}
}
}
impl<T: ErrorExt + 'static> AnyErrorRegistration for ErrorRegistration<T> {
fn downcast_and_is_actionable(
&self,
error: &(dyn std::error::Error + 'static),
) -> Option<bool> {
let err = error.downcast_ref::<T>()?;
Some(err.is_actionable())
}
}
// Collect adapters for all registered error types. Because `inventory::collect!` requires a
// concrete type, we use `&static dyn Trait` to erase the generics.
inventory::collect!(&'static dyn AnyErrorRegistration);
+48
View File
@@ -0,0 +1,48 @@
use http::StatusCode;
use super::{register_error, ErrorExt};
impl ErrorExt for reqwest::Error {
fn is_actionable(&self) -> bool {
// Outside of timeouts, there's nothing we can do about errors
// that occur prior to the successful receipt of an HTTP
// response.
// There's no way to check for connection errors via web APIs, so
// `is_connect` can only be called on native platforms.
#[cfg(not(target_family = "wasm"))]
if self.is_connect() {
return false;
}
if self.is_request() || self.is_body() || self.is_decode() {
return false;
}
// If we're getting a capacity error from the server, then that should trip a server-side
// alert. A duplicate report in Sentry isn't helpful.
if self.status() == Some(StatusCode::TOO_MANY_REQUESTS) {
return false;
}
// Internal server errors (5xx) are server-side issues that we can't act upon from the client.
if self.status().is_some_and(|status| status.is_server_error()) {
return false;
}
// If we're making a request to the staging server and get back
// a 403 Forbidden, the user is probably not whitelisted to talk
// to staging from their current IP address, so downgrade to a
// warning.
if let (Some(url), Some(status)) = (self.url(), self.status()) {
if let Some(domain) = url.domain() {
if domain == "staging.warp.dev" && status == StatusCode::FORBIDDEN {
return false;
}
}
}
true
}
}
register_error!(reqwest::Error);
+19
View File
@@ -0,0 +1,19 @@
use super::{register_error, ErrorExt};
impl ErrorExt for tokio::task::JoinError {
fn is_actionable(&self) -> bool {
// If the task was cancelled (aborted), this is expected behavior and not actionable.
if self.is_cancelled() {
return false;
}
// If the task panicked, this is actionable - we need to know about panics.
if self.is_panic() {
return true;
}
// Other join errors are actionable.
true
}
}
register_error!(tokio::task::JoinError);
@@ -0,0 +1,25 @@
use http::StatusCode;
use super::{register_error, ErrorExt};
impl ErrorExt for websocket::tungstenite::Error {
fn is_actionable(&self) -> bool {
match self {
Self::Http(res) => {
// Capacity errors from the server aren't actionable client-side.
if res.status() == StatusCode::TOO_MANY_REQUESTS {
return false;
}
// Internal server errors (5xx) are server-side issues that we can't act upon from the client.
if res.status().is_server_error() {
return false;
}
true
}
_ => true,
}
}
}
register_error!(websocket::tungstenite::Error);
+120
View File
@@ -0,0 +1,120 @@
use std::sync::OnceLock;
use galaxyui::{Entity, ModelContext, SingletonEntity};
// Global execution mode, for logic that runs outside the UI framework.
static GLOBAL_EXECUTION_MODE: OnceLock<ExecutionMode> = OnceLock::new();
/// Execution mode that Warp is running under.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ExecutionMode {
/// Warp is running as a normal desktop app.
App,
/// Warp is running as a CLI.
Sdk,
}
impl ExecutionMode {
/// Returns the client ID to report to the server.
/// This must stay in sync with the util/client.go constants on the server.
pub fn client_id(&self) -> &'static str {
match self {
ExecutionMode::App => "warp-app",
ExecutionMode::Sdk => "warp-cli",
}
}
}
/// Model tracking the mode that Warp is running in.
///
/// This gates functionality that's disabled when Warp is running in SDK mode.
#[derive(Clone, Debug)]
pub struct AppExecutionMode {
mode: ExecutionMode,
is_sandboxed: bool,
}
impl AppExecutionMode {
/// Create an `AppExecutionMode` model with the execution mode set.
pub fn new(mode: ExecutionMode, is_sandboxed: bool, _ctx: &mut ModelContext<Self>) -> Self {
let _ = GLOBAL_EXECUTION_MODE.set(mode);
Self { mode, is_sandboxed }
}
/// True if running as the full desktop app.
fn is_app(&self) -> bool {
matches!(self.mode, ExecutionMode::App)
}
/// Whether Active AI features are allowed in this execution mode.
///
/// Active AI should only run in the desktop app, where there's a user
/// to engage with it.
pub fn allows_active_ai(&self) -> bool {
self.is_app()
}
/// Whether the app can sync user preferences to the cloud. This does not gate
/// modifying preferences locally.
pub fn can_sync_preferences(&self) -> bool {
self.is_app()
}
/// Whether the app can save and restore sessions.
pub fn can_save_session(&self) -> bool {
self.is_app()
}
/// Whether the app can *automatically* update. This does not prevent manual updates.
pub fn can_autoupdate(&self) -> bool {
self.is_app()
}
/// Whether the app can automatically start MCP servers from the previous session.
pub fn can_autostart_mcp_servers(&self) -> bool {
self.is_app()
}
/// Whether the app can sync agent conversations (tasks and cloud conversation metadata).
/// In CLI mode, we don't need this data since there's no user viewing it.
pub fn can_fetch_agent_runs_for_management(&self) -> bool {
self.is_app()
}
/// Whether telemetry should be sent synchronously at shutdown.
/// In CLI mode, we synchronously send events at shutdown because there's a higher likelihood
/// that they will be lost otherwise.
pub fn send_telemetry_at_shutdown(&self) -> bool {
matches!(self.mode, ExecutionMode::Sdk)
}
/// If true, the app is running autonomously, without a user present.
/// Wherever possible, prefer more targeted capability checks like
/// [`Self::can_autostart_mcp_servers`].
pub fn is_autonomous(&self) -> bool {
matches!(self.mode, ExecutionMode::Sdk)
}
/// Returns the client ID to report to the server.
pub fn client_id(&self) -> &'static str {
self.mode.client_id()
}
/// If true, Warp is running in a sandbox like a Docker container or VM, rather than directly
/// on a user machine.
pub fn is_sandboxed(&self) -> bool {
self.is_sandboxed
}
}
impl Entity for AppExecutionMode {
type Event = ();
}
impl SingletonEntity for AppExecutionMode {}
/// Returns the current global client ID string ("warp-app" or "warp-cli").
/// This is set when AppExecutionMode is constructed during application start.
/// Returns None if the execution mode has not been set yet.
pub fn current_client_id() -> Option<&'static str> {
GLOBAL_EXECUTION_MODE.get().map(|mode| mode.client_id())
}
+28
View File
@@ -0,0 +1,28 @@
pub use galaxy_features::*;
use galaxyui::platform::menu::{CustomMenuItem, MenuItem, MenuItemPropertyChanges};
fn feature_flag_menu_item(flag: FeatureFlag) -> MenuItem {
MenuItem::Custom(CustomMenuItem::new(
&format!("{flag:?}"),
move |_| {
// toggling the flag
flag.set_enabled(!flag.is_enabled())
},
move |_props, _ctx| MenuItemPropertyChanges {
checked: Some(flag.is_enabled()),
..Default::default()
},
None,
))
}
pub fn runtime_flags_menu_items() -> Vec<MenuItem> {
if !FeatureFlag::RuntimeFeatureFlags.is_enabled() {
return Vec::new();
}
RUNTIME_FEATURE_FLAGS
.iter()
.map(|flag| feature_flag_menu_item(*flag))
.collect()
}
+25
View File
@@ -0,0 +1,25 @@
use std::fmt;
/// Opaque identifier for a remote host.
///
/// Returned by the server in `InitializeResponse`. Used by
/// `RemoteServerManager` and downstream features to deduplicate
/// host-scoped models (e.g. `RepoMetadataModel`) across sessions.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct HostId(String);
impl HostId {
pub fn new(id: String) -> Self {
Self(id)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for HostId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
+124
View File
@@ -0,0 +1,124 @@
use std::time::Duration;
use instant::Instant;
use serde::{Deserialize, Serialize};
use galaxyui::{Entity, SingletonEntity};
/// This represents one interval, i.e. one stage in a multiple-stage timer.
struct TimingInterval {
/// Assign each interval a unique name.
name: String,
/// When this interval ended.
instant: Instant,
}
impl TimingInterval {
fn new(name: String, instant: Instant) -> Self {
Self { name, instant }
}
}
/// This is a collection of points in time for a multiple-stage process that we want to time, and
/// we want to measure durations between each stage, or "interval".
pub struct IntervalTimer {
/// The timer starts when this struct is instantiated. The first interval is measured as a
/// duration from this instant, with each subsequent interval being measured from the end of
/// the prior interval.
start_instant: Instant,
intervals: Vec<TimingInterval>,
}
impl IntervalTimer {
pub fn new() -> Self {
Self {
start_instant: Instant::now(),
intervals: Vec::new(),
}
}
pub fn mark_interval_end(&mut self, name: impl Into<String>) {
self.intervals
.push(TimingInterval::new(name.into(), Instant::now()))
}
pub fn compute_duration_for_interval(&self, name: &str) -> Option<Duration> {
self.intervals
.iter()
.enumerate()
.find_map(|(idx, interval)| {
if interval.name == name {
let since = if idx == 0 {
self.start_instant
} else {
self.intervals[idx - 1].instant
};
let marginal = interval.instant.duration_since(since);
Some(marginal)
} else {
None
}
})
}
/// Once you are done with all the intervals in your process, we compute a cumulative sum of the
/// time at each interval, as well as an individual time between each interval.
pub fn compute_stats(&self) -> Vec<TimingDataPoint> {
let mut cumulative_duration_ms = 0;
self.intervals
.iter()
.enumerate()
.map(|(i, interval)| {
let since = if i == 0 {
self.start_instant
} else {
self.intervals[i - 1].instant
};
// Converting a duration to an int in ms returns a u128 which is excessively large
// for our purposes. It's also inconvenient as it isn't serializable by default.
let marginal_duration_ms =
interval.instant.duration_since(since).as_millis() as u64;
cumulative_duration_ms += marginal_duration_ms;
TimingDataPoint::new(
marginal_duration_ms,
cumulative_duration_ms,
interval.name.clone(),
)
})
.collect()
}
}
impl Default for IntervalTimer {
fn default() -> Self {
Self::new()
}
}
impl Entity for IntervalTimer {
type Event = ();
}
impl SingletonEntity for IntervalTimer {}
/// Used for reporting the timing results after timing is complete.
#[derive(Clone, Deserialize, Serialize)]
pub struct TimingDataPoint {
name: String,
marginal_duration_ms: u64,
cumulative_duration_ms: u64,
}
impl TimingDataPoint {
fn new(marginal_duration_ms: u64, cumulative_duration_ms: u64, name: String) -> Self {
Self {
marginal_duration_ms,
cumulative_duration_ms,
name,
}
}
}
#[cfg(test)]
#[path = "interval_timer_tests.rs"]
mod tests;
@@ -0,0 +1,31 @@
use std::thread;
use std::time::Duration;
use super::*;
#[test]
fn test_timing_info() {
let mut timer = IntervalTimer::new();
let ten_ms = Duration::from_millis(10);
thread::sleep(ten_ms);
timer.mark_interval_end("a");
thread::sleep(ten_ms);
timer.mark_interval_end("b");
let stats = timer.compute_stats();
assert_eq!(stats.len(), 2);
assert_eq!(stats[0].name, "a");
assert_eq!(
stats[0].cumulative_duration_ms,
stats[0].marginal_duration_ms
);
assert!(stats[0].marginal_duration_ms >= 10);
assert_eq!(stats[1].name, "b");
assert_eq!(
stats[1].cumulative_duration_ms,
stats[1].marginal_duration_ms + stats[0].marginal_duration_ms
);
assert!(stats[1].marginal_duration_ms >= 10);
}
+31
View File
@@ -0,0 +1,31 @@
pub mod app_id;
pub mod assertions;
pub mod channel;
pub mod command;
pub mod context_flag;
pub mod errors;
pub mod execution_mode;
pub mod features;
pub mod interval_timer;
#[cfg(target_os = "macos")]
pub mod macos;
pub mod operating_system_info;
pub mod paths;
pub mod platform;
pub mod safe_log;
pub mod semantic_selection;
pub use settings;
// Re-export settings macros for backward compatibility
pub use settings::{
define_setting, define_settings_group, implement_setting_for_enum, maybe_define_setting,
};
pub mod host_id;
pub mod session_id;
pub mod sync_queue;
pub mod telemetry;
pub mod ui;
pub mod user_preferences;
pub use app_id::AppId;
pub use host_id::HostId;
pub use session_id::SessionId;
+12
View File
@@ -0,0 +1,12 @@
use anyhow::Result;
use objc2_foundation::NSBundle;
/// Apple Developer Team ID used for code signing and validation.
pub const APPLE_TEAM_ID: &str = "2BBY89MBSN";
/// Get the path to the macOS `.app` bundle.
pub fn get_bundle_path() -> Result<String> {
let bundle = NSBundle::mainBundle();
let path = bundle.bundlePath();
Ok(path.to_string())
}
@@ -0,0 +1,169 @@
//! Module containing operating system information such as the name, category, and version.
use serde::Serialize;
use serde_with::SerializeDisplay;
use std::fmt::{Display, Formatter};
use std::sync::OnceLock;
#[cfg(target_family = "wasm")]
use galaxyui::platform::wasm;
#[cfg(target_family = "wasm")]
use galaxyui::platform::OperatingSystem;
static OS_INFO: OnceLock<Result<OperatingSystemInfo, OperatingSystemInfoError>> = OnceLock::new();
/// Information of the operating system of the client.
#[derive(Serialize)]
pub struct OperatingSystemInfo {
/// The name of the operating system. On Linux this is the name of the distribution.
name: String,
/// The version of the operating system. On Linux this is the version of the distribution, not
/// the Linux kernel version. `None` if the version could not be computed for any reason.
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<String>,
/// The category of the operating system (e.g. "Linux", "macOS", "Windows", or "Web").
category: OperatingSystemCategory,
/// The version of the linux kernel, if running on Linux. If not on Linux, this is always
/// `None`.
#[serde(skip_serializing_if = "Option::is_none")]
linux_kernel_version: Option<String>,
/// The name of the browser parsed from the user agent, if running on Web. If not on Web,
/// this is always `None`.
#[serde(skip_serializing_if = "Option::is_none")]
browser_name: Option<String>,
/// The version of the browser parsed from the user agent, if running on Web. If not on
/// Web, this is always `None`.
#[serde(skip_serializing_if = "Option::is_none")]
browser_version: Option<String>,
}
impl OperatingSystemInfo {
#[cfg(not(target_family = "wasm"))]
fn new() -> Result<Self, OperatingSystemInfoError> {
let os_category =
OperatingSystemCategory::new().ok_or(OperatingSystemInfoError::Unknown)?;
let (os_name, version, linux_kernel_version) =
if os_category == OperatingSystemCategory::Linux {
(
// If we can't compute the distro name, fallback to "Linux" as
// the os release name.
sysinfo::System::name().unwrap_or_else(|| "Linux".to_string()),
sysinfo::System::os_version(),
sysinfo::System::kernel_version(),
)
} else {
(os_category.to_string(), sysinfo::System::os_version(), None)
};
Ok(Self {
name: os_name,
version,
category: os_category,
linux_kernel_version,
browser_name: None,
browser_version: None,
})
}
#[cfg(target_family = "wasm")]
fn new() -> Result<Self, OperatingSystemInfoError> {
// To make sure the operating system names are consistent between native
// and web platforms, we try to use the display names encoded by the
// `OperatingSystemCategory` enum.
let os = match OperatingSystem::get() {
OperatingSystem::Linux => OperatingSystemCategory::Linux.to_string(),
OperatingSystem::Mac => OperatingSystemCategory::Mac.to_string(),
OperatingSystem::Windows => OperatingSystemCategory::Windows.to_string(),
OperatingSystem::Other(Some(os)) => os.to_string(),
_ => "Unknown".to_string(),
};
Ok(Self {
name: os,
version: wasm::current_os_version().map(str::to_string),
category: OperatingSystemCategory::Web,
browser_name: wasm::current_browser().map(str::to_string),
browser_version: wasm::current_browser_version().map(str::to_string),
linux_kernel_version: None,
})
}
/// Returns the current [`OperatingSystemInfo`]. If the system information was unable to be
/// computed, an `Err` is returned.
pub fn get() -> Result<&'static Self, OperatingSystemInfoError> {
let inner = OS_INFO.get_or_init(Self::new);
inner.as_ref().map_err(|error| *error)
}
/// Returns the name of the operating system. On Linux this is the name of the distribution.
/// On all other platforms it should be equivalent to `category`.
pub fn name(&self) -> &str {
&self.name
}
/// Returns the version of the operating system. On Linux this is the version of the
/// distribution, not the Linux kernel version. Returns `None` if the version could not be
/// computed for any reason.
pub fn version(&self) -> Option<&str> {
self.version.as_deref()
}
/// Returns the category of the operating system (e.g. "Linux", "macOS", or "Windows").
pub fn category(&self) -> &OperatingSystemCategory {
&self.category
}
pub fn linux_kernel_version(&self) -> Option<&str> {
self.linux_kernel_version.as_deref()
}
}
#[derive(SerializeDisplay, PartialEq)]
pub enum OperatingSystemCategory {
Linux,
Mac,
#[allow(dead_code)]
Windows,
Web,
}
impl OperatingSystemCategory {
#[cfg_attr(target_family = "wasm", allow(dead_code))]
fn new() -> Option<Self> {
if cfg!(target_os = "linux") {
Some(OperatingSystemCategory::Linux)
} else if cfg!(target_os = "macos") {
Some(OperatingSystemCategory::Mac)
} else if cfg!(target_os = "windows") {
Some(OperatingSystemCategory::Windows)
} else if cfg!(target_family = "wasm") {
Some(OperatingSystemCategory::Web)
} else {
None
}
}
}
impl Display for OperatingSystemCategory {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let str = match self {
OperatingSystemCategory::Linux => "Linux",
OperatingSystemCategory::Mac => "macOS",
OperatingSystemCategory::Windows => "Windows",
OperatingSystemCategory::Web => "Web",
};
write!(f, "{str}")
}
}
/// Error type returned when trying to compute the [`OperatingSystemInfo`].
#[derive(thiserror::Error, Debug, Clone, Copy)]
pub enum OperatingSystemInfoError {
#[error("computing the operating system information is unsupported on this platform")]
#[allow(dead_code)]
UnsupportedPlatform,
#[cfg_attr(target_family = "wasm", allow(dead_code))]
#[error("unable to compute the operating system information")]
Unknown,
}
+471
View File
@@ -0,0 +1,471 @@
//! Helper functions for retrieving base paths for storing config/data files.
//!
//! This file should not be directly exposed to or used in integration tests;
//! any paths computed using these functions should be exposed to integration
//! tests through use-case-specific helper functions.
//!
//! `_local_dir` variants of functions are for storing non-portable data, where
//! "portable" refers to the ability to copy that file to another machine.
//! Some examples of non-portable data include things that reference local
//! paths (which may not exist on a different machine), such as paths to shell
//! binaries or user-added theme files.
//!
//! TODO(vorporeal): In general, we should be returning Option<PathBuf> or
//! Result<PathBuf> when we can't compute the home directory instead of
//! returning a relative path.
use std::path::{Path, PathBuf};
use cfg_if::cfg_if;
use directories::BaseDirs;
use crate::{
channel::{Channel, ChannelState},
AppId,
};
/// The name of the directory in which to put non-global Warp Core-specific files.
///
/// This should be used, for example, as the base directory under which
/// repository workflows would be stored (in "./.warp-core/workflows").
pub const WARP_CONFIG_DIR: &str = ".galaxy-ai";
/// The legacy config directory name used by Warp before the rename to Warp Core.
/// Used for auto-migration on first launch.
pub const LEGACY_WARP_CONFIG_DIR: &str = ".warp-core";
/// The name of the folder that stores Warp execution logs and network logs.
/// This is currently only used on Windows to maintain backwards compatibility.
pub const WARP_LOGS_DIR: &str = "logs";
fn base_warp_config_dir_name() -> String {
match ChannelState::channel() {
// Preview shares the same directory as Stable for backward
// compatibility — existing users already have config in `.warp`.
Channel::Stable | Channel::Preview => WARP_CONFIG_DIR.to_owned(),
Channel::Oss => format!("{WARP_CONFIG_DIR}-oss"),
Channel::Dev => format!("{WARP_CONFIG_DIR}-dev"),
Channel::Integration => format!("{WARP_CONFIG_DIR}-integration"),
Channel::Local => format!("{WARP_CONFIG_DIR}-local"),
}
}
/// Returns the home-relative Warp config directory name for the current channel and data profile.
///
/// This preserves the historical `.warp*` directory shape while still isolating dev, local,
/// integration, oss, and optional development profiles.
pub fn warp_home_config_dir_name() -> String {
let base_dir_name = base_warp_config_dir_name();
if let Some(data_profile) = ChannelState::data_profile() {
format!("{base_dir_name}-{data_profile}")
} else {
base_dir_name
}
}
/// Returns the home-relative Warp Core config directory for the current channel and data profile.
///
/// Unlike [`data_dir`] and [`config_local_dir`] on non-macOS platforms, this intentionally keeps
/// user-facing config under a `.warp-core*` directory in the home directory instead of
/// using the platform XDG/AppData project directories.
pub fn warp_home_config_dir() -> Option<PathBuf> {
dirs::home_dir().map(|home_dir| home_dir.join(warp_home_config_dir_name()))
}
/// Returns the legacy `~/.warp*` config directory path for the current channel,
/// used to detect and migrate data from a previous Warp installation.
pub fn legacy_warp_home_config_dir() -> Option<PathBuf> {
let base = LEGACY_WARP_CONFIG_DIR;
let dir_name = match ChannelState::channel() {
Channel::Stable | Channel::Preview => base.to_owned(),
Channel::Oss => format!("{base}-oss"),
Channel::Dev => format!("{base}-dev"),
Channel::Integration => format!("{base}-integration"),
Channel::Local => format!("{base}-local"),
};
let dir_name = if let Some(data_profile) = ChannelState::data_profile() {
format!("{dir_name}-{data_profile}")
} else {
dir_name
};
dirs::home_dir().map(|home_dir| home_dir.join(dir_name))
}
/// Migrates the legacy `~/.warp*` config directory to `~/.warp-core*` if needed.
///
/// This runs once on first launch after the rename. It creates symlinks from the
/// old directory entries into the new directory, preserving access to existing
/// configuration (keybindings, themes, workflows, skills, etc.).
///
/// This is a no-op if:
/// - The new directory already exists.
/// - The old directory does not exist.
pub fn migrate_legacy_config_dir_if_needed() {
let Some(old_dir) = legacy_warp_home_config_dir() else {
return;
};
let Some(new_dir) = warp_home_config_dir() else {
return;
};
if new_dir.exists() || !old_dir.exists() {
return;
}
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
if let Err(err) = std::fs::create_dir(&new_dir) {
if err.kind() != std::io::ErrorKind::AlreadyExists {
log::warn!(
"Failed to create config directory {}: {err}",
new_dir.display()
);
return;
}
}
let entries = match std::fs::read_dir(&old_dir) {
Ok(entries) => entries,
Err(err) => {
log::warn!(
"Failed to read legacy config directory {}: {err}",
old_dir.display()
);
return;
}
};
let mut migrated = 0u32;
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str == ".DS_Store" || name_str.starts_with("._") {
continue;
}
let target = old_dir.join(&name);
let link = new_dir.join(&name);
if let Err(err) = symlink(&target, &link) {
log::warn!(
"Failed to symlink {} -> {}: {err}",
link.display(),
target.display()
);
} else {
migrated += 1;
}
}
log::info!(
"Migrated legacy config directory: created {migrated} symlinks in {}",
new_dir.display()
);
}
#[cfg(windows)]
{
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let ty = entry.file_type()?;
let dest = dst.join(entry.file_name());
if ty.is_dir() {
copy_dir_recursive(&entry.path(), &dest)?;
} else {
std::fs::copy(entry.path(), dest)?;
}
}
Ok(())
}
if let Err(err) = copy_dir_recursive(&old_dir, &new_dir) {
log::warn!(
"Failed to copy legacy config directory {} to {}: {err}",
old_dir.display(),
new_dir.display()
);
} else {
log::info!(
"Migrated legacy config directory {} to {}",
old_dir.display(),
new_dir.display()
);
}
}
}
pub fn warp_home_skills_dir() -> Option<PathBuf> {
warp_home_config_dir().map(|warp_config_dir| warp_config_dir.join("skills"))
}
pub fn warp_home_mcp_config_file_path() -> Option<PathBuf> {
warp_home_config_dir().map(|warp_config_dir| warp_config_dir.join(".mcp.json"))
}
/// Returns the macOS config directory name for the current channel.
///
/// Stable uses `.warp-core`, while other channels include a channel suffix
/// (e.g., `.warp-core-dev`, `.warp-core-local`).
///
/// These suffixes are persisted on disk as directory names and must not be
/// changed once established, or existing user data will be orphaned.
#[cfg(target_os = "macos")]
fn macos_config_dir_name() -> String {
match ChannelState::channel() {
Channel::Stable => WARP_CONFIG_DIR.to_owned(),
Channel::Preview => format!("{WARP_CONFIG_DIR}-preview"),
Channel::Oss => format!("{WARP_CONFIG_DIR}-oss"),
Channel::Dev => format!("{WARP_CONFIG_DIR}-dev"),
Channel::Integration => format!("{WARP_CONFIG_DIR}-integration"),
Channel::Local => format!("{WARP_CONFIG_DIR}-local"),
}
}
/// Returns the path to the directory where portable user data should be
/// stored.
///
/// This is the appropriate home for things like custom themes and workflows.
pub fn data_dir() -> PathBuf {
cfg_if! {
if #[cfg(target_os = "macos")] {
// TODO(vorporeal): We should do something better than return a
// relative path.
dirs::home_dir().unwrap_or_default().join(macos_config_dir_name())
} else {
project_dirs().map(|dirs| dirs.data_dir().to_owned()).unwrap_or_default()
}
}
}
/// Returns the path to the directory where non-portable configuration files
/// should be stored.
pub fn config_local_dir() -> PathBuf {
cfg_if! {
if #[cfg(target_os = "macos")] {
// TODO(vorporeal): We should do something better than return a
// relative path.
dirs::home_dir().unwrap_or_default().join(macos_config_dir_name())
} else {
project_dirs()
.map(|dirs| dirs.config_local_dir().to_owned())
.unwrap_or_default()
}
}
}
/// Returns the base directory for general config files. Useful for accessing the config files for
/// other programs.
pub fn base_config_dir() -> PathBuf {
BaseDirs::new()
.map(|dirs| dirs.config_dir().to_owned())
.unwrap_or_default()
}
/// Returns the path to the directory where non-portable application state data
/// should be stored.
///
/// This is the appropriate home for files like our sqlite database, which
/// contains durable but non-critical and non-portable data like what windows
/// the user had open and cached state of known Warp Drive objects.
pub fn state_dir() -> PathBuf {
let Some(project_dirs) = project_dirs() else {
return PathBuf::new();
};
// For platforms that don't have a notion of a "state" directory (e.g.:
// macOS and Windows), fall back to using the data directory.
project_dirs
.state_dir()
.unwrap_or_else(|| project_dirs.data_local_dir())
.to_owned()
}
/// Returns the path to the secure directory for non-portable application state data.
///
/// Prefer this over [`state_dir`] where possible.
///
/// On macOS, this will use the App Group container directory if available.
pub fn secure_state_dir() -> Option<PathBuf> {
// Do not use the secure state directory in integration tests, which have a temporary home directory instead.
if ChannelState::channel() == Channel::Integration {
return None;
}
#[cfg(target_os = "macos")]
if let Some(app_group_root) = app_group_container_path() {
// The macOS project_path is the bundle ID (i.e. `dev.warp.Warp-Stable`).
let project_dirs = project_dirs()?;
return Some(
app_group_root
.join("Library/Application Support")
.join(project_dirs.project_path()),
);
}
None
}
/// Returns the path to the directory containing the user's custom themes.
pub fn themes_dir() -> PathBuf {
data_dir().join("themes")
}
/// Returns the path to the directory where files can be stored for caching
/// purposes.
///
/// This is a good place to store things like user profile pictures, which
/// we don't want to fetch on every launch of the app but can be safely
/// deleted by the OS.
pub fn cache_dir() -> PathBuf {
let Some(project_dirs) = project_dirs() else {
return PathBuf::new();
};
cfg_if! {
if #[cfg(target_os = "macos")] {
// TODO(vorporeal): Given that this is just cache data; do we want
// change the path we use on macOS?
project_dirs.data_dir().to_owned()
} else {
project_dirs.cache_dir().to_owned()
}
}
}
/// Returns a display-ready version of the path that is formatted in a
/// home-dir-relative manner, if appropriate.
pub fn home_relative_path(path: &Path) -> String {
#[cfg(unix)]
if let Some(base_dirs) = directories::BaseDirs::new() {
if let Ok(relative_path) = path.strip_prefix(base_dirs.home_dir()) {
return format!("~/{}", relative_path.display());
}
};
path.display().to_string()
}
/// Returns a [`directories::ProjectDirs`] configured based on the current app ID
/// and the current data profile, if one is set.
///
/// This returns [`None`] if the user's home directory could not be determined.
fn project_dirs() -> Option<directories::ProjectDirs> {
project_dirs_for_app_id(
ChannelState::app_id(),
ChannelState::data_profile().as_deref(),
)
}
/// Returns a [`directories::ProjectDirs`] configured based on the given app ID
/// and data profile.
///
/// This returns [`None`] if the user's home directory could not be determined.
fn project_dirs_for_app_id(
app_id: AppId,
data_profile: Option<&str>,
) -> Option<directories::ProjectDirs> {
cfg_if::cfg_if! {
if #[cfg(target_os = "linux")] {
// Adjust the base application name so that we end up with
// directories like "warp-terminal" and "warp-terminal-dev", to
// match our Linux package name.
let base_app_name = match app_id.application_name() {
"Warp" => "Warp-Terminal".to_owned(),
"WarpOss" => "Warp-Oss".to_owned(),
other if other.starts_with("Warp") => other.replace("Warp", "Warp-Terminal-"),
_ => app_id.application_name().to_owned(),
};
} else {
let base_app_name = app_id.application_name().to_owned();
}
}
let app_name = if let Some(data_profile) = data_profile {
format!("{base_app_name}-{data_profile}")
} else {
base_app_name
};
directories::ProjectDirs::from(app_id.qualifier(), app_id.organization(), &app_name)
}
/// Returns the path to the app's secure group container on macOS.
///
/// Returns `None` if the container URL cannot be resolved or converted.
///
/// See:
/// * [Configuring app groups](https://developer.apple.com/documentation/Xcode/configuring-app-groups)
/// * The [App Groups entitlement](https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.security.application-groups?language=objc)
/// * [`containerURLForSecurityApplicationGroupIdentifier`](https://developer.apple.com/documentation/foundation/filemanager/containerurl(forsecurityapplicationgroupidentifier:)?language=objc)
#[cfg(target_os = "macos")]
pub fn app_group_container_path() -> Option<PathBuf> {
use std::sync::LazyLock;
static CONTAINER_PATH: LazyLock<Option<PathBuf>> = LazyLock::new(|| {
use objc2_foundation::{NSFileManager, NSString};
let fm = NSFileManager::defaultManager();
// Keep in sync with Entitlements.plist
let group_id = format!("{}.dev.warpcore", crate::macos::APPLE_TEAM_ID);
let group_id = NSString::from_str(&group_id);
// containerURLForSecurityApplicationGroupIdentifier always returns a value on macOS (unlike iOS).
// We have to double-check that the path points to a directory we can actually use. In addition to
// macOS returning a path that may not exist, processes may list the container directory without
// having permissions to read to or write from it.
if let Some(url) = fm.containerURLForSecurityApplicationGroupIdentifier(&group_id) {
if let Some(ns_path) = url.path() {
let path = PathBuf::from(ns_path.to_string());
if tempfile::tempfile_in(&path).is_ok() {
return Some(path);
}
}
}
None
});
LazyLock::force(&CONTAINER_PATH).clone()
}
/// Returns the path to resources included in the Warp distribution.
///
/// Unlike [`galaxyui::AssetProvider`] assets, which are generally embedded in the binary, these are
/// stored on the filesystem alongside the rest of Warp.
///
/// ## macOS
/// The resources directory is `$APP_DIR/Contents/Resources` (e.g. `/Applications/Warp.app/Contents/Resources`).
///
/// ## Linux
/// The resources directory is `$INSTALL_DIR/resources`, where `$INSTALL_DIR` depends on the
/// specific package manager. For example, on Ubuntu this might be `/opt/warpdotdev/warp-terminal/resources`.
///
/// ## Windows
/// The resources directory is `$INSTALL_DIR/resources`, where `$INSTALL_DIR` is the directory
/// containing the Warp executable (e.g. `C:\Program Files\WarpDev\resources`).
pub fn bundled_resources_dir() -> Option<PathBuf> {
cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] {
crate::macos::get_bundle_path().ok()
.map(|bundle_path| {
PathBuf::from(bundle_path)
.join("Contents")
.join("Resources")
})
} else if #[cfg(target_os = "linux")] {
std::env::current_exe()
.ok()
.and_then(|executable| std::fs::canonicalize(executable).ok())
.and_then(|executable| executable.parent().map(|parent| parent.join("resources")))
} else if #[cfg(target_os = "windows")] {
std::env::current_exe()
.ok()
.and_then(|executable| std::fs::canonicalize(executable).ok())
.and_then(|executable| executable.parent().map(|parent| parent.join("resources")))
} else {
None
}
}
}
#[cfg(all(test, feature = "local_fs"))]
#[path = "paths_tests.rs"]
mod tests;
+148
View File
@@ -0,0 +1,148 @@
use dirs::home_dir;
use super::*;
#[test]
fn test_data_dir_path() {
let home_dir = home_dir().expect("Should be able to compute home directory");
// ChannelState, by default, is configured for Channel::Oss.
cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] {
assert_eq!(data_dir(), home_dir.join(".warp-core-oss"));
} else if #[cfg(target_os = "linux")] {
assert_eq!(data_dir(), home_dir.join(".local/share/warp-oss"));
} else if #[cfg(windows)] {
assert_eq!(data_dir(), home_dir.join("AppData\\Roaming\\warp\\WarpOss\\data"));
} else {
unimplemented!("Need to update tests for current platform!");
}
}
}
#[test]
fn test_config_local_dir_path() {
let home_dir = home_dir().expect("Should be able to compute home directory");
// ChannelState, by default, is configured for Channel::Oss.
cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] {
assert_eq!(config_local_dir(), home_dir.join(".warp-core-oss"));
} else if #[cfg(target_os = "linux")] {
assert_eq!(config_local_dir(), home_dir.join(".config/warp-oss"));
} else if #[cfg(windows)] {
assert_eq!(config_local_dir(), home_dir.join("AppData\\Local\\warp\\WarpOss\\config"));
} else {
unimplemented!("Need to update tests for current platform!");
}
}
}
#[test]
fn test_warp_home_config_dir_path() {
let home_dir = home_dir().expect("Should be able to compute home directory");
let expected_dir_name = match ChannelState::data_profile() {
Some(data_profile) => format!(".warp-core-oss-{data_profile}"),
None => ".warp-core-oss".to_string(),
};
assert_eq!(
warp_home_config_dir(),
Some(home_dir.join(expected_dir_name))
);
}
#[test]
fn test_warp_home_skills_and_mcp_paths() {
let Some(config_dir) = warp_home_config_dir() else {
panic!("Should be able to compute Warp home config directory");
};
assert_eq!(warp_home_skills_dir(), Some(config_dir.join("skills")));
assert_eq!(
warp_home_mcp_config_file_path(),
Some(config_dir.join(".mcp.json"))
);
}
#[test]
fn test_cache_dir_path() {
let home_dir = home_dir().expect("Should be able to compute home directory");
// ChannelState, by default, is configured for Channel::Oss.
cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] {
assert_eq!(cache_dir(), home_dir.join("Library/Application Support/dev.warp.WarpOss"));
} else if #[cfg(target_os = "linux")] {
assert_eq!(cache_dir(), home_dir.join(".cache/warp-oss"));
} else if #[cfg(windows)] {
assert_eq!(cache_dir(), home_dir.join("AppData\\Local\\warp\\WarpOss\\cache"));
} else {
unimplemented!("Need to update tests for current platform!");
}
}
}
#[test]
fn test_state_dir_path() {
let home_dir = home_dir().expect("Should be able to compute home directory");
cfg_if::cfg_if! {
// ChannelState, by default, is configured for Channel::Oss.
if #[cfg(target_os = "macos")] {
assert_eq!(state_dir(), home_dir.join("Library/Application Support/dev.warp.WarpOss"));
} else if #[cfg(target_os = "linux")] {
assert_eq!(state_dir(), home_dir.join(".local/state/warp-oss"));
} else if #[cfg(windows)] {
assert_eq!(state_dir(), home_dir.join("AppData\\Local\\warp\\WarpOss\\data"));
} else {
unimplemented!("Need to update tests for current platform!");
}
}
}
#[test]
fn test_project_path_for_warp_app_id() {
let project_dirs = project_dirs_for_app_id(AppId::new("dev", "warp", "Warp"), None)
.expect("should be able to compute project dirs");
cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] {
assert_eq!(project_dirs.project_path(), "dev.warp.Warp");
} else if #[cfg(target_os = "linux")] {
assert_eq!(project_dirs.project_path(), "warp-terminal");
} else if #[cfg(windows)] {
assert_eq!(project_dirs.project_path(), "warp\\Warp");
} else {
unimplemented!("Need to update tests for current platform!");
}
}
}
#[test]
fn test_project_path_for_warp_dev_app_id() {
let project_dirs = project_dirs_for_app_id(AppId::new("dev", "warp", "WarpDev"), None)
.expect("should be able to compute project dirs");
cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] {
assert_eq!(project_dirs.project_path(), "dev.warp.WarpDev");
} else if #[cfg(target_os = "linux")] {
assert_eq!(project_dirs.project_path(), "warp-terminal-dev");
} else if #[cfg(windows)] {
assert_eq!(project_dirs.project_path(), "warp\\WarpDev");
} else {
unimplemented!("Need to update tests for current platform!");
}
}
}
#[test]
fn test_project_path_for_oss_app_id() {
let project_dirs = project_dirs_for_app_id(AppId::new("dev", "warp", "WarpOss"), None)
.expect("should be able to compute project dirs");
cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] {
assert_eq!(project_dirs.project_path(), "dev.warp.WarpOss");
} else if #[cfg(target_os = "linux")] {
assert_eq!(project_dirs.project_path(), "warp-oss");
} else if #[cfg(windows)] {
assert_eq!(project_dirs.project_path(), "warp\\WarpOss");
} else {
unimplemented!("Need to update tests for current platform!");
}
}
}
+22
View File
@@ -0,0 +1,22 @@
use line_ending::LineEnding;
#[derive(Debug, Clone)]
pub enum SessionPlatform {
MSYS2,
WSL,
Native,
/// A shell running inside a Linux Docker sandbox container.
DockerSandbox,
}
impl SessionPlatform {
#[allow(clippy::disallowed_methods)]
pub fn default_line_ending(&self) -> LineEnding {
match self {
SessionPlatform::MSYS2 | SessionPlatform::WSL | SessionPlatform::DockerSandbox => {
LineEnding::LF
}
SessionPlatform::Native => LineEnding::from_current_platform(),
}
}
}
+99
View File
@@ -0,0 +1,99 @@
/// Safe Logger for sensitive info messages
///
/// Includes two log messages, labeled `safe:` and `full:`, the safe one will be sent in any
/// release channel, while the full log will only be used for local development, to aid in
/// debugging
#[macro_export]
macro_rules! safe_info {
(safe: ($($safe_arg:tt)+), full: ($($full_arg:tt)+)) => (
if $crate::channel::ChannelState::channel().is_dogfood() {
log::info!($($full_arg)+)
} else {
log::info!($($safe_arg)+)
}
)
}
/// Safe Logger for sensitive warning messages
///
/// Includes two log messages, labeled `safe:` and `full:`, the safe one will be sent in any
/// release channel, while the full log will only be used for local development, to aid in
/// debugging
#[macro_export]
macro_rules! safe_warn {
(safe: ($($safe_arg:tt)+), full: ($($full_arg:tt)+)) => (
if $crate::channel::ChannelState::channel().is_dogfood() {
log::warn!($($full_arg)+)
} else {
log::warn!($($safe_arg)+)
}
)
}
/// Safe Logger for sensitive error messages
///
/// Includes two log messages, labeled `safe:` and `full:`, the safe one will be sent in any
/// release channel, while the full log will only be used for local development, to aid in
/// debugging
#[macro_export]
macro_rules! safe_error {
(safe: ($($safe_arg:tt)+), full: ($($full_arg:tt)+)) => ({
if $crate::channel::ChannelState::channel().is_dogfood() {
log::error!($($full_arg)+)
} else {
log::error!($($safe_arg)+)
}
})
}
/// Safe Logger for sensitive debug messages. Debug messages are generally not
/// logged at all in release channels, but could be enabled if a user sets
/// the `RUST_LOG` environment variable.
///
/// Includes two log messages, labeled `safe:` and `full:`, the safe one will be sent in any
/// release channel, while the full log will only be used for local development, to aid in
/// debugging.
#[macro_export]
macro_rules! safe_debug {
(safe: ($($safe_arg:tt)+), full: ($($full_arg:tt)+)) => (
if $crate::channel::ChannelState::channel().is_dogfood() {
log::debug!($($full_arg)+)
} else {
log::debug!($($safe_arg)+)
}
)
}
/// Safe `anyhow::Error` builder for sensitive error messages.
///
/// Includes two error messages, labeled `safe:` and `full:`, the safe one will be sent in any
/// release channel, while the full log will only be used for local development, to aid in
/// debugging.
#[macro_export]
macro_rules! safe_anyhow {
(safe: ($($safe_arg:tt)+), full: ($($full_arg:tt)+)) => (
if $crate::channel::ChannelState::channel().is_dogfood() {
anyhow::anyhow!($($full_arg)+)
} else {
anyhow::anyhow!($($safe_arg)+)
}
)
}
/// Safe `eprint!` for sensitive error messages.
///
/// Includes two error messages, labeled `safe:` and `full:`, the safe one will be sent in any
/// release channel, while the full log will only be used for local development, to aid in
/// debugging.
/// The safe message will only be printed if it is not empty.
/// This macro is mostly useful for the SDK, where access to the debug log is limited.
#[macro_export]
macro_rules! safe_eprintln {
(safe: ($($safe_arg:tt)*), full: ($($full_arg:tt)+)) => (
if $crate::channel::ChannelState::channel().is_dogfood() {
eprintln!($($full_arg)+)
} else if !stringify!($($safe_arg)*).trim().is_empty() {
eprintln!($($safe_arg)*)
}
)
}
@@ -0,0 +1,223 @@
use std::{collections::HashSet, ops::Range};
use lazy_static::lazy_static;
use regex::Regex;
use settings::{macros::define_settings_group, Setting, SupportedPlatforms, SyncToCloud};
use string_offset::ByteOffset;
use galaxyui::elements::SmartSelectFn;
use galaxyui::text::{
word_boundaries::WordBoundariesPolicy,
words::{is_default_word_boundary, DEFAULT_WORD_BOUNDARY_CHARS},
};
/// Upper limit for how many characters in either direction we'll search for patterns. Need to
/// limit this to avoid running regex on absurdly long words
pub const SMART_SELECT_MATCH_WINDOW_LIMIT: u32 = 1000;
pub const DEFAULT_WORD_CHAR_ALLOWLIST: &str = "-.~/\\";
lazy_static! {
static ref DEFAULT_WORD_BOUNDARY_CHAR_SET: HashSet<char> = HashSet::from(DEFAULT_WORD_BOUNDARY_CHARS);
/// These regexes are the specifications for all recognized smart-select objects, sorted in
/// precedence-order (highest precedence first). The precedence is determined by specificity.
/// It is possible for multiple patterns to apply to the same double-click. In that case, we
/// want the more "specific" pattern to take precedence. For example, double-clicking on the
/// path portion of a URL will match the URL regex, filepath regex, and identifier regex. In
/// order to make sure the whole URL actually gets selected, we make the URL highest
/// precedence.
static ref REGEXES: [Regex; 5] = [
// URL pattern, any scheme
// https://en.wikipedia.org/wiki/Uniform_Resource_Identifier#Syntax
// Note: This regex is not 100% rigorously correct for all valid URLs. For example, the
// "web+" scheme is not recognized here. Punctuation characters in the username/password
// component (though use of those in URLs in cleartext is discouraged). The "//" is
// actually optional. Overall we don't need perfect recall for recognizing all URLs.
// These compromises were made for precision.
// [a-z][a-z\d.-]* - scheme e.g. https, ssh
// :// - literal
// ([\w.-]+(:[\w.-]+)?@)? - maybe a username/password e.g. andy:secret@
// ([\w-]+((\.[\w-]+)+)|(\[[:\da-f]+\])) - host, either a domain name (google.com), or
// an IPv4 or IPv6 address
// (:\d{1,5})? - maybe a port number
// ([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-])? - maybe query string and/or fragment. this
// syntax isn't actuall well-defined in general for URLs
Regex::new(
r"(?i)[a-z][a-z\d.-]*://(([\w.-]+(:[\w.-]+)?@)?([\w-]+((\.[\w-]+)+)|(\[[:\da-f]+\]))(:\d{1,5})?)?([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-])?"
)
.expect("URL regex malformed"),
// Email pattern
// https://en.wikipedia.org/wiki/Email_address#Syntax
// Note: This regex is not 100% rigorously correct either. The "local-part" of the address
// cannot contain consecutive dots ".." but this regex does not enforce that. It also does
// not check the UTF-8 byte range for the characters.
// [\w\d!#$%&'*+-/=?^`{|}~.]+ - "local-part" of the email address
// @ - literal "at sign"
// [a-z\d-]+\.[a-z\d.-]+[a-z\d-] - domain with arbitrary subdomains (foo@a.b.c.com)
Regex::new(
r"(?i)[\w\d!#$%&'*+-/=?^`{|}~.]+@[a-z\d-]+\.[a-z\d.-]+[a-z\d-]"
)
.expect("email regex malformed"),
// Float in scientific notation pattern, e.g. 6.02e+23
// -? - may have negative sign
// \d - mantissa, integer portion
// (\.\d+)? - mantissa, may have non-integer portion
// e - literal "e"
// [+-]? - may have sign
// \d+ - exponent part
Regex::new(
r"(?i)-?\d(\.\d+)?(e[+-]?\d+)"
)
.expect("scientific notation regex malformed"),
// Filepath pattern
// Note: Filepaths may contain ANY punctuation characters or whitespace, but we aren't
// attempting to match that all.
// (~|\b[a-z]:|[\w.*-]+)? - On *nix, may start with tilde. On windows, may start with a
// drive letter. Or the prefix might be totally ordinary.
// [/\\] - A slash of some kind is required.
// [/\\\w.*-]* - All stuff after the slash, any number of word-chars, (back)slashes,
// dots, asterisks, and dashes
Regex::new(
r"(?i)(~|\b[a-z]:|[\w.*-]+)?[/\\][/\\\w.*-]*"
)
.expect("filepath regex malformed"),
// Identifier pattern
// This is the least rigorously-defined pattern, so it's last. It is a common set of
// characters people use in names for things. Underscores are already considered part of
// words universally, but hyphens and dots are also commonly-used separators in names.
// Other than filepaths, hyphenated names were the most commonly-requested entities to be
// double-clicked. This pattern also happens to recognize floats and IP addresses
// \w+ - Ordinary word-chars
// ([.-]\w+)* - Any dot or dash separators must be followed by more word characters.
// Cannot have multiple consecutive separators.
Regex::new(
r"(?i)\w+([.-]\w+)*"
)
.expect("identifier regex"),
];
}
define_settings_group!(SemanticSelection, settings: [
smart_select_enabled: SmartSelectEnabled {
type: bool,
default: true,
supported_platforms: SupportedPlatforms::ALL,
sync_to_cloud: SyncToCloud::Never,
private: false,
storage_key: "SmartSelect",
toml_path: "terminal.smart_select.enabled",
description: "Whether double-click smart selection is enabled for URLs, emails, file paths, and identifiers.",
},
word_char_allowlist: WordCharAllowlist {
type: String,
default: DEFAULT_WORD_CHAR_ALLOWLIST.to_owned(),
supported_platforms: SupportedPlatforms::ALL,
sync_to_cloud: SyncToCloud::Never,
private: false,
storage_key: "WordCharAllowlist",
toml_path: "terminal.smart_select.word_char_allowlist",
description: "Characters that are considered part of a word for double-click selection when smart select is disabled.",
},
]);
impl SemanticSelection {
#[cfg(any(test, feature = "test-util"))]
pub fn mock(smart_select_enabled: bool, word_char_allowlist: impl Into<String>) -> Self {
Self {
smart_select_enabled: SmartSelectEnabled::new(Some(smart_select_enabled)),
word_char_allowlist: WordCharAllowlist::new(Some(word_char_allowlist.into())),
}
}
pub fn smart_select_enabled(&self) -> bool {
*self.smart_select_enabled.value()
}
pub fn word_char_allowlist_changed_from_default(&self) -> bool {
*self.word_char_allowlist.value() != DEFAULT_WORD_CHAR_ALLOWLIST
}
pub fn word_char_allowlist_string(&self) -> String {
self.word_char_allowlist.value().clone()
}
fn to_char_set(char_list: &str) -> HashSet<char> {
HashSet::from_iter(char_list.chars().filter(|c| !c.is_whitespace()))
}
fn word_char_allowlist_set(&self) -> HashSet<char> {
Self::to_char_set(self.word_char_allowlist.value())
}
/// The core logic for smart-select. This takes the extracted string window and runs the known
/// regexes on it in precedence-order. It needs the byte offset of the cursor in order to
/// guarantee that the range of the matching pattern actually contains the cursor, otherwise we
/// might match something in the window which doesn't overlap with the cursor.
pub fn smart_search(
&self,
content: &str,
click_offset: ByteOffset,
) -> Option<Range<ByteOffset>> {
if !self.smart_select_enabled() {
return None;
}
Self::smart_select(content, click_offset)
}
pub fn smart_select_fn(&self) -> Option<SmartSelectFn> {
if !self.smart_select_enabled() {
return None;
}
Some(Self::smart_select)
}
fn smart_select(content: &str, click_offset: ByteOffset) -> Option<Range<ByteOffset>> {
for regex in REGEXES.iter() {
for hit in regex.find_iter(content) {
if hit.range().contains(&click_offset.as_usize()) {
return Some(
ByteOffset::from(hit.range().start)..ByteOffset::from(hit.range().end),
);
}
}
}
None
}
/// This function determines if a particular character is word-breaking depending on the
/// semantic selection settings. Used specifically by the GridHandler.
pub fn is_word_boundary_char(&self, c: char) -> bool {
if !self.smart_select_enabled() && self.word_char_allowlist_set().contains(&c) {
return false;
}
is_default_word_boundary(c)
}
/// This function fulfills the same purpose as Self::is_word_boundary_char, but in the way
/// specific to the Editor Buffer. That data structure has a custom iterator which is
/// configurable via a WordBoundariesPolicy enum. Ideally, WordBoundariesPolicy would hold a
/// reference to this model so it could call Self::is_word_boundary_char and share the exact
/// same logic with the GridHandler. However, in order to do that, the reference to this
/// model would need to live for 'static which is not feasible.
pub fn word_boundary_policy(&self) -> WordBoundariesPolicy {
if self.smart_select_enabled() {
WordBoundariesPolicy::Default
} else {
WordBoundariesPolicy::Custom(
DEFAULT_WORD_BOUNDARY_CHAR_SET
.difference(&self.word_char_allowlist_set())
.copied()
.collect(),
)
}
}
}
#[cfg(test)]
#[cfg(feature = "test-util")]
#[path = "mod_test.rs"]
mod tests;
@@ -0,0 +1,117 @@
use super::*;
#[test]
fn test_basic_url_selection() {
let sel = SemanticSelection::mock(true, "");
assert_eq!(
sel.smart_search("http://stackoverflow.com foo", 5.into()),
Some(ByteOffset::range(0..24))
);
assert_eq!(
sel.smart_search("click here:http://stackoverflow.com", 15.into()),
Some(ByteOffset::range(11..35))
);
assert_eq!(
sel.smart_search("word here https://andy:foo@stackoverflow.com/questions/28265036/how?foo=bar&baz=food#thing-here other/stuff", 35.into()),
Some(ByteOffset::range(10..95))
);
}
#[test]
fn test_other_url_selection() {
let sel = SemanticSelection::mock(true, "");
assert_eq!(
sel.smart_search("ssh://git@github.com:acarl005/dotfiles.git", 0.into()),
Some(ByteOffset::range(0..42))
);
assert_eq!(
sel.smart_search("data hdfs://hadoopNS/data/users.csv there", 20.into()),
Some(ByteOffset::range(5..35))
);
assert_eq!(
sel.smart_search("send files here&ftp://thing.网站/file please", 30.into()),
Some(ByteOffset::range(16..39))
);
assert_eq!(
sel.smart_search("http://[2001:db8::1]:80", 10.into()),
Some(ByteOffset::range(0..23))
);
}
#[test]
fn test_email_selection() {
let sel = SemanticSelection::mock(true, "");
assert_eq!(
sel.smart_search(
"mail to: acarl005@g.ucla.edu andy+1.hello@warp.dev",
15.into()
),
Some(ByteOffset::range(9..28))
);
assert_eq!(
sel.smart_search(
"mail to: acarl005@g.ucla.edu andy+1.hello@warp.dev",
33.into()
),
Some(ByteOffset::range(29..50))
);
}
#[test]
fn test_numerical_selection() {
let sel = SemanticSelection::mock(true, "");
assert_eq!(
sel.smart_search("6.02e-23 -8.1e1 3.1415 8.1e+10-3.1415", 2.into()),
Some(ByteOffset::range(0..8))
);
assert_eq!(
sel.smart_search("6.02e-23 -8.1e1 3.1415 8.1e+10-3.1415", 11.into()),
Some(ByteOffset::range(9..15))
);
assert_eq!(
sel.smart_search("6.02e-23 -8.1e1 3.1415 8.1e+10-3.1415", 18.into()),
Some(ByteOffset::range(16..22))
);
assert_eq!(
sel.smart_search("6.02e-23 -8.1e1 3.1415 8.1e+10-3.1415", 27.into()),
Some(ByteOffset::range(23..30))
);
}
#[test]
fn test_filepath_selection() {
let sel = SemanticSelection::mock(true, "");
assert_eq!(
sel.smart_search("~/.config/nvim/init.lua C:\\system\\file\\path", 2.into()),
Some(ByteOffset::range(0..23))
);
assert_eq!(
sel.smart_search("~/.config/nvim/init.lua C:\\system\\file\\path", 30.into()),
Some(ByteOffset::range(24..43))
);
assert_eq!(
sel.smart_search("scp ./foo.txt andy@ubuntu:/etc/foo.txt", 7.into()),
Some(ByteOffset::range(4..13))
);
assert_eq!(
sel.smart_search("scp ./foo.txt andy@ubuntu:/etc/foo.txt", 28.into()),
Some(ByteOffset::range(26..38))
);
}
#[test]
fn test_identifier_selection() {
let sel = SemanticSelection::mock(true, "");
assert_eq!(
sel.smart_search("192.168.0.1:3000 api-service-pod foo-bar--baz", 6.into()),
Some(ByteOffset::range(0..11))
);
assert_eq!(
sel.smart_search("192.168.0.1:3000 api-service-pod foo-bar--baz", 30.into()),
Some(ByteOffset::range(17..32))
);
assert_eq!(
sel.smart_search("192.168.0.1:3000 api-service-pod foo-bar--baz", 33.into()),
Some(ByteOffset::range(33..40))
);
}
+27
View File
@@ -0,0 +1,27 @@
use serde::{Deserialize, Serialize};
/// Unique identifier for a terminal session.
///
/// Each bootstrapped subshell (including SSH sessions) gets its own `SessionId`.
/// This type is defined in `galaxy_core` so that lower-level crates (e.g. `repo_metadata`)
/// can reference it without depending on the `app` crate.
#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, Hash, PartialEq, Serialize)]
pub struct SessionId(u64);
impl SessionId {
pub fn as_u64(&self) -> u64 {
self.0
}
}
impl From<u64> for SessionId {
fn from(id: u64) -> Self {
Self(id)
}
}
impl From<SessionId> for u64 {
fn from(session_id: SessionId) -> Self {
session_id.as_u64()
}
}
+432
View File
@@ -0,0 +1,432 @@
use async_broadcast::{InactiveReceiver, Sender as BroadcastSender};
use futures::channel::mpsc::{self, Receiver as MpscReceiver, Sender as MpscSender};
use futures::channel::oneshot::{self, Receiver, Sender};
use futures::future::{AbortHandle, Abortable};
use futures::StreamExt;
use instant::Instant;
use std::collections::HashMap;
use std::future::Future;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use galaxyui::r#async::executor::Background;
use anyhow::Result;
use galaxyui::{r#async::Timer, Entity, RetryOption, SingletonEntity};
const DEFAULT_BUFFER_SIZE: usize = 1024;
const DEFAULT_SYNC_RETRY_STRATEGY: RetryOption = RetryOption::exponential(
Duration::from_millis(500), /* initial interval */
2.0, /* exponential factor */
3, /* max retry count */
)
.with_jitter(0.2 /* max_jitter_percentage */);
/// An opaque identifier for a task in the sync queue.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct TaskId(u64);
impl TaskId {
/// Constructs a new globally-unique task ID.
#[allow(clippy::new_without_default)]
fn new() -> TaskId {
static NEXT_ID: AtomicU64 = AtomicU64::new(0);
let raw = NEXT_ID.fetch_add(1, Ordering::Relaxed);
TaskId(raw)
}
}
/// Trait for errors that can be classified as transient.
pub trait IsTransientError {
fn is_transient(&self) -> bool;
}
/// Trait for any task that can be enqueued in the sync queue.
pub trait SyncQueueTaskTrait: Send + 'static {
/// Error type for the task (if it fails). It needs to derive IsTransientError
/// to decide the retry logic.
type Error: std::error::Error + Send + Sync + IsTransientError + 'static;
/// Result type for the task (if it succeeds).
type Result: Send + Sync;
/// The future should return a result of Self::Result or Self::Error. Note that
/// we can only implement Send on non-wasm platforms.
#[cfg(not(target_arch = "wasm32"))]
type Fut: Future<Output = Result<Self::Result, Self::Error>> + Send;
#[cfg(target_arch = "wasm32")]
type Fut: Future<Output = Result<Self::Result, Self::Error>>;
/// Implementation for running the task.
fn run(&mut self) -> Self::Fut;
}
/// The operational mode of a [`SyncQueue`], specified at construction time.
enum SyncQueueMode<T: SyncQueueTaskTrait> {
/// Each caller receives results via a per-task oneshot channel
/// (returned by [`SyncQueue::enqueue_with_result`]).
PerTask,
/// Results are broadcast to all subscribers via an
/// [`async_broadcast`] channel (obtained from [`SyncQueue::subscribe`]).
Streaming {
/// Keeps the broadcast channel alive even when no active receivers exist.
/// Without this, dropping the initial receiver from `async_broadcast::broadcast()`
/// would permanently close the channel. New subscribers are created via
/// [`InactiveReceiver::activate_cloned`].
_keepalive: InactiveReceiver<BroadcastResult<T>>,
},
}
/// The result type broadcast in streaming mode.
pub type BroadcastResult<T> =
Result<Arc<<T as SyncQueueTaskTrait>::Result>, Arc<<T as SyncQueueTaskTrait>::Error>>;
/// Broadcast receiver for streaming mode results.
pub type BroadcastReceiver<T> = async_broadcast::Receiver<BroadcastResult<T>>;
/// A queued task, with metadata and retry options.
struct QueuedTask<T: SyncQueueTaskTrait> {
task: T,
retry_options: RetryOption,
result_sender: Option<Sender<Result<T::Result, T::Error>>>,
/// Context of the task used in logging / telemetry.
context: String,
}
/// Configuration for rate limiting in the sync queue.
#[derive(Clone)]
struct RateLimitConfig {
max_requests_per_minute: u32,
tokens: Arc<Mutex<f64>>,
last_refill: Arc<Mutex<Instant>>,
}
impl RateLimitConfig {
fn new(max_requests_per_minute: u32) -> Self {
Self {
max_requests_per_minute,
tokens: Arc::new(Mutex::new(max_requests_per_minute as f64)),
last_refill: Arc::new(Mutex::new(Instant::now())),
}
}
async fn wait_for_token(&self) {
loop {
{
let now = Instant::now();
let mut tokens = self.tokens.lock().unwrap();
let mut last_refill = self.last_refill.lock().unwrap();
// Calculate tokens to add based on time elapsed
let elapsed = now.duration_since(*last_refill);
let tokens_to_add =
(elapsed.as_secs_f64() / 60.0) * self.max_requests_per_minute as f64;
// Refill tokens (capped at max_rpm)
*tokens = (*tokens + tokens_to_add).min(self.max_requests_per_minute as f64);
*last_refill = now;
// Try to consume a token
if *tokens >= 1.0 {
*tokens -= 1.0;
return; // Token consumed, can proceed
}
}
// No tokens available, wait a bit before checking again
// Wait time is calculated to ensure we don't busy-wait
let wait_time = Duration::from_millis(60_000 / self.max_requests_per_minute as u64);
Timer::after(wait_time).await;
}
}
}
/// The global sync queue singleton, generic over the task type.
pub struct SyncQueue<T: SyncQueueTaskTrait> {
sender: Arc<MpscSender<TaskId>>,
task_map: Arc<Mutex<HashMap<TaskId, QueuedTask<T>>>>,
/// Abort handle for the currently executing task. Set by the background
/// processor before running a task and cleared after it completes.
active_task_handle: Arc<Mutex<Option<AbortHandle>>>,
mode: SyncQueueMode<T>,
}
impl<T: SyncQueueTaskTrait> Clone for SyncQueueMode<T> {
fn clone(&self) -> Self {
match self {
SyncQueueMode::PerTask => SyncQueueMode::PerTask,
SyncQueueMode::Streaming { _keepalive } => SyncQueueMode::Streaming {
_keepalive: _keepalive.clone(),
},
}
}
}
impl<T: SyncQueueTaskTrait> Clone for SyncQueue<T> {
fn clone(&self) -> Self {
Self {
sender: self.sender.clone(),
task_map: self.task_map.clone(),
active_task_handle: self.active_task_handle.clone(),
mode: self.mode.clone(),
}
}
}
impl<T: SyncQueueTaskTrait> SyncQueue<T> {
pub fn new(executor: &Arc<Background>) -> Self {
Self::new_with_rate_limit(executor, None)
}
pub fn new_with_rate_limit(executor: &Arc<Background>, max_rpm: Option<u32>) -> Self {
Self::new_inner(executor, max_rpm, SyncQueueMode::PerTask, None)
}
pub fn new_streaming(executor: &Arc<Background>) -> Self {
Self::new_streaming_with_rate_limit(executor, None)
}
pub fn new_streaming_with_rate_limit(executor: &Arc<Background>, max_rpm: Option<u32>) -> Self {
let (broadcast_tx, broadcast_rx) = async_broadcast::broadcast(DEFAULT_BUFFER_SIZE);
let keepalive = broadcast_rx.deactivate();
Self::new_inner(
executor,
max_rpm,
SyncQueueMode::Streaming {
_keepalive: keepalive,
},
Some(broadcast_tx),
)
}
fn new_inner(
executor: &Arc<Background>,
max_rpm: Option<u32>,
mode: SyncQueueMode<T>,
broadcast_sender: Option<BroadcastSender<BroadcastResult<T>>>,
) -> Self {
let (sender, receiver) = mpsc::channel(DEFAULT_BUFFER_SIZE);
let rate_limit_config = max_rpm.map(RateLimitConfig::new);
let task_map: Arc<Mutex<HashMap<TaskId, QueuedTask<T>>>> =
Arc::new(Mutex::new(HashMap::new()));
let active_task_handle: Arc<Mutex<Option<AbortHandle>>> = Arc::new(Mutex::new(None));
executor.spawn(Self::process_queue(
receiver,
rate_limit_config,
task_map.clone(),
active_task_handle.clone(),
broadcast_sender,
));
Self {
sender: Arc::new(sender),
task_map,
active_task_handle,
mode,
}
}
/// Returns a new broadcast receiver for task results.
///
/// # Panics
/// Panics if this is a per-task queue.
pub fn subscribe(&self) -> BroadcastReceiver<T> {
match &self.mode {
SyncQueueMode::Streaming { _keepalive } => _keepalive.activate_cloned(),
SyncQueueMode::PerTask => panic!("subscribe() called on a per-task queue"),
}
}
/// Enqueues a task without returning a per-task result receiver.
/// Results are delivered through the broadcast channel.
///
/// # Panics
/// Panics if this is a per-task queue.
pub fn enqueue(&self, task: T, retry_options: Option<RetryOption>, context: impl Into<String>) {
assert!(
matches!(self.mode, SyncQueueMode::Streaming { .. }),
"enqueue() called on a per-task queue"
);
let task_id = TaskId::new();
let queued_task = QueuedTask {
task,
retry_options: retry_options.unwrap_or(DEFAULT_SYNC_RETRY_STRATEGY),
context: context.into(),
result_sender: None,
};
self.task_map.lock().unwrap().insert(task_id, queued_task);
if let Err(e) = self.sender.as_ref().clone().try_send(task_id) {
log::warn!("Failed to enqueue task because of receiver error {e}");
self.task_map.lock().unwrap().remove(&task_id);
}
}
/// Enqueues a task and returns a oneshot receiver for that task's result.
///
/// # Panics
/// Panics if this is a streaming queue.
pub async fn enqueue_with_result(
&self,
task: T,
retry_options: Option<RetryOption>,
context: impl Into<String>,
) -> Receiver<Result<T::Result, T::Error>> {
assert!(
matches!(self.mode, SyncQueueMode::PerTask),
"enqueue_with_result() called on a streaming queue"
);
let (tx, rx) = oneshot::channel();
let task_id = TaskId::new();
let queued_task = QueuedTask {
task,
retry_options: retry_options.unwrap_or(DEFAULT_SYNC_RETRY_STRATEGY),
context: context.into(),
result_sender: Some(tx),
};
self.task_map.lock().unwrap().insert(task_id, queued_task);
// Ignore send error if no receiver (e.g., queue processor dropped)
if let Err(e) = self.sender.as_ref().clone().try_send(task_id) {
log::warn!("Failed to enqueue task because of receiver error {e}");
// Clean up the task from the map since it will never be processed.
self.task_map.lock().unwrap().remove(&task_id);
}
rx
}
/// Check if there is a queued task (not currently executing) that matches
/// the given comparison function.
pub fn has_queued_task(&self, comparison: impl Fn(&T) -> bool) -> bool {
self.task_map
.lock()
.unwrap()
.values()
.any(|queued_task| comparison(&queued_task.task))
}
/// Cancel all pending and in-flight tasks.
///
/// Pending tasks that have not yet started will have their result senders dropped,
/// causing receivers to resolve to `Err(Canceled)`. The currently executing task
/// (if any) is aborted via its `AbortHandle`.
pub fn cancel_all(&self) {
// Abort the currently executing task, if any.
if let Some(handle) = self.active_task_handle.lock().unwrap().take() {
handle.abort();
}
// Drain all pending tasks from the map. Dropping the QueuedTask entries
// drops their oneshot senders, signaling cancellation to receivers.
self.task_map.lock().unwrap().clear();
}
async fn retry_with_backoff<Fut>(
mut fut: impl FnMut() -> Fut,
mut retry_options: RetryOption,
context: &str,
) -> Result<T::Result, T::Error>
where
Fut: Future<Output = Result<T::Result, T::Error>>,
{
let mut attempt = 0;
let max_attempts = retry_options.remaining_retries();
loop {
match fut().await {
Ok(res) => return Ok(res),
Err(e) => {
attempt += 1;
let is_transient = e.is_transient();
if !is_transient || attempt > max_attempts {
log::warn!(
"SyncQueue task failed after {attempt} attempts: {e}. Context: {context}"
);
return Err(e);
}
let delay = retry_options.duration();
retry_options.advance();
log::debug!(
"SyncQueue retryable error (attempt {attempt}/{max_attempts}), retrying after {delay:?}. Error: {e}. Context: {context}"
);
Timer::after(delay).await;
}
}
}
}
/// Process tasks from the mpsc receiver. Should be called from an async context.
async fn process_queue(
mut receiver: MpscReceiver<TaskId>,
rate_limit_config: Option<RateLimitConfig>,
task_map: Arc<Mutex<HashMap<TaskId, QueuedTask<T>>>>,
active_task_handle: Arc<Mutex<Option<AbortHandle>>>,
broadcast_sender: Option<BroadcastSender<BroadcastResult<T>>>,
) {
while let Some(task_id) = receiver.next().await {
// Remove the task from the map. If it's missing, it was cancelled.
let Some(mut queued_task) = task_map.lock().unwrap().remove(&task_id) else {
continue;
};
let retry_options = queued_task.retry_options;
let rate_limit_config = rate_limit_config.clone();
// Wrap the task in Abortable so cancel_all can abort it.
// Rate limiting is inside the abortable so cancellation also
// interrupts a task waiting for a rate-limit token.
let (abort_handle, abort_registration) = AbortHandle::new_pair();
*active_task_handle.lock().unwrap() = Some(abort_handle);
let abortable_result = Abortable::new(
async {
if let Some(ref rate_limiter) = rate_limit_config {
rate_limiter.wait_for_token().await;
}
let fut = || queued_task.task.run();
Self::retry_with_backoff(fut, retry_options, queued_task.context.as_str()).await
},
abort_registration,
)
.await;
// Clear the active handle now that the task has finished.
*active_task_handle.lock().unwrap() = None;
match abortable_result {
Ok(result) => {
if let Some(sender) = queued_task.result_sender {
// Per-task mode: deliver via oneshot.
let _ = sender.send(result);
} else if let Some(ref broadcast_tx) = broadcast_sender {
// Streaming mode: deliver via broadcast.
let broadcast_result = match result {
Ok(value) => Ok(Arc::new(value)),
Err(error) => Err(Arc::new(error)),
};
if let Err(e) = broadcast_tx.try_broadcast(broadcast_result) {
log::warn!("Failed to broadcast task result: {e}");
}
}
}
// Task was aborted by cancel_all — drop the sender to signal cancellation.
Err(_aborted) => {}
}
}
log::debug!("No more tasks in the queue. Receiver closed.");
}
}
impl<T: SyncQueueTaskTrait> Entity for SyncQueue<T> {
type Event = ();
}
impl<T: SyncQueueTaskTrait> SingletonEntity for SyncQueue<T> {}
#[cfg(test)]
#[path = "sync_queue_tests.rs"]
mod tests;
+220
View File
@@ -0,0 +1,220 @@
use std::sync::Arc;
use futures::channel::oneshot;
use futures::StreamExt;
use galaxyui::r#async::executor::Background;
use super::*;
/// A test error type.
#[derive(Debug, thiserror::Error)]
#[error("test error")]
struct TestError;
impl IsTransientError for TestError {
fn is_transient(&self) -> bool {
false
}
}
/// A test task that has an identifier and can optionally block on a signal
/// before completing.
struct TestTask {
id: u32,
/// If set, the task waits for this signal before returning.
gate: Option<oneshot::Receiver<()>>,
}
impl SyncQueueTaskTrait for TestTask {
type Error = TestError;
type Result = u32;
type Fut = std::pin::Pin<Box<dyn Future<Output = Result<u32, TestError>> + Send>>;
fn run(&mut self) -> Self::Fut {
let id = self.id;
let gate = self.gate.take();
Box::pin(async move {
if let Some(gate) = gate {
let _ = gate.await;
}
Ok(id)
})
}
}
fn create_queue() -> (SyncQueue<TestTask>, Arc<Background>) {
let executor = Arc::new(Background::default());
let queue = SyncQueue::new(&executor);
(queue, executor)
}
fn ungated_task(id: u32) -> TestTask {
TestTask { id, gate: None }
}
fn gated_task(id: u32) -> (TestTask, oneshot::Sender<()>) {
let (tx, rx) = oneshot::channel();
(TestTask { id, gate: Some(rx) }, tx)
}
#[test]
fn has_queued_task_finds_matching_task() {
let (queue, _executor) = create_queue();
// Enqueue a blocking task to hold the processor so subsequent tasks stay queued.
let (blocker, gate_tx) = gated_task(0);
drop(futures::executor::block_on(
queue.enqueue_with_result(blocker, None, "blocker"),
));
// Enqueue the tasks we want to check.
drop(futures::executor::block_on(queue.enqueue_with_result(
ungated_task(1),
None,
"task-1",
)));
drop(futures::executor::block_on(queue.enqueue_with_result(
ungated_task(2),
None,
"task-2",
)));
// Give the processor time to start executing the blocker.
std::thread::sleep(Duration::from_millis(50));
// Tasks 1 and 2 should be queued (not executing).
assert!(queue.has_queued_task(|task| task.id == 1));
assert!(queue.has_queued_task(|task| task.id == 2));
assert!(!queue.has_queued_task(|task| task.id == 99));
// Unblock so the test cleans up.
let _ = gate_tx.send(());
}
#[test]
fn has_queued_task_does_not_match_executing_task() {
let (queue, _executor) = create_queue();
// Enqueue a task that will block — it will be picked up by the processor.
let (blocker, gate_tx) = gated_task(1);
drop(futures::executor::block_on(queue.enqueue_with_result(
blocker,
None,
"blocking-task",
)));
// Give the background processor time to pick up the task.
std::thread::sleep(Duration::from_millis(50));
// The task should be executing (removed from the map), not queued.
assert!(!queue.has_queued_task(|task| task.id == 1));
let _ = gate_tx.send(());
}
#[test]
fn cancel_all_cancels_running_and_queued_tasks() {
let (queue, _executor) = create_queue();
// Enqueue a task that blocks — this will be the "running" task.
// We intentionally drop gate_tx so the task will never complete on its own.
let (blocker, _gate_tx) = gated_task(1);
let running_rx =
futures::executor::block_on(queue.enqueue_with_result(blocker, None, "running-task"));
// Enqueue a second task that will sit in the queue waiting.
let queued_rx = futures::executor::block_on(queue.enqueue_with_result(
ungated_task(2),
None,
"queued-task",
));
// Give the processor time to start executing the first task.
std::thread::sleep(Duration::from_millis(50));
// Cancel everything.
queue.cancel_all();
// Both receivers should resolve to Err(Canceled) since their senders
// were dropped without sending a result.
assert!(
futures::executor::block_on(running_rx).is_err(),
"running task receiver should be cancelled"
);
assert!(
futures::executor::block_on(queued_rx).is_err(),
"queued task receiver should be cancelled"
);
}
fn create_streaming_queue() -> (SyncQueue<TestTask>, Arc<Background>) {
let executor = Arc::new(Background::default());
let queue = SyncQueue::new_streaming(&executor);
(queue, executor)
}
#[test]
fn streaming_subscribe_receives_all_results() {
let (queue, _executor) = create_streaming_queue();
let mut rx1 = queue.subscribe();
let mut rx2 = queue.subscribe();
queue.enqueue(ungated_task(1), None, "task-1");
queue.enqueue(ungated_task(2), None, "task-2");
// Both receivers should get both results.
let r1_a = futures::executor::block_on(rx1.next()).unwrap();
let r1_b = futures::executor::block_on(rx1.next()).unwrap();
let r2_a = futures::executor::block_on(rx2.next()).unwrap();
let r2_b = futures::executor::block_on(rx2.next()).unwrap();
assert_eq!(*r1_a.unwrap(), 1);
assert_eq!(*r1_b.unwrap(), 2);
assert_eq!(*r2_a.unwrap(), 1);
assert_eq!(*r2_b.unwrap(), 2);
}
#[test]
#[should_panic(expected = "subscribe() called on a per-task queue")]
fn subscribe_panics_on_per_task_queue() {
let (queue, _executor) = create_queue();
let _ = queue.subscribe();
}
#[test]
#[should_panic(expected = "enqueue() called on a per-task queue")]
fn enqueue_panics_on_per_task_queue() {
let (queue, _executor) = create_queue();
queue.enqueue(ungated_task(1), None, "task-1");
}
#[test]
#[should_panic(expected = "enqueue_with_result() called on a streaming queue")]
fn enqueue_with_result_panics_on_streaming_queue() {
let (queue, _executor) = create_streaming_queue();
// Use drop() instead of `let _ =` to satisfy clippy::let_underscore_future.
// The test panics inside enqueue_with_result before this value is used.
drop(futures::executor::block_on(queue.enqueue_with_result(
ungated_task(1),
None,
"task-1",
)));
}
#[test]
fn streaming_cancel_all_clears_queued_tasks() {
let (queue, _executor) = create_streaming_queue();
let _rx = queue.subscribe();
// Enqueue a blocking task so subsequent tasks stay queued.
let (blocker, _gate_tx) = gated_task(0);
queue.enqueue(blocker, None, "blocker");
queue.enqueue(ungated_task(1), None, "task-1");
// Give the processor time to start the blocker.
std::thread::sleep(Duration::from_millis(50));
assert!(queue.has_queued_task(|task| task.id == 1));
queue.cancel_all();
assert!(!queue.has_queued_task(|task| task.id == 1));
}
+239
View File
@@ -0,0 +1,239 @@
use std::{fmt, marker::PhantomData};
use serde_json::Value;
use strum::IntoEnumIterator;
use galaxyui::{AppContext, Entity, SingletonEntity};
// Re-export for macro use.
#[doc(hidden)]
#[cfg(not(target_family = "wasm"))]
pub use inventory::submit;
use crate::{
channel::{Channel, ChannelState},
features::FeatureFlag,
};
/// Core trait defining telemetry event behavior.
///
/// This trait encapsulates the basic functionality required for any telemetry event
/// in the Warp ecosystem. It enables events to be defined in any crate while maintaining
/// consistent telemetry reporting behavior.
pub trait TelemetryEvent: RegisteredTelemetryEvent {
/// Returns the name of the telemetry event.
///
/// The name should be a stable identifier that uniquely identifies this type of event.
/// It is used for analytics tracking and should remain consistent over time.
///
/// Returns a borrowed string to avoid allocations for static event names.
fn name(&self) -> &'static str;
/// Returns optional structured data associated with this event.
///
/// The payload allows events to include additional context or metadata beyond
/// just the event name. This is useful for including dynamic data about the
/// event occurrence.
///
/// Returns None if the event has no additional data to report.
fn payload(&self) -> Option<Value>;
/// Returns a human-readable description of what this event represents.
///
/// The description should clearly explain the significance of the event to help
/// with analytics and monitoring. This is used both for documentation and
/// telemetry dashboards.
fn description(&self) -> &'static str;
/// Determines if an event is enabled in the current build. This only works when all
/// feature flags are set appropriately, so this should be used when running
/// the bundled app.
fn enablement_state(&self) -> EnablementState;
/// Returns whether this event contains user-generated content (UGC).
///
/// Events containing UGC may need special handling for privacy and data
/// retention reasons. This flag helps route the event to the appropriate
/// analytics destination.
fn contains_ugc(&self) -> bool;
/// Returns an iterator over the descriptors for all telemetry events of this type.
fn event_descs() -> impl Iterator<Item = Box<dyn TelemetryEventDesc>>;
}
#[macro_export]
macro_rules! register_telemetry_event {
($event:ty) => {
impl $crate::telemetry::RegisteredTelemetryEvent for $event {}
#[cfg(not(target_family = "wasm"))]
$crate::telemetry::submit! {
$crate::telemetry::TelemetryEventRegistration::<$event>::adapt()
}
};
}
/// Marker trait for known telemetry events. We rely on this to print an exhaustive telemetry
/// table in Warp's documentation.
///
/// DO NOT implement this trait directly - use the [`register_telemetry_event!`] macro instead.
pub trait RegisteredTelemetryEvent {}
/// An abstract description of a telemetry event we may emit. Every [`TelemetryEvent`] has a
/// corresponding [`TelemetryEventDesc`].
pub trait TelemetryEventDesc: fmt::Debug {
fn name(&self) -> &'static str;
fn description(&self) -> &'static str;
fn enablement_state(&self) -> EnablementState;
}
/// A type-erased version of [`TelemetryEventRegistration`]. This is only used by the
/// [`register_telemetry_event!`] macro implementation.
#[doc(hidden)]
pub trait AnyTelemetryEventRegistration: Sync {
/// Returns an iterator over the descriptors for all telemetry events in this [`TelemetryEvent`] implementation.
fn events(&self) -> Box<dyn Iterator<Item = Box<dyn TelemetryEventDesc>>>;
}
/// Adapter for statically registering all [`TelemetryEvent`] implementations.
#[doc(hidden)]
pub struct TelemetryEventRegistration<T: TelemetryEvent + 'static> {
/// Marker that `TelemetryEventRegistration` references `T`, but doesn't own a `T` value.
/// See https://doc.rust-lang.org/nomicon/phantom-data.html
_marker: PhantomData<fn(T) -> T>,
}
impl<T: TelemetryEvent + 'static> TelemetryEventRegistration<T> {
pub const fn adapt() -> &'static dyn AnyTelemetryEventRegistration {
&Self {
_marker: PhantomData,
}
}
}
impl<T: TelemetryEvent + 'static> AnyTelemetryEventRegistration for TelemetryEventRegistration<T> {
fn events(&self) -> Box<dyn Iterator<Item = Box<dyn TelemetryEventDesc>>> {
Box::new(T::event_descs())
}
}
/// Returns an iterator over all discriminants of `T` as [`TelemetryEventDesc`]s.
///
/// Telemetry events that use [`strum`] may use this to implement [`TelemetryEvent::event_descs`].
pub fn enum_events<T>() -> impl Iterator<Item = Box<dyn TelemetryEventDesc>>
where
T: strum::IntoDiscriminant,
T::Discriminant: strum::IntoEnumIterator + TelemetryEventDesc + 'static,
{
T::Discriminant::iter()
.map(|discriminant| Box::new(discriminant) as Box<dyn TelemetryEventDesc>)
}
// Collect adapters for all registered telemetry events. Because `inventory::collect!` requires a
// concrete type, we use `&static dyn Trait` to erase the generics.
#[cfg(not(target_family = "wasm"))]
inventory::collect!(&'static dyn AnyTelemetryEventRegistration);
/// Returns all registered telemetry events. This is not available in WASM builds, as it relies on
/// the [`inventory`] crate, which does not fully work in our WASM configuration.
#[cfg(not(target_family = "wasm"))]
pub fn all_events() -> impl Iterator<Item = Box<dyn TelemetryEventDesc>> {
inventory::iter::<&'static dyn AnyTelemetryEventRegistration>().flat_map(|meta| meta.events())
}
// Sends a telemetry `track` event to Rudderstack asynchronously. It adds events to the static
// telemetry queue that is periodically flushed to the Rudderstack API.
// This is the recommended way of recording telemetry events.
// You should almost always use this, unless the recording is time-sensitive and cannot be lost.
// To send a telemetry event synchronously, use [`send_telemetry_sync_from_ctx`].
#[macro_export]
macro_rules! send_telemetry_from_ctx {
($event:expr, $ctx:expr) => {
#[allow(unused_imports)]
use galaxy_core::telemetry::TelemetryEvent as _;
let event = $event;
if event.enablement_state().is_enabled() {
let auth_state =
<$crate::telemetry::TelemetryContextModel as galaxyui::SingletonEntity>::handle($ctx)
.as_ref($ctx);
let user_id = auth_state.user_id($ctx);
let anonymous_id = auth_state.anonymous_id($ctx);
galaxyui::record_telemetry_from_ctx!(
user_id,
anonymous_id,
event.name().into(),
event.payload(),
event.contains_ugc(),
$ctx
);
}
};
}
/// Sends telemetry `track` event to Rudderstack API asynchronously. This is the same as the
/// [`send_telemetry_from_ctx`], except it can be called in instances where you only have
/// a `AppContext` rather than a `ViewContext`/`ModelContext`.
///
/// If possible, use [`send_telemetry_from_ctx`].
#[macro_export]
macro_rules! send_telemetry_from_app_ctx {
($event:expr, $app_ctx:expr) => {
let event = $event;
if event.enablement_state().is_enabled() {
let auth_state =
<$crate::telemetry::TelemetryContextModel as galaxyui::SingletonEntity>::handle(
$app_ctx,
)
.as_ref($app_ctx);
let user_id = auth_state.user_id($app_ctx.as_ref());
let anonymous_id = auth_state.anonymous_id($app_ctx.as_ref());
galaxyui::record_telemetry_on_executor!(
user_id,
anonymous_id,
event.name().into(),
event.payload(),
event.contains_ugc(),
$app_ctx.background_executor()
);
}
};
}
/// Gives information about when a telemetry event is enabled.
#[derive(Debug)]
pub enum EnablementState {
Always,
/// The telemetry event is enabled when a particular feature flag is enabled.
Flag(FeatureFlag),
/// The event is enabled if the app is running in one of the contained channels.
ChannelSpecific {
channels: Vec<Channel>,
},
}
impl EnablementState {
pub fn is_enabled(&self) -> bool {
match self {
EnablementState::Always => true,
EnablementState::Flag(flag) => flag.is_enabled(),
EnablementState::ChannelSpecific { channels } => {
let app_channel = ChannelState::channel();
channels.contains(&app_channel)
}
}
}
}
/// Trait for the context provider that allows us to send telemetry payloads.
pub trait TelemetryContextProvider {
fn user_id(&self, ctx: &AppContext) -> Option<String>;
fn anonymous_id(&self, ctx: &AppContext) -> String;
}
pub type TelemetryContextModel = Box<dyn TelemetryContextProvider>;
impl Entity for TelemetryContextModel {
type Event = ();
}
impl SingletonEntity for TelemetryContextModel {}
+338
View File
@@ -0,0 +1,338 @@
use galaxyui::{
fonts::{FamilyId, Weight},
Entity, ModelContext, SingletonEntity,
};
use super::{builder::UiBuilder, theme::WarpTheme};
/// The standard font size to use for headers (e.g.: in dialogs).
const HEADER_FONT_SIZE: f32 = 18.;
const OVERLINE_FONT_SIZE: f32 = 10.;
pub const DEFAULT_UI_FONT_SIZE: f32 = 12.0;
pub const DEFAULT_COMMAND_PALETTE_FONT_SIZE: f32 = 14.0;
/// Holds visual settings that are so widely used that it's best
/// to invalidate all views when they change rather than forcing views
/// to individually listen for changes. The most prominent examples are
/// settings related to themes and fonts.
pub struct Appearance {
theme: WarpTheme,
monospace_font_family: FamilyId,
monospace_font_size: f32,
monospace_font_weight: Weight,
line_height_ratio: f32,
ui_builder: UiBuilder,
// We cache the family id for the ui font - note that this
// isn't actually a changeable setting right now.
ui_font_family: FamilyId,
ai_font_family: FamilyId,
/// A font that is used for password fields.
password_font_family: FamilyId,
}
/// Defines appearance change events.
///
/// For any properties that are read from appearance (e.g.: theme, font, etc.),
/// users should listen for these events rather than directly listenting to
/// settings change events for the underlying properties.
///
/// NOTE: You do NOT need to set up subscriptions for these events and use them
/// to invalidate views! All views are automatically invalidated on changes to
/// fields in [`Appearance`]. If you appear to need to subscribe to one of
/// these events and call `ctx.notify()` for proper behavior, there is probably
/// a bug in [`Appearance`].
#[derive(Debug)]
pub enum AppearanceEvent {
ThemeChanged,
UiFontFamilyChanged {
previous_family_id: FamilyId,
current_family_id: FamilyId,
},
MonospaceFontSizeChanged {
previous_font_size: f32,
current_font_size: f32,
},
MonospaceFontFamilyChanged {
previous_family_id: FamilyId,
current_family_id: FamilyId,
},
MonospaceFontWeightChanged {
previous_font_weight: Weight,
current_font_weight: Weight,
},
LineHeightRatioChanged {
previous_line_height_ratio: f32,
current_line_height_ratio: f32,
},
}
impl Appearance {
#[allow(clippy::too_many_arguments)]
pub fn new(
theme: WarpTheme,
monospace_font_family: FamilyId,
monospace_font_size: f32,
monospace_font_weight: Weight,
ui_font_family: FamilyId,
line_height_ratio: f32,
ai_font_family: FamilyId,
password_font_family: FamilyId,
) -> Self {
Self {
theme: theme.clone(),
monospace_font_family,
monospace_font_size,
monospace_font_weight,
ui_font_family,
line_height_ratio,
ui_builder: UiBuilder::new(
theme,
ui_font_family,
DEFAULT_UI_FONT_SIZE,
DEFAULT_COMMAND_PALETTE_FONT_SIZE,
line_height_ratio,
),
ai_font_family,
password_font_family,
}
}
#[cfg(feature = "test-util")]
pub fn mock() -> Self {
use galaxyui::color::ColorU;
use crate::ui::theme::{mock_terminal_colors, Details, Fill};
let mock_theme = WarpTheme::new(
Fill::Solid(ColorU::from_u32(0x000000ff)),
ColorU::from_u32(0xffffffff),
Fill::Solid(ColorU::new(18, 123, 156, 255)),
None,
Some(Details::Darker),
mock_terminal_colors(),
None,
Some("Dark".to_string()),
);
let line_height_ratio = 1.4;
let ui_font_family = FamilyId(1);
Self {
theme: mock_theme.clone(),
monospace_font_family: FamilyId(0),
monospace_font_size: 13.,
monospace_font_weight: Weight::Normal,
line_height_ratio,
ui_builder: UiBuilder::new(
mock_theme,
ui_font_family,
DEFAULT_UI_FONT_SIZE,
DEFAULT_COMMAND_PALETTE_FONT_SIZE,
line_height_ratio,
),
ui_font_family,
ai_font_family: FamilyId(0),
password_font_family: FamilyId(0),
}
}
pub fn set_theme(&mut self, new_theme: WarpTheme, ctx: &mut ModelContext<Self>) {
self.theme = new_theme;
self.ui_builder = UiBuilder::new(
self.theme.clone(),
self.ui_font_family,
self.ui_font_size(),
DEFAULT_COMMAND_PALETTE_FONT_SIZE,
self.line_height_ratio,
);
// Request a redraw of all windows.
ctx.invalidate_all_views();
// Allow listeners who specifically care about theme changes to know the theme has changed.
ctx.emit(AppearanceEvent::ThemeChanged);
// Notify listeners that appearance-related configuration
// has changed.
ctx.notify();
}
pub fn set_monospace_font_family(
&mut self,
new_family: FamilyId,
ctx: &mut ModelContext<Self>,
) {
let previous_family_id = self.monospace_font_family;
self.monospace_font_family = new_family;
// Request a redraw of all windows.
ctx.invalidate_all_views();
ctx.emit(AppearanceEvent::MonospaceFontFamilyChanged {
previous_family_id,
current_family_id: new_family,
});
}
pub fn set_ui_font_family(&mut self, new_family: FamilyId, ctx: &mut ModelContext<Self>) {
let previous_family_id = self.ui_font_family;
self.ui_font_family = new_family;
self.ui_builder = UiBuilder::new(
self.theme.clone(),
self.ui_font_family,
self.ui_font_size(),
DEFAULT_COMMAND_PALETTE_FONT_SIZE,
self.line_height_ratio,
);
// Request a redraw of all windows.
ctx.invalidate_all_views();
// We fire the same event as monospace font family change - performance is likely not going to be an issue.
ctx.emit(AppearanceEvent::UiFontFamilyChanged {
previous_family_id,
current_family_id: new_family,
});
}
pub fn set_ai_font_family(&mut self, new_family: FamilyId, ctx: &mut ModelContext<Self>) {
let previous_family_id = self.ai_font_family;
self.ai_font_family = new_family;
// Request a redraw of all windows.
ctx.invalidate_all_views();
// We fire the same event as monospace font family change - performance is likely not going to be an issue.
ctx.emit(AppearanceEvent::MonospaceFontFamilyChanged {
previous_family_id,
current_family_id: new_family,
});
}
pub fn set_monospace_font_size(&mut self, new_font_size: f32, ctx: &mut ModelContext<Self>) {
let previous_font_size = self.monospace_font_size;
self.monospace_font_size = new_font_size;
// Request a redraw of all windows.
ctx.invalidate_all_views();
ctx.emit(AppearanceEvent::MonospaceFontSizeChanged {
current_font_size: self.monospace_font_size,
previous_font_size,
});
}
pub fn set_monospace_font_weight(
&mut self,
new_font_weight: Weight,
ctx: &mut ModelContext<Self>,
) {
let previous_font_weight = self.monospace_font_weight;
self.monospace_font_weight = new_font_weight;
// Request a redraw of all windows.
ctx.invalidate_all_views();
ctx.emit(AppearanceEvent::MonospaceFontWeightChanged {
current_font_weight: self.monospace_font_weight,
previous_font_weight,
});
}
#[cfg(feature = "test-util")]
pub fn set_monospace_font_size_test(&mut self, new_font_size: f32) {
self.monospace_font_size = new_font_size;
}
pub fn set_line_height_ratio(
&mut self,
new_line_height_ratio: f32,
ctx: &mut ModelContext<Self>,
) {
let previous_line_height_ratio = self.line_height_ratio;
self.line_height_ratio = new_line_height_ratio;
self.ui_builder = UiBuilder::new(
self.theme.clone(),
self.ui_font_family,
DEFAULT_UI_FONT_SIZE,
DEFAULT_COMMAND_PALETTE_FONT_SIZE,
self.line_height_ratio,
);
// Request a redraw of all windows.
ctx.invalidate_all_views();
ctx.emit(AppearanceEvent::LineHeightRatioChanged {
current_line_height_ratio: self.line_height_ratio,
previous_line_height_ratio,
});
}
pub fn ui_builder(&self) -> &UiBuilder {
&self.ui_builder
}
pub fn theme(&self) -> &WarpTheme {
&self.theme
}
pub fn monospace_font_family(&self) -> FamilyId {
self.monospace_font_family
}
pub fn ai_font_family(&self) -> FamilyId {
self.ai_font_family
}
pub fn monospace_font_size(&self) -> f32 {
self.monospace_font_size
}
pub fn monospace_ui_scalar(&self) -> f32 {
self.monospace_font_size / DEFAULT_UI_FONT_SIZE
}
pub fn monospace_font_weight(&self) -> Weight {
self.monospace_font_weight
}
pub fn ui_font_family(&self) -> FamilyId {
self.ui_font_family
}
pub fn ui_font_size(&self) -> f32 {
DEFAULT_UI_FONT_SIZE
}
pub fn header_font_family(&self) -> FamilyId {
self.ui_font_family
}
pub fn header_font_size(&self) -> f32 {
HEADER_FONT_SIZE
}
pub fn overline_font_family(&self) -> FamilyId {
self.ui_font_family
}
pub fn overline_font_size(&self) -> f32 {
OVERLINE_FONT_SIZE
}
pub fn line_height_ratio(&self) -> f32 {
self.line_height_ratio
}
pub fn password_font_family(&self) -> FamilyId {
self.password_font_family
}
}
impl Entity for Appearance {
type Event = AppearanceEvent;
}
impl SingletonEntity for Appearance {}
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
use galaxyui::color::ColorU;
pub trait Blend<Rhs = Self> {
type Output;
fn blend(&self, rhs: &Rhs) -> Self::Output;
}
impl Blend for ColorU {
type Output = ColorU;
/// Color blending computation.
/// This function calculates the color, assuming that "self" is a background, and "other" is
/// a new color on top.
/// It simply calculates a weighted sum of each channel, and averages out the opacity.
/// Note that due to rounding errors, the result of computation maybe slightly different than
/// what comes out of figma (ie. 181818 instead of 191918) - differences shouldn't be
/// noticeable though, due to nature of the rounding error.
fn blend(&self, other: &ColorU) -> ColorU {
// Helper function that computes a weighted sum using the overlay color's opacity as weight.
fn add_channels(c1: u8, c2: u8, ratio: f32) -> u8 {
((c1 as f32 * (1. - ratio)) + (c2 as f32 * ratio)) as u8
}
// background not visible, lets return other
if self.is_fully_transparent() || other.a == super::OPAQUE {
return *other;
}
// other not visible, self it is.
if other.is_fully_transparent() {
return *self;
}
// alpha value for new color, opaque if background is opaque already, otherwise simple avg
let alpha = if self.is_opaque() {
super::OPAQUE
} else {
// doing type conversion, since adding two arbitrary alphas may result in u8 overflow
((self.a as f32 + other.a as f32) / 2.) as u8
};
// basically overlay color's opacity expressed as %, rounded to 2 digits after decimal
let ratio = ((other.a as f32 / 255.) * 100.).ceil() / 100.;
ColorU::new(
add_channels(self.r, other.r, ratio),
add_channels(self.g, other.g, ratio),
add_channels(self.b, other.b, ratio),
alpha,
)
}
}
@@ -0,0 +1,38 @@
use super::*;
#[test]
fn coloru_with_opacity_test() {
assert_eq!(
coloru_with_opacity(ColorU::from_u32(0x000000ff), 10),
ColorU::new(0, 0, 0, 25)
);
assert_eq!(
coloru_with_opacity(ColorU::from_u32(0x000000ff), 0),
ColorU::new(0, 0, 0, 0)
);
assert_eq!(
coloru_with_opacity(ColorU::from_u32(0x000000ff), 100),
ColorU::new(0, 0, 0, OPAQUE)
);
}
#[test]
fn darker_lighter_test() {
assert_eq!(
darken(ColorU::new(255, 128, 0, OPAQUE)),
ColorU::new(123, 62, 0, OPAQUE)
);
assert_eq!(
lighten(ColorU::new(255, 128, 0, OPAQUE)),
ColorU::new(255, 192, 128, OPAQUE)
);
}
#[test]
fn pick_foreground_test() {
assert_eq!(ColorU::white(), pick_foreground_color(ColorU::black()));
assert_eq!(ColorU::black(), pick_foreground_color(ColorU::white()));
assert_eq!(
ColorU::white(),
pick_foreground_color(ColorU::new(100, 100, 100, OPAQUE))
);
}
+183
View File
@@ -0,0 +1,183 @@
use galaxyui::color::ColorU;
use super::{blend::Blend, coloru_with_opacity, Rgb};
/// Offset to the relative luminance when computing the contrast ratio per the formula defined in
/// the [W3C Spec](https://www.w3.org/TR/WCAG20-TECHS/G17.html). This offset is included to
/// compensate for contrast ratios that occur when a value is at or near zero, and for ambient light
/// effects. See <https://juicystudio.com/article/luminositycontrastratioalgorithm.php> for more
/// details.
const LUMINANCE_OFFSET_FOR_CONTRAST_RATIO: f32 = 0.05;
/// Returns a new foreground color that when rendered against `background_color` would have a
/// contrast of at least `minimum_allowed_contrast`. NOTE the `background_color` must be fully
/// opaque in in order to perform proper contrast checking.
///
/// If `foreground_color` already meets the minimum contrast, it is returned unchanged.
///
/// Color shifting is performed by computing the color that would produce the max contrast against
/// the `background_color` and then binary searching across all opacities to find an opacity that
/// would produce a color with at least the `minimum_allowed_contrast` when blended with the
/// `foreground_color`.
///
/// This is _heavily_ inspired by Chromium's approach to color shifting. See
/// <https://source.chromium.org/chromium/chromium/src/+/main:ui/gfx/color_utils.cc;l=634;drc=9f7b5c10efd74425f135fd5aad2076a7cc78607a>.
pub fn foreground_color_with_minimum_contrast(
foreground_color: ColorU,
background_color: Rgb,
minimum_allowed_contrast: MinimumAllowedContrast,
) -> ColorU {
// Convert the `RGB` into a fully opaque `ColorU` so that we can use existing blending functions
// that rely on `ColorU`s.
let background_color = ColorU::from(background_color);
let foreground_color = background_color.blend(&foreground_color);
if high_enough_contrast(foreground_color, background_color, minimum_allowed_contrast) {
return foreground_color;
}
// Determine the color that would have the maximum contrast against the background. Contrast
// is determined by the formula C = (L1 + 0.05) /(L2 + 0.05) where L1 is the relative luminance
// of the lighter color and L2 is the relative luminance of the darker color. Since black has a
// luminance of 0, and white has a luminance of 1, we know that white or black must produce the
// color with most contrast against the background. In other words, if the background is
// "light", then a luminance of 1 (black) in the denominator would produce the maximum possible
// contrast. Alternately, if the background is a "dark" color, then a value of 0 (white) in the
// numerator would produce the maximum possible contrast.
let color_with_max_contrast =
pick_constrasting_color(background_color, ColorU::white(), ColorU::black());
// Perform binary search across all possible opacities (0,100) to find the best color that meets
// the minimum allowed contrast. The returned color is computed by blending the current alpha
// with the target foreground color and foreground color.
let mut low_opacity = 0;
let mut high_opacity = 101;
let mut best_color = foreground_color;
while low_opacity < high_opacity {
let opacity = (low_opacity + high_opacity) / 2;
let color = foreground_color.blend(&coloru_with_opacity(color_with_max_contrast, opacity));
let contrast = contrast_ratio(color, background_color);
if contrast >= minimum_allowed_contrast.get() {
best_color = color;
high_opacity = opacity;
} else {
low_opacity = opacity + 1;
}
}
best_color
}
fn relative_luminance_for_channel(channel: u8) -> f32 {
let srgb_channel = channel as f32 / 255.;
if srgb_channel <= 0.03928 {
srgb_channel / 12.92
} else {
((srgb_channel + 0.055) / 1.055).powf(2.4)
}
}
/// Computed based on the WCAG recommendations:
/// https://www.w3.org/TR/WCAG20/#relativeluminancedef
pub fn relative_luminance(color: ColorU) -> f32 {
let r = relative_luminance_for_channel(color.r);
let g = relative_luminance_for_channel(color.g);
let b = relative_luminance_for_channel(color.b);
0.2126 * r + 0.7152 * g + 0.0722 * b
}
/// More on calculating contrast ration here:
/// https://medium.muz.li/the-science-of-color-contrast-an-expert-designers-guide-33e84c41d156
fn contrast_ratio(color1: ColorU, color2: ColorU) -> f32 {
let luminance1 = relative_luminance(color1) + LUMINANCE_OFFSET_FOR_CONTRAST_RATIO;
let luminance2 = relative_luminance(color2) + LUMINANCE_OFFSET_FOR_CONTRAST_RATIO;
// dividend here is supposed to be a lighter color than the divisor
if luminance1 > luminance2 {
return luminance1 / luminance2;
}
luminance2 / luminance1
}
/// This method picks the color option (option1 or option2) that has the highest contrast relative
/// to background color.
pub(super) fn pick_constrasting_color(
background: ColorU,
option1: ColorU,
option2: ColorU,
) -> ColorU {
let contrast_option1 = contrast_ratio(background, option1);
let contrast_option2 = contrast_ratio(background, option2);
if contrast_option1 > contrast_option2 {
return option1;
}
option2
}
/// Enum that species the desired contrast ratio based on the type of content in the foreground.
#[derive(Copy, Clone, Debug)]
pub enum MinimumAllowedContrast {
/// Text is on the foreground.
Text,
/// A non-text element (such as an icon or a UI component) is on the foreground.
NonText,
}
impl MinimumAllowedContrast {
/// Returns the minimum acceptable contrast ratio per the [WCAG (Web Content Accessibility
/// Guidelines)](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html) of a
/// foreground color against a background color.
fn get(&self) -> f32 {
match self {
MinimumAllowedContrast::Text => {
// Normal sized text should have a contrast of at least 4.5:1. Source:
// https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html
4.5
}
MinimumAllowedContrast::NonText => {
// Graphical elements should have a contrast of at least 3:1. Source:
// https://www.w3.org/WAI/WCAG21/Techniques/general/G207
3.0
}
}
}
}
/// This method determines what font color should be used based on the background color it's
/// written on.
/// Most of the time, we juggle between background and foreground colors, assuming one of them
/// is dark, and the other is bright. If that's not the case and the contrast between both
/// background and foreground against provided color is not high enough, we simply fallback to white and
/// black for base font colors.
pub fn pick_best_foreground_color(
bg: ColorU,
option1: ColorU,
option2: ColorU,
minimum_allowed_contrast: MinimumAllowedContrast,
) -> ColorU {
let contrasting_color = pick_constrasting_color(bg, option1, option2);
if high_enough_contrast(bg, contrasting_color, minimum_allowed_contrast) {
return contrasting_color;
}
// if the above didn't have enough contrast, we fallback to using black or white.
// we assume that since luminance for black is 0 and 1 for white, we will always pick a
// color that has high enough contrast.
pick_constrasting_color(bg, ColorU::black(), ColorU::white())
}
/// Returns whether `color1` has a contrast of at least `minimum_allowed_contrast` when rendered
/// against `color2`.
pub fn high_enough_contrast(
color1: ColorU,
color2: ColorU,
minimum_allowed_contrast: MinimumAllowedContrast,
) -> bool {
contrast_ratio(color1, color2) > minimum_allowed_contrast.get()
}
#[cfg(test)]
#[path = "contrast_tests.rs"]
mod tests;
@@ -0,0 +1,138 @@
use super::*;
use rand::prelude::StdRng;
use rand::{Rng, SeedableRng};
#[test]
fn foreground_color_with_minimum_contrast_foreground_already_meets_minimum() {
assert_eq!(
ColorU::black(),
foreground_color_with_minimum_contrast(
ColorU::black(),
ColorU::white().into(),
MinimumAllowedContrast::Text
)
)
}
#[test]
fn foreground_color_with_minimum_contrast_foreground_blend_darker() {
let light_grey = ColorU::from_u32(0xAAAAAAFF);
let white = ColorU::white();
// Grey on white should not meet the contrast requirements.
assert!(!high_enough_contrast(
light_grey,
white,
MinimumAllowedContrast::NonText
));
let result = foreground_color_with_minimum_contrast(
light_grey,
white.into(),
MinimumAllowedContrast::NonText,
);
assert_ne!(light_grey, result);
// The suggested color should meet the contrast requirements.
assert!(contrast_ratio(result, white) > MinimumAllowedContrast::NonText.get());
}
#[test]
fn foreground_color_with_minimum_contrast_blend_lighter() {
let minimum_allowed_contrast = MinimumAllowedContrast::NonText;
let grey = ColorU::from_u32(0x333333FF);
let black = ColorU::black();
// Grey on black should not meet the contrast requirements.
assert!(!high_enough_contrast(
grey,
black,
MinimumAllowedContrast::NonText
));
let suggested_color =
foreground_color_with_minimum_contrast(grey, black.into(), minimum_allowed_contrast);
assert_ne!(grey, suggested_color);
// The suggested color should meet the contrast requirements.
assert!(contrast_ratio(suggested_color, black) > minimum_allowed_contrast.get());
}
#[test]
fn compute_foreground_color_with_minimum_contrast_already_meets_contrast() {
let white = ColorU::white();
let black = ColorU::black();
// White on black should meet the contrast requirements.
assert!(high_enough_contrast(
white,
black,
MinimumAllowedContrast::NonText
));
// Since white on black has enough contrast, we shouldn't need to change the color.
assert_eq!(
foreground_color_with_minimum_contrast(
white,
black.into(),
MinimumAllowedContrast::NonText,
),
white
);
}
#[test]
fn compute_foreground_color_with_minimum_contrast_same_color() {
let black = ColorU::black();
// black on black should _not_ meet the contrast requirements.
assert!(!high_enough_contrast(
black,
black,
MinimumAllowedContrast::NonText
));
// Since white on black has enough contrast, we shouldn't need to change the color.
let suggested_color = foreground_color_with_minimum_contrast(
black,
black.into(),
MinimumAllowedContrast::NonText,
);
assert!(high_enough_contrast(
suggested_color,
black,
MinimumAllowedContrast::NonText
));
}
/// Test that ensures that a random foreground color against a background color produces
/// a new foreground color that has a minimum contrast after calling
/// `foreground_color_with_minimum_contrast`.
#[test]
fn compute_foreground_color_with_minimum_contrast_random() {
let minimum_allowed_contrast = MinimumAllowedContrast::NonText;
for seed in 0..1000 {
let mut rng = StdRng::seed_from_u64(seed);
let foreground_color = ColorU::from_u32(rng.gen());
let background_color = ColorU::from_u32(rng.gen());
let suggested_color = foreground_color_with_minimum_contrast(
foreground_color,
background_color.into(),
minimum_allowed_contrast,
);
let actual_contrast_ratio = contrast_ratio(suggested_color, background_color);
let desired_contrast_ratio = minimum_allowed_contrast.get();
assert!(
high_enough_contrast(suggested_color, background_color, minimum_allowed_contrast),
"{foreground_color:?} on {background_color:?} does not have contrast. Expected contrast = {desired_contrast_ratio:?}, actual contrast {actual_contrast_ratio:?}"
);
}
}
@@ -0,0 +1,90 @@
use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
use std::{borrow::Cow, fmt};
use galaxyui::color::ColorU;
use super::OPAQUE;
const SHORT_COLOR_LEN: usize = 3;
const FULL_COLOR_LEN: usize = 6;
#[derive(PartialEq, Eq, Debug, Clone)]
pub enum HexColorError {
HashPrefix,
InvalidLength,
InvalidValue,
}
impl fmt::Display for HexColorError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
HexColorError::HashPrefix => {
write!(f, "Expected hex color string starting with #.")
}
HexColorError::InvalidLength => write!(
f,
"Expected hex color string starting with # followed by 3 or 6 characters."
),
HexColorError::InvalidValue => write!(f, "Invalid hex color string"),
}
}
}
pub fn coloru_from_hex_string(s: &str) -> Result<ColorU, HexColorError> {
if !s.starts_with('#') {
return Err(HexColorError::HashPrefix);
}
let mut s: Cow<str> = s[1..].into();
if s.len() != SHORT_COLOR_LEN && s.len() != FULL_COLOR_LEN {
return Err(HexColorError::InvalidLength);
}
// for a shorter color representation we want to "normalize" it to the standard 6-character
// one, so #123 becomes #112233.
if s.len() == SHORT_COLOR_LEN {
s = s
.chars()
.flat_map(|c| std::iter::repeat_n(c, 2))
.collect::<String>()
.into();
}
let parsed = (0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16))
.collect::<Result<Vec<_>, _>>();
match parsed {
Ok(bytes) if bytes.len() == 3 => Ok(ColorU {
r: bytes[0],
g: bytes[1],
b: bytes[2],
a: OPAQUE,
}),
_ => Err(HexColorError::InvalidValue),
}
}
pub fn coloru_to_hex_string(coloru: &ColorU) -> String {
format!("#{:02x}{:02x}{:02x}", coloru.r, coloru.g, coloru.b)
}
pub fn deserialize<'de, D, C>(deserializer: D) -> Result<C, D::Error>
where
C: From<ColorU>,
D: Deserializer<'de>,
{
let s: String = Deserialize::deserialize(deserializer)?;
coloru_from_hex_string(&s)
.map(Into::into)
.map_err(de::Error::custom)
}
pub fn serialize<S, C>(color: &C, serializer: S) -> Result<S::Ok, S::Error>
where
C: Into<ColorU> + Clone,
S: Serializer,
{
let coloru: ColorU = color.to_owned().into();
coloru_to_hex_string(&coloru).serialize(serializer)
}
+125
View File
@@ -0,0 +1,125 @@
use galaxyui::color::ColorU;
use self::contrast::{high_enough_contrast, pick_constrasting_color, MinimumAllowedContrast};
pub mod blend;
pub mod contrast;
pub mod hex_color;
/// Opacity of the given color expressed as %. Allowing for range 0..100 inclusive.
/// TODO: use a bounded type instead
pub type Opacity = u8;
pub const OPAQUE: u8 = 255;
/// Claude brand orange color (#E8704E)
pub const CLAUDE_ORANGE: ColorU = ColorU {
r: 232,
g: 112,
b: 78,
a: OPAQUE,
};
/// Simple type representing a color _without_ an alpha channel.
pub struct Rgb {
r: u8,
g: u8,
b: u8,
}
impl From<Rgb> for ColorU {
fn from(rgb: Rgb) -> Self {
Self {
r: rgb.r,
g: rgb.g,
b: rgb.b,
a: OPAQUE,
}
}
}
impl From<ColorU> for Rgb {
fn from(color: ColorU) -> Self {
Self {
r: color.r,
g: color.g,
b: color.b,
}
}
}
pub fn coloru_with_opacity(color: ColorU, opacity: Opacity) -> ColorU {
let new_alpha: u8 = (color.a as f32 * (opacity as f32 / 100.)) as u8;
ColorU::new(color.r, color.g, color.b, new_alpha)
}
/// mid_coloru determines a color 'in-between' the 2 colors (or simply, an average of 2 colors).
/// Currently used to figure the midpoint color for gradients (which is then needed for the font
/// color computation etc.).
pub fn mid_coloru(c1: ColorU, c2: ColorU) -> ColorU {
let r = (c1.r as f32 + c2.r as f32) / 2.;
let g = (c1.g as f32 + c2.g as f32) / 2.;
let b = (c1.b as f32 + c2.b as f32) / 2.;
ColorU::new(r as u8, g as u8, b as u8, OPAQUE)
}
/// "those are kinda arbitrary" -- Agata. We could tweak these factors.
const DARKEN_COLORU_SHADE_FACTOR: f32 = 0.52;
const LIGHTEN_COLORU_SHADE_FACTOR: f32 = 0.5;
/// Finds a darker version of the given color using DARKEN_COLORU_SHADE_FACTOR form factor.
pub fn darken(c: ColorU) -> ColorU {
let shade_factor = 1. - DARKEN_COLORU_SHADE_FACTOR;
let r = ((c.r as f32) * shade_factor).ceil() as u8;
let g = ((c.g as f32) * shade_factor).ceil() as u8;
let b = ((c.b as f32) * shade_factor).ceil() as u8;
ColorU::new(r, g, b, c.a)
}
/// Finds a ligher version of the given color using LIGHTEN_COLORU_SHADE_FACTOR form factor.
pub fn lighten(c: ColorU) -> ColorU {
// aplying the shade factor only to the difference between 255 and channel
// (doing so to the actual channel value could produce incorrect results
// since channels are capped at 255 value).
let r = ((OPAQUE - c.r) as f32 * LIGHTEN_COLORU_SHADE_FACTOR).ceil() as u8;
let g = ((OPAQUE - c.g) as f32 * LIGHTEN_COLORU_SHADE_FACTOR).ceil() as u8;
let b = ((OPAQUE - c.b) as f32 * LIGHTEN_COLORU_SHADE_FACTOR).ceil() as u8;
// in the result, we add the computed value to the current channel value
// to get the actual lighter color.
ColorU::new(r + c.r, g + c.g, b + c.b, c.a)
}
pub fn pick_foreground_color(background: ColorU) -> ColorU {
pick_constrasting_color(background, ColorU::black(), ColorU::white())
}
pub trait ContrastingColor<Rhs = Self> {
type Output;
fn on_background(
self,
background: Rhs,
minimum_allowed_contrast: MinimumAllowedContrast,
) -> Self::Output;
}
impl ContrastingColor for ColorU {
type Output = ColorU;
fn on_background(
self,
background: ColorU,
minimum_allowed_contrast: MinimumAllowedContrast,
) -> ColorU {
if !high_enough_contrast(background, self, minimum_allowed_contrast) {
return contrast::foreground_color_with_minimum_contrast(
self,
background.into(),
minimum_allowed_contrast,
);
}
self
}
}
#[cfg(test)]
#[path = "color_tests.rs"]
mod tests;
@@ -0,0 +1,42 @@
use crate::ui::theme::Fill;
use galaxyui::elements::Icon as WarpUiIcon;
pub enum ExternalProductIcon {
Heroku,
Notion,
Linear,
Figma,
Github,
Slack,
}
impl ExternalProductIcon {
pub fn from_string(s: &str) -> Option<ExternalProductIcon> {
let s_lower = s.to_ascii_lowercase();
match s_lower.as_str() {
"heroku" => Some(ExternalProductIcon::Heroku),
"notion" => Some(ExternalProductIcon::Notion),
"linear" => Some(ExternalProductIcon::Linear),
"figma" => Some(ExternalProductIcon::Figma),
"github" => Some(ExternalProductIcon::Github),
"slack" => Some(ExternalProductIcon::Slack),
_other => None,
}
}
pub fn get_path(&self) -> &'static str {
match self {
ExternalProductIcon::Heroku => "bundled/svg/heroku.svg",
ExternalProductIcon::Notion => "bundled/svg/notion.svg",
ExternalProductIcon::Linear => "bundled/svg/linear.svg",
ExternalProductIcon::Figma => "bundled/svg/figma.svg",
ExternalProductIcon::Github => "bundled/svg/github.svg",
ExternalProductIcon::Slack => "bundled/svg/slack-logo.svg",
}
}
pub fn to_galaxyui_icon(&self, color: Fill) -> WarpUiIcon {
let path = self.get_path();
WarpUiIcon::new(path, color.into_solid())
}
}
+635
View File
@@ -0,0 +1,635 @@
use crate::ui::theme::Fill;
use galaxyui::elements::Icon as WarpUiIcon;
/// Default icon dimensions that apply to all icons used within the ui system.
pub const ICON_DIMENSIONS: f32 = 24.;
/// Icon enum to be used within the app in place of the galaxyui::elements::Icon directly. It
/// abstracts things like svg paths out and provides a utility method to convert into the actual Icon.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub enum Icon {
File,
NodeJS,
AtSign,
Menu,
Plus,
Copy,
Circle,
CircleFilled,
Queued,
Triangle,
CopyMenuItem,
Duplicate,
Notebook,
Workflow,
X,
DotsVertical,
DotsHorizontal,
Trash,
Terminal,
TerminalInput,
TextInput,
ListCollapsed,
ListOpen,
AddTeammates,
Folder,
Find,
FindAll,
Globe,
Globe4,
Search,
Lightbulb,
LightbulbFilled,
Gear,
Settings,
Keyboard,
AiAssistant,
Rename,
Share,
Share3,
LogOut,
Move,
TextBlock,
RunnableCommandBlock,
BulletedListBlock,
OrderedListBlock,
HorizontalRuleBlock,
EmbedBlock,
TaskListBlock,
HeaderBlock,
Cloud,
CloudFilled,
CloudOffline,
Compass,
CreateTeam,
WarpDrive,
Warp,
WarpLogoLight,
ArrowLeft,
ArrowBlockLeft,
ArrowBlockUp,
ArrowRight,
ArrowUp,
ArrowDown,
ArrowSplit,
LinkExternal,
CheckCircleBroken,
Link,
Refresh,
RefreshCcw,
RefreshCw04,
AlertTriangle,
Laptop,
Tool,
Tool2,
CalendarDate,
Gift,
CalendarCheck,
AlphaDescending,
AlphaAscending,
ReverseLeft,
Pencil,
Sort,
Check,
FilterFunnel,
FilterFunnelFilled,
FilterOff,
Bug,
Code1,
Code2,
Explain,
Rocket,
Rocket1,
Regex,
CaseSensitivity,
PreserveCase,
Import,
Download,
XCircle,
SearchSmall,
VimNormalMode,
VimInsertMode,
VimVisualMode,
VimReplaceMode,
Info,
LinkHorizontal,
Clock,
Maximize,
Minimize,
Sharing,
DistributeSpacingVertical,
ChevronLeft,
ChevronDown,
ChevronRight,
ChevronUp,
ChevronRightDouble,
ChevronLeftDouble,
ArrowDropDown,
AlertCircle,
Bold,
Italic,
Underline,
InlineCode,
Strikethrough,
Stars,
AgentMode,
AmbientAgentMode,
Github,
Docker,
Linear,
Slack,
Loading,
Warning,
HelpCircle,
ThumbsUp,
ThumbsDown,
Repeat,
Edit,
EditLine,
Eye,
Slash,
ClockRefresh,
ClockRewind,
ClockLoader,
Paperclip,
EnvVarCollection,
CornerRight,
DashedRectangle,
CornersOfBox,
Microphone,
Stop,
StopFilled,
MinusCircle,
Minus,
Key,
OnePassword,
LastPass,
SlashCircle,
User,
Users,
CoinsStacked,
Phone,
Navigation,
AutoUpdate,
Bell,
GitBranch,
CheckSkinny,
Lock,
Save,
CornerDownLeft,
Powershell,
GitBash,
Ubuntu,
Debian,
Kali,
Arch,
Linux,
Cancelled,
BookOpen,
LoadingDots0,
LoadingDots1,
LoadingDots2,
LoadingDots3,
LoadingBlinker0,
LoadingBlinker1,
LoadingBlinker2,
PackageCheck,
Liftoff0,
Liftoff1,
Liftoff2,
Liftoff3,
Liftoff4,
LoadingAgents0,
LoadingAgents1,
LoadingAgents2,
LoadingAgents3,
LoadingAgents4,
LoadingAgents5,
LoadingAgents6,
LoadingAgents7,
Warping0,
Warping1,
Warping2,
Warping3,
Warping4,
Warping5,
Warping6,
Warping7,
Warping8,
Warping9,
Warping10,
Warping11,
FlipForward,
PaintBrush,
GamingPad,
Neurology,
Lightning,
Orbit,
PlusCircle,
Dataflow,
Play,
MessageText,
NewConversation,
Image,
FastForward,
FastForwardFilled,
ContextWindowTwentyPct,
ContextWindowFourtyPct,
ContextWindowSixtyPct,
ContextWindowWarning,
ContextWindowSummarized,
ConversationContext0,
ConversationContext10,
ConversationContext20,
ConversationContext30,
ConversationContext40,
ConversationContext50,
ConversationContext60,
ConversationContext70,
ConversationContext80,
ConversationContext90,
ConversationContext100,
LeftSidebarOpen,
LeftSidebarClose,
Diff,
ExpandUp,
ExpandDown,
ExpandUpAndDown,
Psychology,
History,
SlashCommands,
MessagePlusSquare,
Hash,
FolderClosed,
FileCopy,
Credits,
AddressedComment,
ClockSnooze,
Hand,
ArrowCircleBrokenUp,
FilterLines,
ChatDashed,
ClaudeLogo,
GeminiLogo,
OpenAILogo,
AmpLogo,
DroidLogo,
OpenCodeLogo,
CopilotLogo,
PiLogo,
AuggieLogo,
BedrockLogo,
CursorLogo,
NLD,
Oz,
OzCloud,
Conversation,
Prompt,
Grid,
Figma,
FigmaColored,
StripeLogo,
CalloutTriangleBorderDown,
CalloutTriangleFillDown,
CalloutTriangleBorderUp,
CalloutTriangleFillUp,
CalloutTriangleBorderLeft,
CalloutTriangleFillLeft,
DragIndicator,
Ellipse,
Inbox,
Menu01,
LayoutAlt01,
Dataflow02,
Sliders,
MessageCheckSquare,
Phone01,
GitCommit,
UploadCloud,
ClockPlus,
SwitchHorizontal01,
HeartHand,
MessageChatSquare,
}
impl From<Icon> for &'static str {
fn from(icon: Icon) -> &'static str {
match icon {
Icon::Menu => "bundled/svg/layout-left.svg",
Icon::AtSign => "bundled/svg/at-sign.svg",
Icon::Plus => "bundled/svg/plus.svg",
Icon::Copy => "bundled/svg/copy.svg",
Icon::Circle => "bundled/svg/circle.svg",
Icon::CircleFilled => "bundled/svg/circle-filled.svg",
Icon::Triangle => "bundled/svg/triangle.svg",
Icon::Queued => "bundled/svg/queued.svg",
Icon::CopyMenuItem => "bundled/svg/copy-05.svg",
Icon::Duplicate => "bundled/svg/copy-07.svg",
Icon::Notebook => "bundled/svg/notebook.svg",
Icon::Workflow => "bundled/svg/workflow.svg",
Icon::X => "bundled/svg/x-close.svg",
Icon::ChevronLeft => "bundled/svg/chevron-left.svg",
Icon::DotsVertical => "bundled/svg/dots-vertical.svg",
Icon::DotsHorizontal => "bundled/svg/dots-horizontal.svg",
Icon::Globe => "bundled/svg/globe-01.svg",
Icon::Globe4 => "bundled/svg/globe-04.svg",
Icon::Trash => "bundled/svg/trash-02.svg",
Icon::Terminal => "bundled/svg/terminal.svg",
Icon::TerminalInput => "bundled/svg/terminal-input.svg",
Icon::TextInput => "bundled/svg/text-input.svg",
Icon::ListCollapsed => "bundled/svg/chevron-right-2.svg",
Icon::ListOpen => "bundled/svg/chevron-down-2.svg",
Icon::Microphone => "bundled/svg/microphone.svg",
Icon::Stop => "bundled/svg/stop.svg",
Icon::StopFilled => "bundled/svg/stop-filled.svg",
Icon::AddTeammates => "bundled/svg/user-plus-01.svg",
Icon::Folder => "bundled/svg/folder.svg",
Icon::Find => "bundled/svg/find.svg",
Icon::FindAll => "bundled/svg/find-all.svg",
Icon::Search => "bundled/svg/search.svg",
Icon::Lightbulb => "bundled/svg/lightbulb.svg",
Icon::LightbulbFilled => "bundled/svg/lightbulb-filled.svg",
Icon::Gear => "bundled/svg/gear.svg",
Icon::Settings => "bundled/svg/settings.svg",
Icon::Keyboard => "bundled/svg/keyboard.svg",
Icon::AiAssistant => "bundled/svg/ai-assistant.svg",
Icon::Rename => "bundled/svg/pencil-line.svg",
Icon::Share => "bundled/svg/share-01.svg",
Icon::Share3 => "bundled/svg/share-03.svg",
Icon::LogOut => "bundled/svg/log-out-01.svg",
Icon::Move => "bundled/svg/move.svg",
Icon::TextBlock => "bundled/svg/block-text.svg",
Icon::RunnableCommandBlock => "bundled/svg/block-command.svg",
Icon::HeaderBlock => "bundled/svg/block-header.svg",
Icon::HorizontalRuleBlock => "bundled/svg/block-horizontal-rule.svg",
Icon::EmbedBlock => "bundled/svg/block-embed.svg",
Icon::BulletedListBlock => "bundled/svg/block-bulletedlist.svg",
Icon::OrderedListBlock => "bundled/svg/block-ordered-list.svg",
Icon::TaskListBlock => "bundled/svg/block-tasklist.svg",
Icon::Cloud => "bundled/svg/cloud-01.svg",
Icon::CloudFilled => "bundled/svg/cloud-filled.svg",
Icon::CloudOffline => "bundled/svg/cloud-offline.svg",
Icon::Compass => "bundled/svg/compass-3.svg",
Icon::CreateTeam => "bundled/svg/create-team.svg",
Icon::WarpDrive => "bundled/svg/warp.svg",
Icon::Warp => "bundled/svg/warp-drive.svg",
Icon::WarpLogoLight => "bundled/svg/warp-logo-light.svg",
Icon::ArrowLeft => "bundled/svg/arrow-left.svg",
Icon::ArrowBlockLeft => "bundled/svg/arrow-block-left.svg",
Icon::ArrowBlockUp => "bundled/svg/arrow-block-up.svg",
Icon::ArrowRight => "bundled/svg/arrow-right.svg",
Icon::ArrowUp => "bundled/svg/arrow-narrow-up.svg",
Icon::ArrowDown => "bundled/svg/arrow-narrow-down.svg",
Icon::ArrowSplit => "bundled/svg/arrow-split.svg",
Icon::SwitchHorizontal01 => "bundled/svg/switch-horizontal-01.svg",
Icon::ArrowDropDown => "bundled/svg/arrow-drop-down.svg",
Icon::CheckCircleBroken => "bundled/svg/check-circle-broken.svg",
Icon::LinkExternal => "bundled/svg/link-external-02.svg",
Icon::Link => "bundled/svg/link-03.svg",
Icon::Refresh => "bundled/svg/refresh.svg",
Icon::RefreshCcw => "bundled/svg/refresh-ccw-01.svg",
Icon::RefreshCw04 => "bundled/svg/refresh-cw-04.svg",
Icon::AlertTriangle => "bundled/svg/alert-triangle.svg",
Icon::Laptop => "bundled/svg/laptop.svg",
Icon::Tool => "bundled/svg/tool-01.svg",
Icon::Tool2 => "bundled/svg/tool-02.svg",
Icon::CalendarDate => "bundled/svg/calendar-date.svg",
Icon::Gift => "bundled/svg/gift-01.svg",
Icon::CalendarCheck => "bundled/svg/calendar-check-01.svg",
Icon::AlphaDescending => "bundled/svg/Alpha descending.svg",
Icon::AlphaAscending => "bundled/svg/Alpha ascending.svg",
Icon::ReverseLeft => "bundled/svg/reverse-left.svg",
Icon::Pencil => "bundled/svg/pencil-02.svg",
Icon::Sort => "bundled/svg/sort.svg",
Icon::Check => "bundled/svg/check.svg",
Icon::FilterFunnel => "bundled/svg/filter-funnel.svg",
Icon::FilterFunnelFilled => "bundled/svg/filter-funnel-filled.svg",
Icon::FilterOff => "bundled/svg/filter-list-off.svg",
Icon::Bug => "bundled/svg/bug.svg",
Icon::Code1 => "bundled/svg/code-01.svg",
Icon::Code2 => "bundled/svg/code-02.svg",
Icon::Explain => "bundled/svg/explain.svg",
Icon::Rocket => "bundled/svg/rocket.svg",
Icon::Rocket1 => "bundled/svg/rocket-01.svg",
Icon::Regex => "bundled/svg/regex.svg",
Icon::CaseSensitivity => "bundled/svg/case-sensitive.svg",
Icon::PreserveCase => "bundled/svg/preserve-case.svg",
Icon::Import => "bundled/svg/import.svg",
Icon::Download => "bundled/svg/download-02.svg",
Icon::XCircle => "bundled/svg/x-circle.svg",
Icon::SearchSmall => "bundled/svg/search-small.svg",
Icon::VimNormalMode => "bundled/svg/vim-normal-mode.svg",
Icon::VimInsertMode => "bundled/svg/vim-insert-mode.svg",
Icon::VimVisualMode => "bundled/svg/vim-visual-mode.svg",
Icon::VimReplaceMode => "bundled/svg/vim-replace-mode.svg",
Icon::Info => "bundled/svg/info.svg",
Icon::LinkHorizontal => "bundled/svg/link-horizontal.svg",
Icon::Clock => "bundled/svg/clock.svg",
Icon::Maximize => "bundled/svg/maximize-01.svg",
Icon::Minimize => "bundled/svg/minimize-01.svg",
Icon::Sharing => "bundled/svg/sharing.svg",
Icon::DistributeSpacingVertical => "bundled/svg/distribute-spacing-vertical.svg",
Icon::ChevronDown => "bundled/svg/chevron-down.svg",
Icon::ChevronUp => "bundled/svg/chevron-up.svg",
Icon::ChevronRightDouble => "bundled/svg/chevron-right-double.svg",
Icon::ChevronLeftDouble => "bundled/svg/chevron-left-double.svg",
Icon::AlertCircle => "bundled/svg/alert-circle.svg",
Icon::Bold => "bundled/svg/edit-bold.svg",
Icon::Italic => "bundled/svg/edit-italic.svg",
Icon::Underline => "bundled/svg/edit-underline.svg",
Icon::InlineCode => "bundled/svg/edit-code.svg",
Icon::Strikethrough => "bundled/svg/edit-strikethrough.svg",
Icon::Stars => "bundled/svg/stars-01.svg",
Icon::AgentMode => "bundled/svg/agentmode.svg",
Icon::AmbientAgentMode => "bundled/svg/ambient-agent-mode.svg",
Icon::Github => "bundled/svg/github.svg",
Icon::Docker => "bundled/svg/docker.svg",
Icon::Linear => "bundled/svg/linear.svg",
Icon::Slack => "bundled/svg/slack-logo.svg",
Icon::ChevronRight => "bundled/svg/chevron-right.svg",
Icon::Loading => "bundled/svg/loading-02.svg",
Icon::Warning => "bundled/svg/warning.svg",
Icon::HelpCircle => "bundled/svg/help-circle.svg",
Icon::ThumbsUp => "bundled/svg/thumbs-up.svg",
Icon::ThumbsDown => "bundled/svg/thumbs-down.svg",
Icon::Repeat => "bundled/svg/repeat-01.svg",
Icon::Edit => "bundled/svg/edit-01.svg",
Icon::EditLine => "bundled/svg/edit-03.svg",
Icon::Eye => "bundled/svg/eye.svg",
Icon::Slash => "bundled/svg/slash.svg",
Icon::ClockRefresh => "bundled/svg/clock-refresh.svg",
Icon::ClockRewind => "bundled/svg/clock-rewind.svg",
Icon::ClockLoader => "bundled/svg/clock-loader.svg",
Icon::Paperclip => "bundled/svg/paperclip.svg",
Icon::EnvVarCollection => "bundled/svg/env-var-collection.svg",
Icon::CornerRight => "bundled/svg/corner-right.svg",
Icon::DashedRectangle => "bundled/svg/dashed-rectangle.svg",
Icon::CornersOfBox => "bundled/svg/corners-of-box.svg",
Icon::MinusCircle => "bundled/svg/minus-circle.svg",
Icon::Minus => "bundled/svg/minus.svg",
Icon::Key => "bundled/svg/key.svg",
Icon::OnePassword => "bundled/svg/onepassword.svg",
Icon::LastPass => "bundled/svg/lastpass.svg",
Icon::SlashCircle => "bundled/svg/slash-circle-01.svg",
Icon::User => "bundled/svg/user-02.svg",
Icon::Users => "bundled/svg/users-02.svg",
Icon::CoinsStacked => "bundled/svg/coins-stacked-02.svg",
Icon::Phone => "bundled/svg/phone.svg",
Icon::Navigation => "bundled/svg/navigation.svg",
Icon::AutoUpdate => "bundled/svg/autoupdate.svg",
Icon::Bell => "bundled/svg/bell.svg",
Icon::GitBranch => "bundled/svg/git-branch-02.svg",
Icon::CheckSkinny => "bundled/svg/check-skinny.svg",
Icon::Lock => "bundled/svg/lock-unlocked-01.svg",
Icon::Save => "bundled/svg/download-01.svg",
Icon::CornerDownLeft => "bundled/svg/corner-down-left.svg",
Icon::Powershell => "bundled/svg/powershell.svg",
Icon::GitBash => "bundled/svg/git-bash.svg",
Icon::Ubuntu => "bundled/svg/ubuntu.svg",
Icon::Debian => "bundled/svg/debian.svg",
Icon::Kali => "bundled/svg/kali.svg",
Icon::Arch => "bundled/svg/arch.svg",
Icon::Linux => "bundled/svg/linux.svg",
Icon::Cancelled => "bundled/svg/cancelled.svg",
Icon::BookOpen => "bundled/svg/book-open.svg",
Icon::LoadingDots0 => "bundled/svg/dots-0.svg",
Icon::LoadingDots1 => "bundled/svg/dots-1.svg",
Icon::LoadingDots2 => "bundled/svg/dots-2.svg",
Icon::LoadingDots3 => "bundled/svg/dots-3.svg",
Icon::LoadingBlinker0 => "bundled/svg/blinker-0.svg",
Icon::LoadingBlinker1 => "bundled/svg/blinker-1.svg",
Icon::LoadingBlinker2 => "bundled/svg/blinker-2.svg",
Icon::PackageCheck => "bundled/svg/package-check.svg",
Icon::Liftoff0 => "bundled/svg/liftoff-0.svg",
Icon::Liftoff1 => "bundled/svg/liftoff-1.svg",
Icon::Liftoff2 => "bundled/svg/liftoff-2.svg",
Icon::Liftoff3 => "bundled/svg/liftoff-3.svg",
Icon::Liftoff4 => "bundled/svg/liftoff-4.svg",
Icon::LoadingAgents0 => "bundled/svg/loading-agents-01.svg",
Icon::LoadingAgents1 => "bundled/svg/loading-agents-02.svg",
Icon::LoadingAgents2 => "bundled/svg/loading-agents-03.svg",
Icon::LoadingAgents3 => "bundled/svg/loading-agents-04.svg",
Icon::LoadingAgents4 => "bundled/svg/loading-agents-05.svg",
Icon::LoadingAgents5 => "bundled/svg/loading-agents-06.svg",
Icon::LoadingAgents6 => "bundled/svg/loading-agents-07.svg",
Icon::LoadingAgents7 => "bundled/svg/loading-agents-08.svg",
Icon::Warping0 => "bundled/svg/warp-loading-0.svg",
Icon::Warping1 => "bundled/svg/warp-loading-1.svg",
Icon::Warping2 => "bundled/svg/warp-loading-2.svg",
Icon::Warping3 => "bundled/svg/warp-loading-3.svg",
Icon::Warping4 => "bundled/svg/warp-loading-4.svg",
Icon::Warping5 => "bundled/svg/warp-loading-5.svg",
Icon::Warping6 => "bundled/svg/warp-loading-6.svg",
Icon::Warping7 => "bundled/svg/warp-loading-7.svg",
Icon::Warping8 => "bundled/svg/warp-loading-8.svg",
Icon::Warping9 => "bundled/svg/warp-loading-9.svg",
Icon::Warping10 => "bundled/svg/warp-loading-10.svg",
Icon::Warping11 => "bundled/svg/warp-loading-11.svg",
Icon::FlipForward => "bundled/svg/flip-forward.svg",
Icon::PaintBrush => "bundled/svg/brush-01.svg",
Icon::GamingPad => "bundled/svg/gaming-pad-01.svg",
Icon::Neurology => "bundled/svg/neurology.svg",
Icon::Lightning => "bundled/svg/lightning-02.svg",
Icon::Orbit => "bundled/svg/orbit.svg",
Icon::PlusCircle => "bundled/svg/plus-circle.svg",
Icon::Dataflow => "bundled/svg/dataflow.svg",
Icon::Play => "bundled/svg/play-white.svg",
Icon::MessageText => "bundled/svg/message-text-square-02.svg",
Icon::NewConversation => "bundled/svg/new-conversation.svg",
Icon::Image => "bundled/svg/image-01.svg",
Icon::File => "bundled/svg/file.svg",
Icon::NodeJS => "bundled/svg/nodejs-logo.svg",
Icon::FastForward => "bundled/svg/fast-forward.svg",
Icon::FastForwardFilled => "bundled/svg/fast-forward-filled.svg",
Icon::ContextWindowTwentyPct => "bundled/svg/context-window-20-pct.svg",
Icon::ContextWindowFourtyPct => "bundled/svg/context-window-40-pct.svg",
Icon::ContextWindowSixtyPct => "bundled/svg/context-window-60-pct.svg",
Icon::ContextWindowWarning => "bundled/svg/context-window-warning.svg",
Icon::ContextWindowSummarized => "bundled/svg/context-window-summarized.svg",
Icon::ConversationContext0 => "bundled/svg/conversation-context-0.svg",
Icon::ConversationContext10 => "bundled/svg/conversation-context-10.svg",
Icon::ConversationContext20 => "bundled/svg/conversation-context-20.svg",
Icon::ConversationContext30 => "bundled/svg/conversation-context-30.svg",
Icon::ConversationContext40 => "bundled/svg/conversation-context-40.svg",
Icon::ConversationContext50 => "bundled/svg/conversation-context-50.svg",
Icon::ConversationContext60 => "bundled/svg/conversation-context-60.svg",
Icon::ConversationContext70 => "bundled/svg/conversation-context-70.svg",
Icon::ConversationContext80 => "bundled/svg/conversation-context-80.svg",
Icon::ConversationContext90 => "bundled/svg/conversation-context-90.svg",
Icon::ConversationContext100 => "bundled/svg/conversation-context-100.svg",
Icon::LeftSidebarOpen => "bundled/svg/left-panel-open.svg",
Icon::LeftSidebarClose => "bundled/svg/left-panel-close.svg",
Icon::Diff => "bundled/svg/diff.svg",
Icon::ExpandUp => "bundled/svg/expand-up.svg",
Icon::ExpandDown => "bundled/svg/expand-down.svg",
Icon::ExpandUpAndDown => "bundled/svg/expand-up-and-down.svg",
Icon::Psychology => "bundled/svg/psychology.svg",
Icon::History => "bundled/svg/history.svg",
Icon::SlashCommands => "bundled/svg/slash-square.svg",
Icon::MessagePlusSquare => "bundled/svg/message-plus-square.svg",
Icon::Hash => "bundled/svg/hash-02.svg",
Icon::FolderClosed => "bundled/svg/folder-closed.svg",
Icon::FileCopy => "bundled/svg/file_copy.svg",
Icon::Credits => "bundled/svg/credits.svg",
Icon::AddressedComment => "bundled/svg/addressed-comment.svg",
Icon::ClockSnooze => "bundled/svg/clock-snooze.svg",
Icon::Hand => "bundled/svg/hand.svg",
Icon::ArrowCircleBrokenUp => "bundled/svg/arrow-circle-broken-up.svg",
Icon::FilterLines => "bundled/svg/filter-lines.svg",
Icon::ChatDashed => "bundled/svg/chat-dashed.svg",
Icon::ClaudeLogo => "bundled/svg/claude.svg",
Icon::GeminiLogo => "bundled/svg/gemini_cli.svg",
Icon::OpenAILogo => "bundled/svg/openai.svg",
Icon::BedrockLogo => "bundled/svg/bedrock.svg",
Icon::AmpLogo => "bundled/svg/amp.svg",
Icon::DroidLogo => "bundled/svg/droid.svg",
Icon::OpenCodeLogo => "bundled/svg/opencode.svg",
Icon::CopilotLogo => "bundled/svg/copilot.svg",
Icon::PiLogo => "bundled/svg/pi.svg",
Icon::AuggieLogo => "bundled/svg/auggie.svg",
Icon::CursorLogo => "bundled/svg/cursor.svg",
Icon::NLD => "bundled/svg/nld.svg",
Icon::Oz => "bundled/svg/oz.svg",
Icon::OzCloud => "bundled/svg/oz-cloud.svg",
Icon::Conversation => "bundled/svg/conversation.svg",
Icon::Prompt => "bundled/svg/prompt.svg",
Icon::Grid => "bundled/svg/grid.svg",
Icon::Figma => "bundled/svg/figma.svg",
Icon::FigmaColored => "bundled/svg/figma-colored.svg",
Icon::StripeLogo => "bundled/svg/stripe.svg",
Icon::CalloutTriangleBorderDown => "bundled/svg/callout-triangle-border-down.svg",
Icon::CalloutTriangleFillDown => "bundled/svg/callout-triangle-fill-down.svg",
Icon::CalloutTriangleBorderUp => "bundled/svg/callout-triangle-border-up.svg",
Icon::CalloutTriangleFillUp => "bundled/svg/callout-triangle-fill-up.svg",
Icon::CalloutTriangleBorderLeft => "bundled/svg/callout-triangle-border-left.svg",
Icon::CalloutTriangleFillLeft => "bundled/svg/callout-triangle-fill-left.svg",
Icon::DragIndicator => "bundled/svg/drag_indicator.svg",
Icon::Ellipse => "bundled/svg/ellipse.svg",
Icon::Inbox => "bundled/svg/inbox-01.svg",
Icon::Menu01 => "bundled/svg/menu-01.svg",
Icon::LayoutAlt01 => "bundled/svg/layout-alt-01.svg",
Icon::Dataflow02 => "bundled/svg/dataflow-02.svg",
Icon::Sliders => "bundled/svg/sliders-04.svg",
Icon::MessageCheckSquare => "bundled/svg/message-check-square.svg",
Icon::Phone01 => "bundled/svg/phone-01.svg",
Icon::GitCommit => "bundled/svg/git-commit.svg",
Icon::UploadCloud => "bundled/svg/upload-cloud-01.svg",
Icon::ClockPlus => "bundled/svg/clock-plus.svg",
Icon::HeartHand => "bundled/svg/heart-hand.svg",
Icon::MessageChatSquare => "bundled/svg/message-chat-square.svg",
}
}
}
impl Icon {
pub fn to_galaxyui_icon(self, color: Fill) -> WarpUiIcon {
WarpUiIcon::new(self.into(), color.into_solid())
}
pub fn icon_for_key(key: &str) -> Option<WarpUiIcon> {
match key {
"" => Some(Self::CornerDownLeft.to_galaxyui_icon(Fill::black())),
_ => None,
}
}
}
+8
View File
@@ -0,0 +1,8 @@
pub mod appearance;
pub mod builder;
pub mod color;
pub mod external_product_icon;
pub mod icons;
pub mod theme;
pub use icons::Icon;
+599
View File
@@ -0,0 +1,599 @@
//! Module providing utility functions to retrieve the colors used within our ui system and
//! designs.
//! These colors can be further understood here:
//! https://docs.google.com/document/d/1YMovEoXsPRziPk99a4i9LZNEKGm_rjEyzhcHsFkT3ac/edit.
use self::internal_colors::{
accent_overlay_2, fg_overlay_1, fg_overlay_2, fg_overlay_3, neutral_1, neutral_2, neutral_3,
neutral_4,
};
use super::{AnsiColor, AnsiColorIdentifier, Fill, TerminalColors, WarpTheme};
use crate::ui::color::{
blend::Blend,
contrast::{pick_best_foreground_color, MinimumAllowedContrast},
Opacity,
};
use getset::Getters;
use serde::{Deserialize, Serialize};
use galaxyui::color::ColorU;
const BLOCK_SELECTION_OPACITY: Opacity = 10;
#[derive(Serialize, Copy, Clone, Debug, Deserialize, Getters, PartialEq, Eq)]
#[get = "pub"]
// TODO handle optional fields (so users can specify some and not all)
pub struct CustomDetails {
pub main_text_opacity: Opacity,
pub sub_text_opacity: Opacity,
pub hint_text_opacity: Opacity,
pub disabled_text_opacity: Opacity,
pub foreground_button_opacity: Opacity, // foreground color overlay on the std button
pub accent_button_opacity: Opacity, // foreground color overlay on the active button
pub button_hover_opacity: Opacity, // foreground color overlay on the button
pub button_click_opacity: Opacity, // bg color overlay on the button
pub keybinding_row_overlay_opacity: Opacity,
pub welcome_tips_completion_overlay_opacity: Opacity,
}
const DARKER_DETAILS: CustomDetails = CustomDetails {
main_text_opacity: 90,
sub_text_opacity: 60,
hint_text_opacity: 40,
disabled_text_opacity: 20,
foreground_button_opacity: 30,
accent_button_opacity: 0,
button_hover_opacity: 10,
button_click_opacity: 20,
keybinding_row_overlay_opacity: 40,
welcome_tips_completion_overlay_opacity: 90,
};
const LIGHTER_DETAILS: CustomDetails = CustomDetails {
main_text_opacity: 90,
sub_text_opacity: 60,
hint_text_opacity: 40,
disabled_text_opacity: 20,
foreground_button_opacity: 30,
accent_button_opacity: 0,
button_hover_opacity: 10,
button_click_opacity: 20,
keybinding_row_overlay_opacity: 40,
welcome_tips_completion_overlay_opacity: 90,
};
impl CustomDetails {
pub fn darker_details() -> Self {
DARKER_DETAILS
}
pub fn lighter_details() -> Self {
LIGHTER_DETAILS
}
}
impl Default for CustomDetails {
fn default() -> Self {
DARKER_DETAILS
}
}
// Core colors
impl WarpTheme {
pub fn accent(&self) -> Fill {
self.accent
}
pub fn foreground(&self) -> Fill {
Fill::Solid(self.foreground)
}
/// Background color for large backgrounds like that of the terminal view.
/// Allows gradients because these are meant to be very large surfaces.
pub fn background(&self) -> Fill {
self.background
}
pub fn terminal_colors(&self) -> &TerminalColors {
&self.terminal_colors
}
/// Background color for UI elements that need to stand out from the main
/// `background()` color and the `surface_1()`` and `surface_2()`` backgrounds.
/// Doesn't allow gradients because these surfaces will often be too small
/// for the gradients to look appealing.
pub fn surface_3(&self) -> Fill {
Fill::Solid(neutral_3(self))
}
/// Background color for UI elements that need to stand out from the main
/// `background()` color and the `surface_1()` color.
/// Doesn't allow gradients because these surfaces will often be too small
/// for the gradients to look appealing.
pub fn surface_2(&self) -> Fill {
Fill::Solid(neutral_2(self))
}
/// Background color for UI elements that need to stand out from the main
/// `background()` color.
/// Doesn't allow gradients because these surfaces will often be too small
/// for the gradients to look appealing.
pub fn surface_1(&self) -> Fill {
Fill::Solid(neutral_1(self))
}
pub fn cursor(&self) -> Fill {
self.cursor.unwrap_or(self.accent())
}
pub fn ui_warning_color(&self) -> ColorU {
ColorU::new(194, 128, 0, 255)
}
pub fn ui_error_color(&self) -> ColorU {
ColorU::new(188, 54, 42, 255)
}
pub fn ui_yellow_color(&self) -> ColorU {
ColorU::new(229, 160, 26, 255)
}
pub fn ui_green_color(&self) -> ColorU {
ColorU::new(28, 160, 90, 255)
}
pub fn outline(&self) -> Fill {
fg_overlay_2(self)
}
// text colors
pub fn font_color(&self, background: impl Into<ColorU>) -> Fill {
Fill::Solid(pick_best_foreground_color(
background.into(),
self.background().into(),
self.foreground().into(),
MinimumAllowedContrast::Text,
))
}
pub fn main_text_color(&self, background: Fill) -> Fill {
internal_colors::text_main(self, background).into()
}
pub fn sub_text_color(&self, background: Fill) -> Fill {
internal_colors::text_sub(self, background).into()
}
pub fn hint_text_color(&self, background: Fill) -> Fill {
let details = self.details();
self.font_color(background)
.with_opacity(details.hint_text_opacity)
}
pub fn disabled_text_color(&self, background: Fill) -> Fill {
internal_colors::text_disabled(self, background).into()
}
pub fn active_ui_text_color(&self) -> Fill {
self.main_text_color(self.surface_2())
}
pub fn nonactive_ui_text_color(&self) -> Fill {
self.sub_text_color(self.surface_2())
}
pub fn disabled_ui_text_color(&self) -> Fill {
self.disabled_text_color(self.surface_2())
}
pub fn active_highlighted_text_color(&self) -> Fill {
self.main_text_color(self.accent())
}
pub fn settings_import_config_hover_opacity(&self) -> Opacity {
10
}
pub fn dark_overlay(&self) -> Fill {
let details = self.details();
Fill::black().with_opacity(details.button_click_opacity)
}
pub fn keybinding_row_overlay(&self) -> Fill {
let details = self.details();
Fill::black().with_opacity(details.keybinding_row_overlay_opacity)
}
pub fn welcome_tips_completion_overlay(&self) -> Fill {
let details = self.details();
self.surface_2()
.with_opacity(details.welcome_tips_completion_overlay_opacity)
}
pub fn blurred_background_overlay(&self) -> Fill {
Fill::black().with_opacity(70)
}
}
// Feature-specific theme colors
impl WarpTheme {
pub fn foreground_button_color(&self) -> Fill {
let details = self.details();
self.background.blend(
&self
.foreground()
.with_opacity(details.foreground_button_opacity),
)
}
pub fn accent_button_color(&self) -> Fill {
let details = self.details();
self.accent.blend(
&self
.foreground()
.with_opacity(details.accent_button_opacity),
)
}
pub fn button_hover_opacity(&self, button: Fill) -> Fill {
let details = self.details();
button.blend(&self.foreground().with_opacity(details.button_hover_opacity))
}
pub fn split_pane_border_color(&self) -> Fill {
fg_overlay_3(self)
}
pub fn accent_overlay(&self) -> Fill {
accent_overlay_2(self)
}
pub fn surface_overlay_3(&self) -> Fill {
fg_overlay_3(self)
}
pub fn surface_overlay_2(&self) -> Fill {
fg_overlay_2(self)
}
pub fn surface_overlay_1(&self) -> Fill {
fg_overlay_1(self)
}
pub fn yellow_overlay_1(&self) -> Fill {
let yellow: Fill = self.ui_yellow_color().into();
yellow.with_opacity(10)
}
pub fn green_overlay_1(&self) -> Fill {
let green: Fill = self.ansi_fg_green().into();
green.with_opacity(10)
}
pub fn green_overlay_2(&self) -> Fill {
let green: Fill = self.ui_green_color().into();
green.with_opacity(50)
}
pub fn block_selection_color(&self) -> Fill {
accent_overlay_2(self)
}
pub fn block_selection_as_context_background_color(&self) -> Fill {
let color_fill: Fill = self.terminal_colors.normal.yellow.into();
color_fill.with_opacity(BLOCK_SELECTION_OPACITY)
}
pub fn block_selection_as_context_border_color(&self) -> Fill {
let color_fill: Fill = self.terminal_colors.normal.yellow.into();
color_fill
}
// Although text selection colors aren't yet themed, declaring them in this file
// will make it easier to theme text selection colors in the future!
pub fn text_selection_color(&self) -> Fill {
Fill::Solid(ColorU::new(118, 167, 250, (0.4 * 255.) as u8))
}
pub fn text_selection_as_context_color(&self) -> Fill {
self.ansi_overlay_2(self.terminal_colors.normal.yellow)
.into()
}
pub fn find_bar_button_selection_color(&self) -> Fill {
accent_overlay_2(self)
}
pub fn failed_block_color(&self) -> Fill {
Fill::Solid(self.terminal_colors().normal.red.into())
}
pub fn active_ui_detail(&self) -> Fill {
self.main_text_color(self.surface_2())
}
pub fn nonactive_ui_detail(&self) -> Fill {
self.disabled_text_color(self.surface_2())
}
/// We apply an overlay over the terminal view background. The default overlay opacity is low
/// so it doesn't conflict with window opacity adjustments.
pub fn ai_blocks_overlay(&self) -> Fill {
fg_overlay_1(self)
}
/// We apply an overlay over the terminal view background. The default overlay opacity is low
/// so it doesn't conflict with window opacity adjustments.
pub fn restored_blocks_overlay(&self) -> Fill {
fg_overlay_2(self)
}
/// We apply an overlay over the terminal view background. The default overlay opacity is low
/// so it doesn't conflict with window opacity adjustments.
pub fn restored_ai_blocks_overlay(&self) -> Fill {
fg_overlay_3(self)
}
pub fn inactive_pane_overlay(&self) -> Fill {
fg_overlay_2(self)
}
pub fn subshell_background(&self) -> Fill {
Fill::Solid(neutral_4(self))
}
pub fn block_banner_background(&self) -> Fill {
Fill::Solid(neutral_3(self))
}
/// Background color for tooltips.
/// Uses neutral_6 for better contrast with text.
pub fn tooltip_background(&self) -> ColorU {
internal_colors::neutral_6(self)
}
}
// ANSI color blends
impl WarpTheme {
pub fn ansi_bg(&self, ansi_color: AnsiColor) -> ColorU {
let ansi_fill = Fill::from(ansi_color);
self.background()
.blend(&ansi_fill.with_opacity(50))
.into_solid()
}
pub fn ansi_fg(&self, ansi_color: AnsiColor) -> ColorU {
let ansi_fill = Fill::from(ansi_color);
self.foreground()
.blend(&ansi_fill.with_opacity(50))
.into_solid()
}
pub fn ansi_overlay_1(&self, ansi_color: AnsiColor) -> ColorU {
Fill::from(ansi_color).with_opacity(10).into_solid()
}
pub fn ansi_overlay_2(&self, ansi_color: AnsiColor) -> ColorU {
Fill::from(ansi_color).with_opacity(50).into_solid()
}
pub fn ansi_fg_red(&self) -> ColorU {
self.ansi_fg(AnsiColorIdentifier::Red.to_ansi_color(&self.terminal_colors().normal))
}
pub fn ansi_bg_red(&self) -> ColorU {
self.ansi_bg(AnsiColorIdentifier::Red.to_ansi_color(&self.terminal_colors().normal))
}
pub fn ansi_fg_blue(&self) -> ColorU {
self.ansi_fg(AnsiColorIdentifier::Blue.to_ansi_color(&self.terminal_colors().normal))
}
pub fn ansi_fg_green(&self) -> ColorU {
self.ansi_fg(AnsiColorIdentifier::Green.to_ansi_color(&self.terminal_colors().normal))
}
pub fn ansi_bg_green(&self) -> ColorU {
self.ansi_bg(AnsiColorIdentifier::Green.to_ansi_color(&self.terminal_colors().normal))
}
pub fn ansi_fg_yellow(&self) -> ColorU {
self.ansi_fg(AnsiColorIdentifier::Yellow.to_ansi_color(&self.terminal_colors().normal))
}
pub fn ansi_fg_magenta(&self) -> ColorU {
self.ansi_fg(AnsiColorIdentifier::Magenta.to_ansi_color(&self.terminal_colors().normal))
}
pub fn ansi_fg_cyan(&self) -> ColorU {
self.ansi_fg(AnsiColorIdentifier::Cyan.to_ansi_color(&self.terminal_colors().normal))
}
}
/// Internal color system tokens, defined in "Colors" [Figma project](https://www.figma.com/design/dnvTdLbfFaosFSP00F30S0/Colors).
/// Should not be used directly outside of reusable components. Use color methods on `WarpTheme` instead.
pub mod internal_colors {
use galaxyui::color::ColorU;
use super::{Fill, WarpTheme};
use crate::ui::color::blend::Blend;
use crate::ui::color::coloru_with_opacity;
/// Calculates the font color based on contrast needs for text legibility.
/// The font color is a mixture of the `warp_theme`'s background and foreground
/// colors, and the supplied `background` color.
fn font_color(warp_theme: &WarpTheme, background: impl Into<ColorU>) -> ColorU {
warp_theme.font_color(background).into_solid()
}
/// Used for UI elements like buttons to which we want to call attention.
/// Allows gradients so shouldn't be used for small elements.
pub fn accent(warp_theme: &WarpTheme) -> Fill {
warp_theme.accent()
}
/// Hover state for UI elements like buttons to which we want to call attention.
/// Allows gradients so shouldn't be used for small elements.
pub fn accent_hover(warp_theme: &WarpTheme) -> Fill {
warp_theme
.accent()
.blend(&warp_theme.foreground().with_opacity(40))
}
/// Pressed state for UI elements like buttons
/// to which we want to call attention.
/// Allows gradients so shouldn't be used for small elements.
#[allow(dead_code)]
pub fn accent_pressed(warp_theme: &WarpTheme) -> Fill {
warp_theme
.accent()
.blend(&warp_theme.background().with_opacity(30))
}
/// The color of most text throughout the UI.
pub fn text_main(warp_theme: &WarpTheme, background: impl Into<ColorU>) -> ColorU {
coloru_with_opacity(font_color(warp_theme, background), 90)
}
/// The color of subheaders and similar lower priority text.
pub fn text_sub(warp_theme: &WarpTheme, background: impl Into<ColorU>) -> ColorU {
coloru_with_opacity(font_color(warp_theme, background), 60)
}
/// The color of text elements that are disabled or the lowest priority.
pub fn text_disabled(warp_theme: &WarpTheme, background: impl Into<ColorU>) -> ColorU {
coloru_with_opacity(font_color(warp_theme, background), 40)
}
// TODO (roland): evaluate whether text_disabled above is intentionally different or if it should be consolidated with this
// which matches figma mocks.
pub fn semantic_text_disabled(warp_theme: &WarpTheme) -> ColorU {
warp_theme
.background()
.blend(&fg_overlay_5(warp_theme))
.into()
}
pub fn neutral_1(warp_theme: &WarpTheme) -> ColorU {
warp_theme
.background()
.blend(&warp_theme.foreground().with_opacity(5))
.into_solid()
}
pub fn neutral_2(warp_theme: &WarpTheme) -> ColorU {
warp_theme
.background()
.blend(&warp_theme.foreground().with_opacity(10))
.into_solid()
}
pub fn neutral_3(warp_theme: &WarpTheme) -> ColorU {
warp_theme
.background()
.blend(&warp_theme.foreground().with_opacity(15))
.into_solid()
}
pub fn neutral_4(warp_theme: &WarpTheme) -> ColorU {
warp_theme
.background()
.blend(&warp_theme.foreground().with_opacity(20))
.into_solid()
}
pub fn neutral_5(warp_theme: &WarpTheme) -> ColorU {
warp_theme
.background()
.blend(&warp_theme.foreground().with_opacity(40))
.into_solid()
}
pub fn neutral_6(warp_theme: &WarpTheme) -> ColorU {
warp_theme
.background()
.blend(&warp_theme.foreground().with_opacity(60))
.into_solid()
}
pub fn neutral_7(warp_theme: &WarpTheme) -> ColorU {
warp_theme
.background()
.blend(&warp_theme.foreground().with_opacity(90))
.into_solid()
}
pub fn fg_overlay_1(warp_theme: &WarpTheme) -> Fill {
warp_theme.foreground().with_opacity(5)
}
pub fn fg_overlay_2(warp_theme: &WarpTheme) -> Fill {
warp_theme.foreground().with_opacity(10)
}
pub fn fg_overlay_3(warp_theme: &WarpTheme) -> Fill {
warp_theme.foreground().with_opacity(15)
}
pub fn fg_overlay_4(warp_theme: &WarpTheme) -> Fill {
warp_theme.foreground().with_opacity(20)
}
pub fn fg_overlay_5(warp_theme: &WarpTheme) -> Fill {
warp_theme.foreground().with_opacity(40)
}
pub fn fg_overlay_6(warp_theme: &WarpTheme) -> Fill {
warp_theme.foreground().with_opacity(60)
}
pub fn fg_overlay_7(warp_theme: &WarpTheme) -> Fill {
warp_theme.foreground().with_opacity(90)
}
pub fn accent_bg_strong(warp_theme: &WarpTheme) -> Fill {
Fill::Solid(warp_theme.background().into_solid())
.blend(&warp_theme.accent().with_opacity(60))
}
pub fn accent_bg(warp_theme: &WarpTheme) -> Fill {
Fill::Solid(warp_theme.background().into_solid())
.blend(&warp_theme.accent().with_opacity(40))
}
pub fn accent_fg_strong(warp_theme: &WarpTheme) -> Fill {
warp_theme
.foreground()
.blend(&warp_theme.accent().with_opacity(60))
}
pub fn accent_fg(warp_theme: &WarpTheme) -> Fill {
warp_theme
.foreground()
.blend(&warp_theme.accent().with_opacity(40))
}
pub fn accent_overlay_1(warp_theme: &WarpTheme) -> Fill {
warp_theme.accent().with_opacity(10)
}
pub fn accent_overlay_2(warp_theme: &WarpTheme) -> Fill {
warp_theme.accent().with_opacity(25)
}
pub fn accent_overlay_3(warp_theme: &WarpTheme) -> Fill {
warp_theme.accent().with_opacity(40)
}
pub fn accent_overlay_4(warp_theme: &WarpTheme) -> Fill {
warp_theme.accent().with_opacity(60)
}
}
+697
View File
@@ -0,0 +1,697 @@
pub mod color;
pub mod phenomenon;
use std::path::PathBuf;
use crate::paths::themes_dir;
use super::color::{
blend::Blend,
coloru_with_opacity,
contrast::{pick_best_foreground_color, MinimumAllowedContrast},
hex_color, mid_coloru, ContrastingColor, Opacity, OPAQUE,
};
// Import relative_luminance from contrast module for brightness calculation
use crate::ui::color::contrast::relative_luminance;
use self::color::CustomDetails;
use dirs::home_dir;
use serde::{Deserialize, Serialize};
use galaxyui::{assets::asset_cache::AssetSource, color::ColorU, geometry::vector::vec2f};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Image {
pub source: AssetSource,
pub opacity: Opacity,
}
/// This is a helper struct used for deserialization.
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
struct SerializedBackgroundThemeImage {
path: String,
#[serde(default = "default_image_opacity")]
pub opacity: Opacity,
}
impl Serialize for Image {
// We only serialize Images that are sourced from local files. Currently,
// there is no need in our app to serialize a theme that contains a bundled image.
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let AssetSource::LocalFile { path } = self.source.clone() else {
return Err(serde::ser::Error::custom(
"image path was serialized but it's not a local file",
));
};
let serialized = SerializedBackgroundThemeImage {
path,
opacity: self.opacity,
};
serialized.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for Image {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value: SerializedBackgroundThemeImage =
SerializedBackgroundThemeImage::deserialize(deserializer)?;
// The user is allowed to specify a relative path. It's our responsibility to
// deserialize this into an absolute path.
let path = {
let expanded_path = expand_tilde(value.path.into());
if expanded_path.is_absolute() {
expanded_path
} else {
themes_dir().join(expanded_path)
}
};
Ok(Image {
source: AssetSource::LocalFile {
path: path.to_str().unwrap_or_default().to_owned(),
},
opacity: value.opacity,
})
}
}
/// Returns the default opacity for serde to use for an [`Image`] if one is not
/// specified.
fn default_image_opacity() -> Opacity {
100
}
/// Performs tilde expansion to expand a _leading_ tilde to the user's home dir. Any intermediate
/// tildes are not expanded. If the path does not begin with a tilde, then the existing path is
/// returned unchanged.
fn expand_tilde(path: PathBuf) -> PathBuf {
let home_dir = match home_dir() {
Some(home_dir) => home_dir,
None => return path,
};
match path.strip_prefix("~") {
Ok(stripped) => home_dir.join(stripped),
Err(_) => path,
}
}
impl Image {
pub fn source(&self) -> AssetSource {
self.source.clone()
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct AnsiColor {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl From<AnsiColor> for ColorU {
fn from(color: AnsiColor) -> Self {
ColorU {
r: color.r,
g: color.g,
b: color.b,
a: OPAQUE, // ansi colors are at full opacity
}
}
}
impl From<ColorU> for AnsiColor {
fn from(color: ColorU) -> Self {
AnsiColor {
r: color.r,
g: color.g,
b: color.b,
}
}
}
impl From<AnsiColor> for Fill {
fn from(color: AnsiColor) -> Fill {
Fill::Solid(color.into())
}
}
impl AnsiColor {
pub const fn from_u32(color: u32) -> Self {
AnsiColor {
r: (color >> 24) as u8,
g: ((color >> 16) & 0xff) as u8,
b: ((color >> 8) & 0xff) as u8,
}
}
}
#[derive(Serialize, Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
pub struct VerticalGradient {
#[serde(with = "hex_color")]
top: ColorU,
#[serde(with = "hex_color")]
bottom: ColorU,
}
impl VerticalGradient {
pub fn new(top: ColorU, bottom: ColorU) -> Self {
VerticalGradient { top, bottom }
}
fn midcolor(&self) -> ColorU {
mid_coloru(self.top, self.bottom)
}
pub fn get_most_opaque(&self) -> ColorU {
if self.top.a > self.bottom.a {
self.top
} else {
self.bottom
}
}
}
impl Blend for VerticalGradient {
type Output = VerticalGradient;
fn blend(&self, other: &VerticalGradient) -> VerticalGradient {
VerticalGradient::new(self.top.blend(&other.top), self.bottom.blend(&other.bottom))
}
}
impl ContrastingColor<ColorU> for VerticalGradient {
type Output = VerticalGradient;
fn on_background(
self,
background: ColorU,
minimum_allowed_contrast: MinimumAllowedContrast,
) -> VerticalGradient {
VerticalGradient::new(
self.top.on_background(background, minimum_allowed_contrast),
self.bottom
.on_background(background, minimum_allowed_contrast),
)
}
}
#[derive(Serialize, Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
pub struct HorizontalGradient {
#[serde(with = "hex_color")]
left: ColorU,
#[serde(with = "hex_color")]
right: ColorU,
}
impl HorizontalGradient {
pub fn new(left: ColorU, right: ColorU) -> Self {
HorizontalGradient { left, right }
}
fn midcolor(&self) -> ColorU {
mid_coloru(self.left, self.right)
}
pub fn get_most_opaque(&self) -> ColorU {
if self.left.a > self.right.a {
self.left
} else {
self.right
}
}
}
impl Blend for HorizontalGradient {
type Output = HorizontalGradient;
fn blend(&self, other: &HorizontalGradient) -> HorizontalGradient {
HorizontalGradient::new(self.left.blend(&other.left), self.right.blend(&other.right))
}
}
impl ContrastingColor<ColorU> for HorizontalGradient {
type Output = HorizontalGradient;
fn on_background(
self,
background: ColorU,
minimum_allowed_contrast: MinimumAllowedContrast,
) -> HorizontalGradient {
HorizontalGradient::new(
self.left
.on_background(background, minimum_allowed_contrast),
self.right
.on_background(background, minimum_allowed_contrast),
)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ColorScheme {
/// Light foreground colors on a dark background (light mode).
LightOnDark,
/// Dark foreground colors on a light background (dark mode).
DarkOnLight,
}
impl ColorScheme {
fn infer_from_foreground_color(foreground_color: ColorU) -> Self {
// We actually are picking whether the foreground color is most visible
// on a light or dark _background_, despite the helper function name.
if pick_best_foreground_color(
foreground_color,
ColorU::white(),
ColorU::black(),
MinimumAllowedContrast::Text,
) == ColorU::white()
{
ColorScheme::DarkOnLight
} else {
ColorScheme::LightOnDark
}
}
}
#[derive(Serialize, Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(untagged, rename_all = "lowercase")]
pub enum Fill {
#[serde(with = "hex_color")]
Solid(ColorU),
VerticalGradient(VerticalGradient),
HorizontalGradient(HorizontalGradient),
}
impl Fill {
pub fn black() -> Fill {
Fill::Solid(ColorU::from_u32(0x000000ff))
}
pub fn white() -> Fill {
Fill::Solid(ColorU::from_u32(0xffffffff))
}
pub fn warn() -> Fill {
Fill::Solid(ColorU::from_u32(0xC28000FF))
}
pub fn error() -> Fill {
Fill::Solid(ColorU::new(188, 54, 42, 255))
}
// Translucent black used for blur backdrop
pub fn blur() -> Fill {
Fill::Solid(ColorU::new(0, 0, 0, 179))
}
/// Green color used for elements that show success status.
pub fn success() -> Fill {
Fill::Solid(ColorU::new(0, 142, 65, 255))
}
pub fn with_opacity(&self, opacity: Opacity) -> Self {
match self {
Fill::Solid(c) => Fill::Solid(coloru_with_opacity(*c, opacity)),
Fill::VerticalGradient(g) => Fill::VerticalGradient(VerticalGradient::new(
coloru_with_opacity(g.top, opacity),
coloru_with_opacity(g.bottom, opacity),
)),
Fill::HorizontalGradient(g) => Fill::HorizontalGradient(HorizontalGradient::new(
coloru_with_opacity(g.left, opacity),
coloru_with_opacity(g.right, opacity),
)),
}
}
/// Convert this fill into a solid color, taking the midpoint color for gradients
pub fn into_solid(self) -> ColorU {
match self {
Fill::Solid(c) => c,
Fill::VerticalGradient(g) => g.midcolor(),
Fill::HorizontalGradient(g) => g.midcolor(),
}
}
/// Convert this Fill into a solid color, taking the top color for vertical gradients and the
/// midpoint color for horizontal gradients.
pub fn into_solid_bias_top_color(self) -> ColorU {
match self {
Fill::Solid(c) => c,
Fill::VerticalGradient(g) => g.top,
Fill::HorizontalGradient(g) => g.midcolor(),
}
}
/// Convert this Fill into a solid color, taking the right color for horizontal gradients and
/// the midpoint color for vertical gradients.
pub fn into_solid_bias_right_color(self) -> ColorU {
match self {
Self::Solid(c) => c,
Self::HorizontalGradient(g) => g.right,
Self::VerticalGradient(g) => g.midcolor(),
}
}
/// Convert this Fill into a version of itself whose color is adaptively faded based on the
/// background brightness. Uses less aggressive fading on light backgrounds and more aggressive
/// fading on dark backgrounds to maintain optimal contrast.
pub fn fade_into_background(self, background_color: &Self) -> Self {
let background_luminance = relative_luminance(background_color.into_solid());
// Threshold for determining if background is "light" vs "dark"
// 0.5 is approximately middle gray in terms of perceived brightness
let is_light_background = background_luminance > 0.2;
// Use different opacity levels based on background brightness:
// - Light backgrounds: Use higher opacity (85%) to maintain contrast with focused diffs
// - Dark backgrounds: Use lower opacity (65%) since the contrast is naturally better
let fade_opacity = if is_light_background {
85 // More aggressive fading on light backgrounds
} else {
65 // Less aggressive fading on dark backgrounds
};
self.blend(&background_color.with_opacity(fade_opacity))
}
}
impl Blend for Fill {
type Output = Fill;
fn blend(&self, other: &Fill) -> Fill {
match (self, other) {
(Fill::Solid(c1), Fill::Solid(c2)) => Fill::Solid(c1.blend(c2)),
(Fill::VerticalGradient(g), Fill::Solid(c)) => {
Fill::VerticalGradient(VerticalGradient::new(g.top.blend(c), g.bottom.blend(c)))
}
(Fill::Solid(c), Fill::VerticalGradient(g)) => {
Fill::VerticalGradient(VerticalGradient::new(c.blend(&g.top), c.blend(&g.bottom)))
}
(Fill::HorizontalGradient(g), Fill::Solid(c)) => {
Fill::HorizontalGradient(HorizontalGradient::new(g.left.blend(c), g.right.blend(c)))
}
(Fill::Solid(c), Fill::HorizontalGradient(g)) => Fill::HorizontalGradient(
HorizontalGradient::new(c.blend(&g.left), c.blend(&g.right)),
),
(Fill::VerticalGradient(g1), Fill::VerticalGradient(g2)) => {
Fill::VerticalGradient(g1.blend(g2))
}
(Fill::HorizontalGradient(g1), Fill::HorizontalGradient(g2)) => {
Fill::HorizontalGradient(g1.blend(g2))
}
(Fill::HorizontalGradient(g1), Fill::VerticalGradient(g2)) => {
Fill::VerticalGradient(VerticalGradient::new(
g1.midcolor().blend(&g2.top),
g1.midcolor().blend(&g2.bottom),
))
}
(Fill::VerticalGradient(g1), Fill::HorizontalGradient(g2)) => {
Fill::HorizontalGradient(HorizontalGradient::new(
g1.midcolor().blend(&g2.left),
g1.midcolor().blend(&g2.right),
))
}
}
}
}
impl ContrastingColor for Fill {
type Output = Fill;
fn on_background(
self,
background: Fill,
minimum_allowed_contrast: MinimumAllowedContrast,
) -> Fill {
match self {
Fill::Solid(c) => {
Fill::Solid(c.on_background(background.into(), minimum_allowed_contrast))
}
Fill::HorizontalGradient(g) => Fill::HorizontalGradient(
g.on_background(background.into(), minimum_allowed_contrast),
),
Fill::VerticalGradient(g) => {
Fill::VerticalGradient(g.on_background(background.into(), minimum_allowed_contrast))
}
}
}
}
impl From<Fill> for galaxyui::elements::Fill {
fn from(theme: Fill) -> Self {
match theme {
Fill::Solid(c) => galaxyui::elements::Fill::Solid(c),
Fill::HorizontalGradient(g) => galaxyui::elements::Fill::Gradient {
start: vec2f(0.0, 0.0),
end: vec2f(1.0, 0.0),
start_color: g.left,
end_color: g.right,
},
Fill::VerticalGradient(g) => galaxyui::elements::Fill::Gradient {
start: vec2f(0.0, 0.0),
end: vec2f(0.0, 1.0),
start_color: g.top,
end_color: g.bottom,
},
}
}
}
impl From<Fill> for ColorU {
fn from(color: Fill) -> Self {
match color {
Fill::Solid(c) => c,
Fill::VerticalGradient(g) => g.midcolor(),
Fill::HorizontalGradient(g) => g.midcolor(),
}
}
}
impl From<ColorU> for Fill {
fn from(color: ColorU) -> Fill {
Fill::Solid(color)
}
}
#[derive(Serialize, Copy, Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct AnsiColors {
#[serde(with = "hex_color")]
pub black: AnsiColor,
#[serde(with = "hex_color")]
pub red: AnsiColor,
#[serde(with = "hex_color")]
pub green: AnsiColor,
#[serde(with = "hex_color")]
pub yellow: AnsiColor,
#[serde(with = "hex_color")]
pub blue: AnsiColor,
#[serde(with = "hex_color")]
pub magenta: AnsiColor,
#[serde(with = "hex_color")]
pub cyan: AnsiColor,
#[serde(with = "hex_color")]
pub white: AnsiColor,
}
impl AnsiColors {
#[allow(clippy::too_many_arguments)]
pub const fn new(
black: AnsiColor,
red: AnsiColor,
green: AnsiColor,
yellow: AnsiColor,
blue: AnsiColor,
magenta: AnsiColor,
cyan: AnsiColor,
white: AnsiColor,
) -> Self {
AnsiColors {
black,
red,
green,
yellow,
blue,
magenta,
cyan,
white,
}
}
}
#[derive(
Serialize,
Copy,
Clone,
Debug,
Deserialize,
PartialEq,
Eq,
schemars::JsonSchema,
settings_value::SettingsValue,
)]
#[schemars(
description = "One of the eight standard ANSI terminal colors.",
rename_all = "snake_case"
)]
#[serde(rename_all = "lowercase")]
pub enum AnsiColorIdentifier {
Black,
Red,
Green,
Yellow,
Blue,
Magenta,
Cyan,
White,
}
impl std::fmt::Display for AnsiColorIdentifier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let color_name = match self {
Self::Black => "Black",
Self::Red => "Red",
Self::Green => "Green",
Self::Yellow => "Yellow",
Self::Blue => "Blue",
Self::Magenta => "Magenta",
Self::Cyan => "Cyan",
Self::White => "White",
};
write!(f, "{color_name}")
}
}
impl AnsiColorIdentifier {
pub fn to_ansi_color(self, colors: &AnsiColors) -> AnsiColor {
match self {
Self::Black => colors.black,
Self::Red => colors.red,
Self::Green => colors.green,
Self::Yellow => colors.yellow,
Self::Blue => colors.blue,
Self::Magenta => colors.magenta,
Self::Cyan => colors.cyan,
Self::White => colors.white,
}
}
}
#[derive(Serialize, Clone, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Details {
Darker,
Lighter,
Custom(CustomDetails),
}
#[derive(Serialize, Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct TerminalColors {
pub normal: AnsiColors,
pub bright: AnsiColors,
}
impl TerminalColors {
pub fn new(normal: AnsiColors, bright: AnsiColors) -> Self {
TerminalColors { normal, bright }
}
}
#[derive(Serialize, Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct WarpTheme {
background: Fill,
accent: Fill,
#[serde(with = "hex_color")]
foreground: ColorU,
#[serde(default, skip_serializing_if = "Option::is_none")]
cursor: Option<Fill>,
#[serde(skip_serializing_if = "Option::is_none")]
background_image: Option<Image>,
details: Details,
terminal_colors: TerminalColors,
// If name is None, we construct the name by processing the theme .yaml file name
name: Option<String>,
}
impl WarpTheme {
#[allow(clippy::too_many_arguments)]
pub fn new(
bg: Fill,
foreground: ColorU,
accent: Fill,
cursor: Option<Fill>,
details: Option<Details>,
terminal_colors: TerminalColors,
background_image: Option<Image>,
name: Option<String>,
) -> Self {
WarpTheme {
background: bg,
foreground,
accent,
cursor,
details: details.unwrap_or_else(|| Details::Custom(CustomDetails::default())),
terminal_colors,
background_image,
name,
}
}
pub fn name(&self) -> Option<String> {
self.name.clone()
}
pub fn set_name(&mut self, name: String) {
self.name = Some(name);
}
pub fn details(&self) -> CustomDetails {
match self.details {
Details::Darker => CustomDetails::darker_details(),
Details::Lighter => CustomDetails::lighter_details(),
Details::Custom(details) => details,
}
}
pub fn inferred_color_scheme(&self) -> ColorScheme {
ColorScheme::infer_from_foreground_color(self.foreground)
}
pub fn background_image(&self) -> Option<Image> {
self.background_image.clone()
}
}
#[cfg(any(test, feature = "test-util"))]
pub fn mock_terminal_colors() -> TerminalColors {
TerminalColors::new(
AnsiColors::new(
AnsiColor::from_u32(0x616161FF),
AnsiColor::from_u32(0xFF8272FF),
AnsiColor::from_u32(0xB4FA72FF),
AnsiColor::from_u32(0xFEFDC2FF),
AnsiColor::from_u32(0xA5D5FEFF),
AnsiColor::from_u32(0xFF8FFDFF),
AnsiColor::from_u32(0xD0D1FEFF),
AnsiColor::from_u32(0xF1F1F1FF),
),
AnsiColors::new(
AnsiColor::from_u32(0x8E8E8EFF),
AnsiColor::from_u32(0xFFC4BDFF),
AnsiColor::from_u32(0xD6FCB9FF),
AnsiColor::from_u32(0xFEFDD5FF),
AnsiColor::from_u32(0xC1E3FEFF),
AnsiColor::from_u32(0xFFB1FEFF),
AnsiColor::from_u32(0xE5E6FEFF),
AnsiColor::from_u32(0xFEFFFFFF),
),
)
}
#[cfg(test)]
#[path = "theme_tests.rs"]
mod tests;
@@ -0,0 +1,158 @@
use galaxyui::color::ColorU;
use crate::ui::color::blend::Blend;
use super::Fill;
const PHENOMENON_BACKGROUND: u32 = 0x121212FF;
const PHENOMENON_FOREGROUND: u32 = 0xFAF9F6FF;
const PHENOMENON_ACCENT: u32 = 0x2E5D9EFF;
const PHENOMENON_BLUE: u32 = 0x3780E9FF;
const PHENOMENON_BODY_TEXT: u32 = 0xFAF9F6E5;
const PHENOMENON_LABEL_TEXT: u32 = 0xFAF9F699;
const PHENOMENON_DISABLED_LABEL_TEXT: u32 = 0xFAF9F680;
const PHENOMENON_SUBTLE_BORDER: u32 = 0xFAF9F633;
const PHENOMENON_MODAL_BACKGROUND: u32 = 0x2A2A2AFF;
const PHENOMENON_MODAL_BADGE_BACKGROUND: u32 = 0xFF8FFD1A;
const PHENOMENON_MODAL_BADGE_TEXT: u32 = 0xFF8FFDFF;
const PHENOMENON_MODAL_TITLE_TEXT: u32 = 0xFFFFFFFF;
const PHENOMENON_MODAL_FEATURE_TITLE_TEXT: u32 = 0xE6E6E6FF;
const PHENOMENON_MODAL_FEATURE_DESCRIPTION_TEXT: u32 = 0x9B9B9BFF;
const PHENOMENON_MODAL_BUTTON_BACKGROUND: u32 = 0xFFFFFFFF;
const PHENOMENON_MODAL_BUTTON_TEXT: u32 = 0x050505FF;
const PHENOMENON_MODAL_BUTTON_HOVER_OVERLAY: u32 = 0x0505051F;
const PHENOMENON_MODAL_CLOSE_BUTTON_TEXT: u32 = 0xFFFFFFFF;
const PHENOMENON_MODAL_CLOSE_BUTTON_HOVER: u32 = 0x050505BF;
pub struct PhenomenonStyle;
impl PhenomenonStyle {
pub fn background() -> ColorU {
ColorU::from_u32(PHENOMENON_BACKGROUND)
}
pub fn foreground() -> ColorU {
ColorU::from_u32(PHENOMENON_FOREGROUND)
}
pub fn accent() -> ColorU {
ColorU::from_u32(PHENOMENON_ACCENT)
}
pub fn blue() -> ColorU {
ColorU::from_u32(PHENOMENON_BLUE)
}
pub fn body_text() -> ColorU {
ColorU::from_u32(PHENOMENON_BODY_TEXT)
}
pub fn label_text() -> ColorU {
ColorU::from_u32(PHENOMENON_LABEL_TEXT)
}
pub fn disabled_label_text() -> ColorU {
ColorU::from_u32(PHENOMENON_DISABLED_LABEL_TEXT)
}
pub fn subtle_border() -> ColorU {
ColorU::from_u32(PHENOMENON_SUBTLE_BORDER)
}
pub fn modal_background() -> ColorU {
ColorU::from_u32(PHENOMENON_MODAL_BACKGROUND)
}
pub fn modal_badge_background() -> ColorU {
ColorU::from_u32(PHENOMENON_MODAL_BADGE_BACKGROUND)
}
pub fn modal_badge_text() -> ColorU {
ColorU::from_u32(PHENOMENON_MODAL_BADGE_TEXT)
}
pub fn modal_title_text() -> ColorU {
ColorU::from_u32(PHENOMENON_MODAL_TITLE_TEXT)
}
pub fn modal_feature_title_text() -> ColorU {
ColorU::from_u32(PHENOMENON_MODAL_FEATURE_TITLE_TEXT)
}
pub fn modal_feature_description_text() -> ColorU {
ColorU::from_u32(PHENOMENON_MODAL_FEATURE_DESCRIPTION_TEXT)
}
pub fn modal_button_background() -> ColorU {
ColorU::from_u32(PHENOMENON_MODAL_BUTTON_BACKGROUND)
}
pub fn modal_button_text() -> ColorU {
ColorU::from_u32(PHENOMENON_MODAL_BUTTON_TEXT)
}
pub fn modal_button_hover_overlay() -> ColorU {
ColorU::from_u32(PHENOMENON_MODAL_BUTTON_HOVER_OVERLAY)
}
pub fn modal_close_button_text() -> ColorU {
ColorU::from_u32(PHENOMENON_MODAL_CLOSE_BUTTON_TEXT)
}
pub fn modal_close_button_hover() -> ColorU {
ColorU::from_u32(PHENOMENON_MODAL_CLOSE_BUTTON_HOVER)
}
pub fn tinted_surface() -> Fill {
Fill::Solid(Self::background()).blend(&Fill::Solid(Self::blue()).with_opacity(50))
}
pub fn surface_border() -> ColorU {
Self::blue()
}
pub fn primary_button_background(hovered: bool) -> Fill {
Fill::Solid(if hovered {
Self::blue()
} else {
Self::accent()
})
}
pub fn primary_button_text() -> ColorU {
Self::foreground()
}
pub fn modal_button_background_fill(hovered: bool) -> Fill {
if hovered {
Fill::Solid(Self::modal_button_background())
.blend(&Fill::Solid(Self::modal_button_hover_overlay()))
} else {
Fill::Solid(Self::modal_button_background())
}
}
pub fn segmented_control_background() -> Fill {
Fill::Solid(Self::foreground()).with_opacity(8)
}
pub fn selected_chip_background() -> Fill {
Fill::Solid(Self::foreground())
}
pub fn selected_chip_text() -> ColorU {
Self::background()
}
pub fn selected_chip_border() -> Fill {
Fill::Solid(Self::accent())
}
pub fn unselected_chip_background() -> Fill {
Fill::Solid(Self::foreground()).with_opacity(8)
}
pub fn unselected_chip_text() -> ColorU {
Self::body_text()
}
}
@@ -0,0 +1,341 @@
use super::*;
#[test]
fn serialize_test() {
let theme = WarpTheme::new(
Fill::Solid(ColorU::from_u32(0x20A5BAFF)),
ColorU::from_u32(0x20A5BAFF),
Fill::Solid(ColorU::from_u32(0x20A5BAFF)),
None,
Some(Details::Darker),
mock_terminal_colors(),
None,
Some("test_theme".to_string()),
);
assert_eq!(
r##"---
background: "#20a5ba"
accent: "#20a5ba"
foreground: "#20a5ba"
details: darker
terminal_colors:
normal:
black: "#616161"
red: "#ff8272"
green: "#b4fa72"
yellow: "#fefdc2"
blue: "#a5d5fe"
magenta: "#ff8ffd"
cyan: "#d0d1fe"
white: "#f1f1f1"
bright:
black: "#8e8e8e"
red: "#ffc4bd"
green: "#d6fcb9"
yellow: "#fefdd5"
blue: "#c1e3fe"
magenta: "#ffb1fe"
cyan: "#e5e6fe"
white: "#feffff"
name: test_theme
"##,
serde_yaml::to_string(&theme).expect("Couldn't serialize")
);
}
#[test]
fn deserialize_with_name_test() {
let theme = serde_yaml::from_str::<WarpTheme>(
r##"---
background: "#20a5ba"
accent: "#20a5ba"
foreground: "#20a5ba"
details: darker
terminal_colors:
normal:
black: "#616161"
red: "#ff8272"
green: "#b4fa72"
yellow: "#fefdc2"
blue: "#a5d5fe"
magenta: "#ff8ffd"
cyan: "#d0d1fe"
white: "#f1f1f1"
bright:
black: "#8e8e8e"
red: "#ffc4bd"
green: "#d6fcb9"
yellow: "#fefdd5"
blue: "#c1e3fe"
magenta: "#ffb1fe"
cyan: "#e5e6fe"
white: "#feffff"
name: test_theme
"##,
)
.expect("Couldn't deserialize");
let expected_theme = WarpTheme::new(
Fill::Solid(ColorU::from_u32(0x20A5BAFF)),
ColorU::from_u32(0x20A5BAFF),
Fill::Solid(ColorU::from_u32(0x20A5BAFF)),
None,
Some(Details::Darker),
mock_terminal_colors(),
None,
Some("test_theme".to_string()),
);
assert_eq!(expected_theme, theme);
}
#[test]
fn deserialize_without_name_test() {
let theme = serde_yaml::from_str::<WarpTheme>(
r##"---
background: "#20a5ba"
accent: "#20a5ba"
foreground: "#20a5ba"
details: darker
terminal_colors:
normal:
black: "#616161"
red: "#ff8272"
green: "#b4fa72"
yellow: "#fefdc2"
blue: "#a5d5fe"
magenta: "#ff8ffd"
cyan: "#d0d1fe"
white: "#f1f1f1"
bright:
black: "#8e8e8e"
red: "#ffc4bd"
green: "#d6fcb9"
yellow: "#fefdd5"
blue: "#c1e3fe"
magenta: "#ffb1fe"
cyan: "#e5e6fe"
white: "#feffff"
"##,
)
.expect("Couldn't deserialize");
let expected_theme = WarpTheme::new(
Fill::Solid(ColorU::from_u32(0x20A5BAFF)),
ColorU::from_u32(0x20A5BAFF),
Fill::Solid(ColorU::from_u32(0x20A5BAFF)),
None,
Some(Details::Darker),
mock_terminal_colors(),
None,
None,
);
assert_eq!(expected_theme, theme);
}
#[test]
fn blend_gradient_test() {
let (c1, c2, c3, c4) = (
ColorU::from_u32(0x002b36ff),
ColorU::from_u32(0xcb4b16ff),
ColorU::from_u32(0xffffff19),
ColorU::from_u32(0xffffff19),
);
let g1 = VerticalGradient::new(c1, c2);
let g2 = VerticalGradient::new(c3, c4);
assert_eq!(
g1.blend(&g2),
VerticalGradient::new(c1.blend(&c3), c2.blend(&c4))
);
}
#[test]
fn blend_coloru_test() {
let c1 = ColorU::from_u32(0x002b36ff);
let c2 = ColorU::from_u32(0xF8F8F2FF);
assert_eq!(
c1.blend(&coloru_with_opacity(c2, 10)),
ColorU::from_u32(0x183f48ff)
);
assert_eq!(
ColorU::from_u32(0x000000ff).blend(&coloru_with_opacity(c2, 10)),
ColorU::from_u32(0x181818ff)
);
}
/// TODO(CORE-3626): write an equivalent test with Windows paths.
#[cfg(not(windows))]
#[test]
fn test_deserialize_image() {
// Paths that start with `~` should expand to include the home dir.
let a = "
path: ~/warp.jpg
opacity: 60
";
let image: Image = serde_yaml::from_str(a).unwrap();
assert_eq!(image.opacity, 60);
assert_eq!(
image.source,
AssetSource::LocalFile {
path: home_dir()
.unwrap()
.join("warp.jpg")
.to_str()
.unwrap_or_default()
.to_owned()
}
);
// Absolute paths should be unchanged.
let b = "
path: /warp.jpg
opacity: 60
";
let image: Image = serde_yaml::from_str(b).unwrap();
assert_eq!(image.opacity, 60);
assert_eq!(
image.source,
AssetSource::LocalFile {
path: "/warp.jpg".to_owned()
}
);
// Relative paths should expand to include the theme dir.
let c = "
path: warp.jpg
opacity: 60
";
let image: Image = serde_yaml::from_str(c).unwrap();
assert_eq!(image.opacity, 60);
assert_eq!(
image.source,
AssetSource::LocalFile {
path: themes_dir()
.join("warp.jpg")
.to_str()
.unwrap_or_default()
.to_owned()
}
);
// No opacity should become the default
let d = "
path: warp.jpg
";
let image: Image = serde_yaml::from_str(d).unwrap();
assert_eq!(image.opacity, default_image_opacity());
}
#[test]
fn ansi_color_deserializing_test() {
let raw = r##"
black: "#000000"
red: "#ff0000"
green: "#00ff00"
yellow: "#00ffff"
blue: "#0000ff"
magenta: "#ff0000"
cyan: "#0000ff"
white: "#ffffff"
"##;
let ansi_colors: AnsiColors = serde_yaml::from_str(raw).expect("Couldn't deserialize");
assert_eq!(ansi_colors.black, AnsiColor::from_u32(0x000000ff));
assert_eq!(ansi_colors.red, AnsiColor::from_u32(0xff0000ff));
assert_eq!(ansi_colors.green, AnsiColor::from_u32(0x00ff00ff));
assert_eq!(ansi_colors.yellow, AnsiColor::from_u32(0x00ffffff));
assert_eq!(ansi_colors.blue, AnsiColor::from_u32(0x0000ffff));
assert_eq!(ansi_colors.magenta, AnsiColor::from_u32(0xff0000ff));
assert_eq!(ansi_colors.cyan, AnsiColor::from_u32(0x0000ffff));
assert_eq!(ansi_colors.white, AnsiColor::from_u32(0xffffffff));
}
#[test]
fn ansi_color_serializing_test() {
let ansi_colors = AnsiColors::new(
AnsiColor::from_u32(0x000000ff),
AnsiColor::from_u32(0xff0000ff),
AnsiColor::from_u32(0x00ff00ff),
AnsiColor::from_u32(0x00ffffff),
AnsiColor::from_u32(0x0000ffff),
AnsiColor::from_u32(0xff0000ff),
AnsiColor::from_u32(0x0000ffff),
AnsiColor::from_u32(0xffffffff),
);
let serialized = serde_yaml::to_string(&ansi_colors).expect("Couldn't serialize");
let raw = r##"---
black: "#000000"
red: "#ff0000"
green: "#00ff00"
yellow: "#00ffff"
blue: "#0000ff"
magenta: "#ff0000"
cyan: "#0000ff"
white: "#ffffff"
"##;
assert_eq!(serialized, raw);
let ansi_colors2: AnsiColors = serde_yaml::from_str(&serialized).expect("Couldn't deserialize");
assert_eq!(ansi_colors2, ansi_colors);
}
#[test]
fn from_hex_negative_test() {
assert_eq!(
hex_color::coloru_from_hex_string("#0").unwrap_err(),
hex_color::HexColorError::InvalidLength
);
assert_eq!(
hex_color::coloru_from_hex_string("#00").unwrap_err(),
hex_color::HexColorError::InvalidLength
);
assert_eq!(
hex_color::coloru_from_hex_string("#00000").unwrap_err(),
hex_color::HexColorError::InvalidLength
);
assert_eq!(
hex_color::coloru_from_hex_string("#0000000").unwrap_err(),
hex_color::HexColorError::InvalidLength
);
assert_eq!(
hex_color::coloru_from_hex_string("0000").unwrap_err(),
hex_color::HexColorError::HashPrefix
);
assert_eq!(
hex_color::coloru_from_hex_string("#ZXD").unwrap_err(),
hex_color::HexColorError::InvalidValue
);
}
#[test]
fn from_hex_positive_test() {
assert_eq!(
hex_color::coloru_from_hex_string("#000").unwrap(),
ColorU::from_u32(0x000000ff)
);
assert_eq!(
hex_color::coloru_from_hex_string("#000000").unwrap(),
ColorU::from_u32(0x000000ff)
);
assert_eq!(
hex_color::coloru_from_hex_string("#123").unwrap(),
ColorU::from_u32(0x112233ff)
);
assert_eq!(
hex_color::coloru_from_hex_string("#112233").unwrap(),
ColorU::from_u32(0x112233ff)
);
}
#[test]
fn infer_from_foreground_color_test() {
assert_eq!(
ColorScheme::infer_from_foreground_color(ColorU::white()),
ColorScheme::LightOnDark
);
assert_eq!(
ColorScheme::infer_from_foreground_color(ColorU::black()),
ColorScheme::DarkOnLight
);
}
@@ -0,0 +1,31 @@
//! Extension trait for accessing the private user preferences backend.
//!
//! Public settings are accessed exclusively through the settings macros
//! (`define_settings_group!`) and the `settings::PublicPreferences` wrapper,
//! which restricts direct access at compile time.
use std::ops::Deref;
use settings::PrivatePreferences;
use galaxyui::SingletonEntity;
use galaxyui_extras::user_preferences::UserPreferences;
/// An extension trait on [`galaxyui::AppContext`] for accessing private user
/// preferences.
///
/// Private settings are always stored in the platform-native store (e.g.
/// UserDefaults on macOS, registry on Windows, JSON file on Linux) and never
/// appear in the user-visible settings file.
pub trait GetUserPreferences {
/// Returns the preferences backend for private settings.
///
/// Private settings are always stored in the platform-native store and never
/// appear in the user-visible settings file.
fn private_user_preferences(&self) -> &dyn UserPreferences;
}
impl GetUserPreferences for galaxyui::AppContext {
fn private_user_preferences(&self) -> &dyn UserPreferences {
<PrivatePreferences as SingletonEntity>::as_ref(self).deref()
}
}