Rebrand to Galaxy, major improvements to Bedrock support, still needs some TLC though
This commit is contained in:
@@ -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
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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!");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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
@@ -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))
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user