Initialize Rust GPUI browser shell
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "ely_app"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ely_browser_core = { path = "../ely_browser_core" }
|
||||
ely_design_system = { path = "../ely_design_system" }
|
||||
ely_domain = { path = "../ely_domain" }
|
||||
gpui.workspace = true
|
||||
gpui-component.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,48 @@
|
||||
mod shell;
|
||||
|
||||
use gpui::{
|
||||
App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem, SystemMenuType, WindowBounds,
|
||||
WindowOptions, actions, px, size,
|
||||
};
|
||||
use shell::ElyShell;
|
||||
|
||||
actions!(ely_app, [Quit]);
|
||||
|
||||
fn main() {
|
||||
Application::new().run(|cx: &mut App| {
|
||||
gpui_component::init(cx);
|
||||
cx.on_action(quit);
|
||||
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
|
||||
cx.set_menus(vec![Menu {
|
||||
name: "ELY Browser".into(),
|
||||
items: vec![
|
||||
MenuItem::os_submenu("Services", SystemMenuType::Services),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action("Quit ELY Browser", Quit),
|
||||
],
|
||||
}]);
|
||||
|
||||
let bounds = Bounds::centered(None, size(px(1240.0), px(780.0)), cx);
|
||||
let opened = cx.open_window(
|
||||
WindowOptions {
|
||||
titlebar: None,
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
..WindowOptions::default()
|
||||
},
|
||||
|window, cx| {
|
||||
let shell = cx.new(|cx| ElyShell::new(window, cx));
|
||||
cx.new(|cx| gpui_component::Root::new(shell, window, cx))
|
||||
},
|
||||
);
|
||||
|
||||
if opened.is_ok() {
|
||||
cx.activate(true);
|
||||
} else {
|
||||
cx.quit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn quit(_: &Quit, cx: &mut App) {
|
||||
cx.quit();
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
use ely_browser_core::{BrowserCore, BrowserSnapshot, InitialBrowserConfig};
|
||||
use ely_design_system::{ELY_THEME, colors, spacing};
|
||||
use ely_domain::{BrowserTab, CommandIntent, TabId, UrlText};
|
||||
use gpui::{
|
||||
AnyElement, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, div, px, rgb,
|
||||
};
|
||||
use gpui_component::{
|
||||
Sizable, StyledExt,
|
||||
button::{Button, ButtonVariants},
|
||||
input::{Input, InputEvent, InputState},
|
||||
};
|
||||
|
||||
enum ShellState {
|
||||
Ready(BrowserCore),
|
||||
StartupError(String),
|
||||
}
|
||||
|
||||
pub struct ElyShell {
|
||||
state: ShellState,
|
||||
command_input: Entity<InputState>,
|
||||
last_intent: Option<CommandIntent>,
|
||||
_command_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl ElyShell {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let command_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("Search or enter address"));
|
||||
|
||||
let command_subscription =
|
||||
cx.subscribe(&command_input, |shell: &mut Self, input, event: &InputEvent, cx| {
|
||||
let ShellState::Ready(core) = &mut shell.state else {
|
||||
return;
|
||||
};
|
||||
|
||||
let value = input.read(cx).value().to_string();
|
||||
core.set_command_query(value);
|
||||
|
||||
if matches!(event, InputEvent::PressEnter { .. }) {
|
||||
shell.last_intent = core.submit_command().ok().flatten();
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
let state = match InitialBrowserConfig::ely_defaults().and_then(|config| {
|
||||
BrowserCore::new(config).map_err(|error| match error {
|
||||
ely_browser_core::CoreError::Domain(source) => source,
|
||||
_ => ely_domain::DomainError::InvalidCommand,
|
||||
})
|
||||
}) {
|
||||
Ok(core) => ShellState::Ready(core),
|
||||
Err(error) => ShellState::StartupError(error.to_string()),
|
||||
};
|
||||
|
||||
Self {
|
||||
state,
|
||||
command_input,
|
||||
last_intent: None,
|
||||
_command_subscription: command_subscription,
|
||||
}
|
||||
}
|
||||
|
||||
fn open_new_tab(&mut self, cx: &mut Context<Self>) {
|
||||
if let ShellState::Ready(core) = &mut self.state
|
||||
&& let Ok(url) = UrlText::parse("ely://new-tab")
|
||||
{
|
||||
core.open_tab(url);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn select_tab(&mut self, tab_id: &TabId, cx: &mut Context<Self>) {
|
||||
if let ShellState::Ready(core) = &mut self.state
|
||||
&& core.select_tab(tab_id).is_ok()
|
||||
{
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ElyShell {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
match &self.state {
|
||||
ShellState::Ready(core) => match (core.snapshot(), core.active_tab().cloned()) {
|
||||
(Ok(snapshot), Ok(active_tab)) => self.render_browser(snapshot, active_tab, cx),
|
||||
(Err(error), _) | (_, Err(error)) => render_error(error.to_string()),
|
||||
},
|
||||
ShellState::StartupError(message) => render_error(message.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ElyShell {
|
||||
fn render_browser(
|
||||
&mut self,
|
||||
snapshot: BrowserSnapshot,
|
||||
active_tab: BrowserTab,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
div()
|
||||
.size_full()
|
||||
.bg(rgb(ELY_THEME.canvas))
|
||||
.text_color(rgb(ELY_THEME.ink))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(self.render_command_bar(&snapshot, cx))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_1()
|
||||
.overflow_hidden()
|
||||
.child(self.render_sidebar(&snapshot, cx))
|
||||
.child(render_web_canvas(&active_tab)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_command_bar(
|
||||
&mut self,
|
||||
snapshot: &BrowserSnapshot,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
div()
|
||||
.h(px(spacing::COMMAND_BAR_HEIGHT))
|
||||
.px_4()
|
||||
.gap_3()
|
||||
.flex()
|
||||
.items_center()
|
||||
.border_b_1()
|
||||
.border_color(rgb(colors::HAIRLINE))
|
||||
.child(
|
||||
div()
|
||||
.w(px(spacing::SIDEBAR_WIDTH - spacing::XL))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(div().text_size(px(18.0)).font_semibold().child("ELY Browser"))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(rgb(colors::MUTED))
|
||||
.child(snapshot.active_space_name.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.h(px(40.0))
|
||||
.rounded_md()
|
||||
.border_1()
|
||||
.border_color(rgb(colors::HAIRLINE_STRONG))
|
||||
.bg(rgb(colors::SURFACE_CARD))
|
||||
.px_3()
|
||||
.child(Input::new(&self.command_input).appearance(false).cleanable(true)),
|
||||
)
|
||||
.child(
|
||||
Button::new("new-tab")
|
||||
.primary()
|
||||
.small()
|
||||
.label("+")
|
||||
.tooltip("New Tab")
|
||||
.on_click(cx.listener(|shell, _, _, cx| shell.open_new_tab(cx))),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_sidebar(&mut self, snapshot: &BrowserSnapshot, cx: &mut Context<Self>) -> AnyElement {
|
||||
div()
|
||||
.w(px(spacing::SIDEBAR_WIDTH))
|
||||
.h_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.p_3()
|
||||
.border_r_1()
|
||||
.border_color(rgb(colors::HAIRLINE))
|
||||
.bg(rgb(colors::CANVAS))
|
||||
.child(section_label("Favorites"))
|
||||
.child(empty_line("Pinned cross-space tabs appear here"))
|
||||
.child(section_label("Space"))
|
||||
.child(
|
||||
div()
|
||||
.rounded_md()
|
||||
.bg(rgb(colors::SURFACE_CARD))
|
||||
.border_1()
|
||||
.border_color(rgb(colors::HAIRLINE))
|
||||
.px_3()
|
||||
.py_2()
|
||||
.child(snapshot.active_space_name.clone()),
|
||||
)
|
||||
.child(section_label("Tabs"))
|
||||
.children(
|
||||
snapshot
|
||||
.tabs
|
||||
.iter()
|
||||
.map(|tab| self.render_tab_row(tab, tab.id() == &snapshot.active_tab_id, cx)),
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.child(section_label("Profile"))
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(rgb(colors::BODY))
|
||||
.child(snapshot.active_profile_name.clone()),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_tab_row(
|
||||
&mut self,
|
||||
tab: &BrowserTab,
|
||||
active: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let tab_id = tab.id().clone();
|
||||
let background = if active { colors::SURFACE_CARD } else { colors::CANVAS };
|
||||
let border = if active { colors::HAIRLINE_STRONG } else { colors::HAIRLINE };
|
||||
|
||||
div()
|
||||
.id(SharedString::from(tab.id().as_str().to_string()))
|
||||
.rounded_md()
|
||||
.border_1()
|
||||
.border_color(rgb(border))
|
||||
.bg(rgb(background))
|
||||
.px_3()
|
||||
.py_2()
|
||||
.gap_1()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.cursor_pointer()
|
||||
.hover(|style| style.bg(rgb(colors::SURFACE_CARD)))
|
||||
.active(|style| style.opacity(0.82))
|
||||
.on_click(cx.listener(move |shell, _, _, cx| shell.select_tab(&tab_id, cx)))
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.text_color(rgb(colors::INK))
|
||||
.child(tab.title().to_string()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(rgb(colors::MUTED))
|
||||
.child(tab.url().as_str().to_string()),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
fn render_web_canvas(tab: &BrowserTab) -> AnyElement {
|
||||
div()
|
||||
.flex_1()
|
||||
.h_full()
|
||||
.p_6()
|
||||
.bg(rgb(colors::CANVAS_SOFT))
|
||||
.child(
|
||||
div()
|
||||
.size_full()
|
||||
.rounded_lg()
|
||||
.border_1()
|
||||
.border_color(rgb(colors::HAIRLINE))
|
||||
.bg(rgb(colors::SURFACE_CARD))
|
||||
.p_8()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_4()
|
||||
.child(
|
||||
div()
|
||||
.text_size(px(26.0))
|
||||
.text_color(rgb(colors::INK))
|
||||
.child(tab.title().to_string()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(rgb(colors::MUTED))
|
||||
.child(tab.url().as_str().to_string()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.mt_4()
|
||||
.text_sm()
|
||||
.text_color(rgb(colors::BODY))
|
||||
.child("Servo host boundary owns webpage rendering, input, permissions, downloads, and recovery."),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_error(message: String) -> AnyElement {
|
||||
div()
|
||||
.size_full()
|
||||
.bg(rgb(colors::CANVAS))
|
||||
.text_color(rgb(colors::ERROR))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(message)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn section_label(label: &'static str) -> impl IntoElement {
|
||||
div().text_xs().font_semibold().text_color(rgb(colors::MUTED)).child(label)
|
||||
}
|
||||
|
||||
fn empty_line(text: &'static str) -> impl IntoElement {
|
||||
div().text_xs().text_color(rgb(colors::MUTED_SOFT)).child(text)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "ely_browser_core"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ely_domain = { path = "../ely_domain" }
|
||||
thiserror.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,14 @@
|
||||
use ely_domain::{DomainError, TabId};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Clone, Debug, Error, Eq, PartialEq)]
|
||||
pub enum CoreError {
|
||||
#[error(transparent)]
|
||||
Domain(#[from] DomainError),
|
||||
|
||||
#[error("tab not found: {id}")]
|
||||
TabNotFound { id: TabId },
|
||||
|
||||
#[error("browser state has no active tab")]
|
||||
MissingActiveTab,
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod error;
|
||||
mod state;
|
||||
|
||||
pub use error::CoreError;
|
||||
pub use state::{BrowserCore, BrowserSnapshot, InitialBrowserConfig};
|
||||
@@ -0,0 +1,148 @@
|
||||
use ely_domain::{
|
||||
BrowserTab, CommandIntent, DomainError, Profile, ProfileId, ProfileKind, Space, SpaceId, TabId,
|
||||
UrlText,
|
||||
};
|
||||
|
||||
use crate::CoreError;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InitialBrowserConfig {
|
||||
pub space_name: String,
|
||||
pub space_icon: String,
|
||||
pub profile_name: String,
|
||||
pub initial_url: UrlText,
|
||||
}
|
||||
|
||||
impl InitialBrowserConfig {
|
||||
pub fn ely_defaults() -> Result<Self, DomainError> {
|
||||
Ok(Self {
|
||||
space_name: "Work".to_string(),
|
||||
space_icon: "W".to_string(),
|
||||
profile_name: "Default".to_string(),
|
||||
initial_url: UrlText::parse("ely://new-tab")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BrowserSnapshot {
|
||||
pub tabs: Vec<BrowserTab>,
|
||||
pub active_tab_id: TabId,
|
||||
pub active_space_name: String,
|
||||
pub active_profile_name: String,
|
||||
pub command_query: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct BrowserCore {
|
||||
spaces: Vec<Space>,
|
||||
profiles: Vec<Profile>,
|
||||
tabs: Vec<BrowserTab>,
|
||||
active_space_id: SpaceId,
|
||||
active_profile_id: ProfileId,
|
||||
active_tab_id: TabId,
|
||||
command_query: String,
|
||||
}
|
||||
|
||||
impl BrowserCore {
|
||||
pub fn new(config: InitialBrowserConfig) -> Result<Self, CoreError> {
|
||||
let space = Space::new(config.space_name, config.space_icon, 0xf54e00);
|
||||
let profile = Profile::new(config.profile_name, 0x26251e, ProfileKind::Standard);
|
||||
let tab = BrowserTab::new(
|
||||
TabId::new(),
|
||||
space.id().clone(),
|
||||
profile.id().clone(),
|
||||
"New Tab",
|
||||
config.initial_url,
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
active_space_id: space.id().clone(),
|
||||
active_profile_id: profile.id().clone(),
|
||||
active_tab_id: tab.id().clone(),
|
||||
spaces: vec![space],
|
||||
profiles: vec![profile],
|
||||
tabs: vec![tab],
|
||||
command_query: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn open_tab(&mut self, url: UrlText) -> TabId {
|
||||
let title = tab_title(&url);
|
||||
let tab = BrowserTab::new(
|
||||
TabId::new(),
|
||||
self.active_space_id.clone(),
|
||||
self.active_profile_id.clone(),
|
||||
title,
|
||||
url,
|
||||
);
|
||||
let tab_id = tab.id().clone();
|
||||
self.tabs.push(tab);
|
||||
self.active_tab_id = tab_id.clone();
|
||||
tab_id
|
||||
}
|
||||
|
||||
pub fn select_tab(&mut self, tab_id: &TabId) -> Result<(), CoreError> {
|
||||
if self.tabs.iter().any(|tab| tab.id() == tab_id) {
|
||||
self.active_tab_id = tab_id.clone();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(CoreError::TabNotFound { id: tab_id.clone() })
|
||||
}
|
||||
|
||||
pub fn set_command_query(&mut self, query: impl Into<String>) {
|
||||
self.command_query = query.into();
|
||||
}
|
||||
|
||||
pub fn submit_command(&mut self) -> Result<Option<CommandIntent>, CoreError> {
|
||||
let query = self.command_query.trim();
|
||||
if query.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let intent = CommandIntent::parse(query)?;
|
||||
if let CommandIntent::Navigate(url) = &intent {
|
||||
self.open_tab(url.clone());
|
||||
self.command_query.clear();
|
||||
}
|
||||
|
||||
Ok(Some(intent))
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> Result<BrowserSnapshot, CoreError> {
|
||||
let active_space = self
|
||||
.spaces
|
||||
.iter()
|
||||
.find(|space| space.id() == &self.active_space_id)
|
||||
.ok_or(CoreError::MissingActiveTab)?;
|
||||
let active_profile = self
|
||||
.profiles
|
||||
.iter()
|
||||
.find(|profile| profile.id() == &self.active_profile_id)
|
||||
.ok_or(CoreError::MissingActiveTab)?;
|
||||
|
||||
Ok(BrowserSnapshot {
|
||||
tabs: self.tabs.clone(),
|
||||
active_tab_id: self.active_tab_id.clone(),
|
||||
active_space_name: active_space.name().to_string(),
|
||||
active_profile_name: active_profile.name().to_string(),
|
||||
command_query: self.command_query.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn active_tab(&self) -> Result<&BrowserTab, CoreError> {
|
||||
self.tabs
|
||||
.iter()
|
||||
.find(|tab| tab.id() == &self.active_tab_id)
|
||||
.ok_or(CoreError::MissingActiveTab)
|
||||
}
|
||||
}
|
||||
|
||||
fn tab_title(url: &UrlText) -> String {
|
||||
if url.as_str() == "ely://new-tab" {
|
||||
return "New Tab".to_string();
|
||||
}
|
||||
|
||||
url.display_host()
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "ely_design_system"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,14 @@
|
||||
pub const PRIMARY: u32 = 0xf54e00;
|
||||
pub const PRIMARY_ACTIVE: u32 = 0xd04200;
|
||||
pub const INK: u32 = 0x26251e;
|
||||
pub const BODY: u32 = 0x5a5852;
|
||||
pub const MUTED: u32 = 0x807d72;
|
||||
pub const MUTED_SOFT: u32 = 0xa09c92;
|
||||
pub const HAIRLINE: u32 = 0xe6e5e0;
|
||||
pub const HAIRLINE_STRONG: u32 = 0xcfcdc4;
|
||||
pub const CANVAS: u32 = 0xf7f7f4;
|
||||
pub const CANVAS_SOFT: u32 = 0xfafaf7;
|
||||
pub const SURFACE_CARD: u32 = 0xffffff;
|
||||
pub const SURFACE_STRONG: u32 = 0xe6e5e0;
|
||||
pub const SUCCESS: u32 = 0x1f8a65;
|
||||
pub const ERROR: u32 = 0xcf2d56;
|
||||
@@ -0,0 +1,31 @@
|
||||
pub mod colors;
|
||||
pub mod motion;
|
||||
pub mod spacing;
|
||||
pub mod typography;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct Theme {
|
||||
pub canvas: u32,
|
||||
pub canvas_soft: u32,
|
||||
pub surface: u32,
|
||||
pub ink: u32,
|
||||
pub body: u32,
|
||||
pub muted: u32,
|
||||
pub hairline: u32,
|
||||
pub accent: u32,
|
||||
pub success: u32,
|
||||
pub error: u32,
|
||||
}
|
||||
|
||||
pub const ELY_THEME: Theme = Theme {
|
||||
canvas: colors::CANVAS,
|
||||
canvas_soft: colors::CANVAS_SOFT,
|
||||
surface: colors::SURFACE_CARD,
|
||||
ink: colors::INK,
|
||||
body: colors::BODY,
|
||||
muted: colors::MUTED,
|
||||
hairline: colors::HAIRLINE,
|
||||
accent: colors::PRIMARY,
|
||||
success: colors::SUCCESS,
|
||||
error: colors::ERROR,
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MotionRegister {
|
||||
Productive,
|
||||
Expressive,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct MotionToken {
|
||||
pub register: MotionRegister,
|
||||
pub duration_ms: u16,
|
||||
}
|
||||
|
||||
pub const HOVER_FEEDBACK: MotionToken =
|
||||
MotionToken { register: MotionRegister::Productive, duration_ms: 120 };
|
||||
|
||||
pub const PANEL_TRANSITION: MotionToken =
|
||||
MotionToken { register: MotionRegister::Productive, duration_ms: 180 };
|
||||
@@ -0,0 +1,11 @@
|
||||
pub const XXS: f32 = 4.0;
|
||||
pub const XS: f32 = 8.0;
|
||||
pub const SM: f32 = 12.0;
|
||||
pub const BASE: f32 = 16.0;
|
||||
pub const MD: f32 = 20.0;
|
||||
pub const LG: f32 = 24.0;
|
||||
pub const XL: f32 = 32.0;
|
||||
pub const XXL: f32 = 48.0;
|
||||
pub const SIDEBAR_WIDTH: f32 = 280.0;
|
||||
pub const SIDEBAR_COLLAPSED_WIDTH: f32 = 56.0;
|
||||
pub const COMMAND_BAR_HEIGHT: f32 = 64.0;
|
||||
@@ -0,0 +1,14 @@
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct TypeToken {
|
||||
pub size_px: f32,
|
||||
pub line_height: f32,
|
||||
pub weight: u16,
|
||||
}
|
||||
|
||||
pub const DISPLAY_MD: TypeToken = TypeToken { size_px: 26.0, line_height: 1.25, weight: 400 };
|
||||
|
||||
pub const TITLE_MD: TypeToken = TypeToken { size_px: 18.0, line_height: 1.4, weight: 600 };
|
||||
|
||||
pub const BODY_MD: TypeToken = TypeToken { size_px: 16.0, line_height: 1.5, weight: 400 };
|
||||
|
||||
pub const CAPTION: TypeToken = TypeToken { size_px: 13.0, line_height: 1.4, weight: 400 };
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "ely_domain"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
url.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,62 @@
|
||||
use crate::{DomainError, UrlText};
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum CommandScope {
|
||||
Tabs,
|
||||
Bookmarks,
|
||||
History,
|
||||
Settings,
|
||||
Plugins,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum CommandIntent {
|
||||
Navigate(UrlText),
|
||||
Search(String),
|
||||
Command(String),
|
||||
ScopedSearch { scope: CommandScope, query: String },
|
||||
}
|
||||
|
||||
impl CommandIntent {
|
||||
pub fn parse(input: &str) -> Result<Self, DomainError> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(DomainError::InvalidCommand);
|
||||
}
|
||||
|
||||
if let Some(command) = trimmed.strip_prefix('>') {
|
||||
return non_empty_text(command).map(Self::Command);
|
||||
}
|
||||
|
||||
if let Some(query) = trimmed.strip_prefix('?') {
|
||||
return non_empty_text(query).map(Self::Search);
|
||||
}
|
||||
|
||||
if let Some((scope, query)) = parse_scope(trimmed) {
|
||||
return non_empty_text(query).map(|query| Self::ScopedSearch { scope, query });
|
||||
}
|
||||
|
||||
UrlText::from_address_text(trimmed).map(Self::Navigate)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_scope(value: &str) -> Option<(CommandScope, &str)> {
|
||||
let (scope, query) = value.split_once(' ')?;
|
||||
let scope = match scope {
|
||||
"@tabs" => CommandScope::Tabs,
|
||||
"@bookmarks" => CommandScope::Bookmarks,
|
||||
"@history" => CommandScope::History,
|
||||
"@settings" => CommandScope::Settings,
|
||||
"@plugins" => CommandScope::Plugins,
|
||||
_ => return None,
|
||||
};
|
||||
Some((scope, query))
|
||||
}
|
||||
|
||||
fn non_empty_text(value: &str) -> Result<String, DomainError> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(DomainError::InvalidCommand);
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Clone, Debug, Error, Eq, PartialEq)]
|
||||
pub enum DomainError {
|
||||
#[error("{field} cannot be empty")]
|
||||
EmptyField { field: &'static str },
|
||||
|
||||
#[error("invalid URL: {value}")]
|
||||
InvalidUrl { value: String },
|
||||
|
||||
#[error("invalid command query")]
|
||||
InvalidCommand,
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use std::fmt;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
macro_rules! entity_id {
|
||||
($name:ident, $prefix:literal) => {
|
||||
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct $name(String);
|
||||
|
||||
impl $name {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self(format!("{}_{}", $prefix, Uuid::now_v7().simple()))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for $name {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
entity_id!(TabId, "tab");
|
||||
entity_id!(SpaceId, "space");
|
||||
entity_id!(ProfileId, "profile");
|
||||
entity_id!(SplitId, "split");
|
||||
entity_id!(WebViewId, "webview");
|
||||
@@ -0,0 +1,17 @@
|
||||
mod command;
|
||||
mod error;
|
||||
mod identifiers;
|
||||
mod profile;
|
||||
mod space;
|
||||
mod split;
|
||||
mod tab;
|
||||
mod url_text;
|
||||
|
||||
pub use command::{CommandIntent, CommandScope};
|
||||
pub use error::DomainError;
|
||||
pub use identifiers::{ProfileId, SpaceId, SplitId, TabId, WebViewId};
|
||||
pub use profile::{Profile, ProfileKind};
|
||||
pub use space::{ArchivePolicy, Space};
|
||||
pub use split::{SplitAxis, SplitLayout, SplitPane};
|
||||
pub use tab::{BrowserTab, TabFlags, TabState};
|
||||
pub use url_text::UrlText;
|
||||
@@ -0,0 +1,42 @@
|
||||
use crate::ProfileId;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ProfileKind {
|
||||
Standard,
|
||||
Private,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Profile {
|
||||
id: ProfileId,
|
||||
name: String,
|
||||
color_hex: u32,
|
||||
kind: ProfileKind,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
#[must_use]
|
||||
pub fn new(name: impl Into<String>, color_hex: u32, kind: ProfileKind) -> Self {
|
||||
Self { id: ProfileId::new(), name: name.into(), color_hex, kind }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn id(&self) -> &ProfileId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn color_hex(&self) -> u32 {
|
||||
self.color_hex
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn kind(&self) -> &ProfileKind {
|
||||
&self.kind
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
use crate::SpaceId;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ArchivePolicy {
|
||||
Manual,
|
||||
IdleDays(u16),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Space {
|
||||
id: SpaceId,
|
||||
name: String,
|
||||
icon: String,
|
||||
accent_hex: u32,
|
||||
archive_policy: ArchivePolicy,
|
||||
}
|
||||
|
||||
impl Space {
|
||||
#[must_use]
|
||||
pub fn new(name: impl Into<String>, icon: impl Into<String>, accent_hex: u32) -> Self {
|
||||
Self {
|
||||
id: SpaceId::new(),
|
||||
name: name.into(),
|
||||
icon: icon.into(),
|
||||
accent_hex,
|
||||
archive_policy: ArchivePolicy::Manual,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn id(&self) -> &SpaceId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn icon(&self) -> &str {
|
||||
&self.icon
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn accent_hex(&self) -> u32 {
|
||||
self.accent_hex
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn archive_policy(&self) -> &ArchivePolicy {
|
||||
&self.archive_policy
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use crate::{SplitId, TabId};
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum SplitAxis {
|
||||
Horizontal,
|
||||
Vertical,
|
||||
Grid,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SplitPane {
|
||||
tab_id: TabId,
|
||||
weight: u16,
|
||||
}
|
||||
|
||||
impl SplitPane {
|
||||
#[must_use]
|
||||
pub fn new(tab_id: TabId, weight: u16) -> Self {
|
||||
Self { tab_id, weight }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn tab_id(&self) -> &TabId {
|
||||
&self.tab_id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn weight(&self) -> u16 {
|
||||
self.weight
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SplitLayout {
|
||||
id: SplitId,
|
||||
axis: SplitAxis,
|
||||
panes: Vec<SplitPane>,
|
||||
}
|
||||
|
||||
impl SplitLayout {
|
||||
#[must_use]
|
||||
pub fn new(axis: SplitAxis, panes: Vec<SplitPane>) -> Self {
|
||||
Self { id: SplitId::new(), axis, panes }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn id(&self) -> &SplitId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn axis(&self) -> &SplitAxis {
|
||||
&self.axis
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn panes(&self) -> &[SplitPane] {
|
||||
&self.panes
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
use crate::{ProfileId, SpaceId, SplitId, TabId, UrlText};
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum TabState {
|
||||
Loading,
|
||||
Ready,
|
||||
Crashed,
|
||||
Discarded,
|
||||
Archived,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct TabFlags {
|
||||
pub pinned: bool,
|
||||
pub favorite: bool,
|
||||
pub muted: bool,
|
||||
pub unread: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct BrowserTab {
|
||||
id: TabId,
|
||||
space_id: SpaceId,
|
||||
profile_id: ProfileId,
|
||||
title: String,
|
||||
url: UrlText,
|
||||
state: TabState,
|
||||
flags: TabFlags,
|
||||
split_id: Option<SplitId>,
|
||||
}
|
||||
|
||||
impl BrowserTab {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
id: TabId,
|
||||
space_id: SpaceId,
|
||||
profile_id: ProfileId,
|
||||
title: impl Into<String>,
|
||||
url: UrlText,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
space_id,
|
||||
profile_id,
|
||||
title: title.into(),
|
||||
url,
|
||||
state: TabState::Ready,
|
||||
flags: TabFlags::default(),
|
||||
split_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn id(&self) -> &TabId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn space_id(&self) -> &SpaceId {
|
||||
&self.space_id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn profile_id(&self) -> &ProfileId {
|
||||
&self.profile_id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn url(&self) -> &UrlText {
|
||||
&self.url
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn state(&self) -> &TabState {
|
||||
&self.state
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn flags(&self) -> &TabFlags {
|
||||
&self.flags
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn split_id(&self) -> Option<&SplitId> {
|
||||
self.split_id.as_ref()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use std::fmt;
|
||||
|
||||
use url::Url;
|
||||
|
||||
use crate::DomainError;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct UrlText {
|
||||
value: String,
|
||||
}
|
||||
|
||||
impl UrlText {
|
||||
pub fn parse(value: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let value = value.into();
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(DomainError::EmptyField { field: "url" });
|
||||
}
|
||||
|
||||
Url::parse(trimmed).map_err(|_| DomainError::InvalidUrl { value: trimmed.to_string() })?;
|
||||
|
||||
Ok(Self { value: trimmed.to_string() })
|
||||
}
|
||||
|
||||
pub fn from_address_text(value: &str) -> Result<Self, DomainError> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(DomainError::EmptyField { field: "url" });
|
||||
}
|
||||
|
||||
if Url::parse(trimmed).is_ok() {
|
||||
return Self::parse(trimmed);
|
||||
}
|
||||
|
||||
if trimmed.contains('.') && !trimmed.contains(char::is_whitespace) {
|
||||
return Self::parse(format!("https://{trimmed}"));
|
||||
}
|
||||
|
||||
Err(DomainError::InvalidUrl { value: trimmed.to_string() })
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.value
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn display_host(&self) -> String {
|
||||
Url::parse(&self.value)
|
||||
.ok()
|
||||
.and_then(|url| url.host_str().map(str::to_string))
|
||||
.unwrap_or_else(|| self.value.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for UrlText {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "ely_servo_host"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ely_domain = { path = "../ely_domain" }
|
||||
thiserror.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,11 @@
|
||||
use ely_domain::WebViewId;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Clone, Debug, Error, Eq, PartialEq)]
|
||||
pub enum ServoHostError {
|
||||
#[error("webview not found: {id}")]
|
||||
WebViewNotFound { id: WebViewId },
|
||||
|
||||
#[error("permission request missing profile context")]
|
||||
MissingProfileContext,
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use ely_domain::{ProfileId, TabId, UrlText, WebViewId};
|
||||
|
||||
use crate::ServoHostError;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum WebViewState {
|
||||
Created,
|
||||
Attached,
|
||||
Sleeping,
|
||||
Crashed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct NavigationRequest {
|
||||
pub webview_id: WebViewId,
|
||||
pub tab_id: TabId,
|
||||
pub url: UrlText,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct PermissionRequest {
|
||||
pub webview_id: WebViewId,
|
||||
pub tab_id: TabId,
|
||||
pub profile_id: ProfileId,
|
||||
pub feature: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum PermissionDecision {
|
||||
AllowOnce,
|
||||
AllowAlways,
|
||||
DenyAlways,
|
||||
}
|
||||
|
||||
pub trait ServoHost {
|
||||
fn create_webview(
|
||||
&mut self,
|
||||
tab_id: TabId,
|
||||
profile_id: ProfileId,
|
||||
) -> Result<WebViewId, ServoHostError>;
|
||||
|
||||
fn navigate(&mut self, request: NavigationRequest) -> Result<(), ServoHostError>;
|
||||
|
||||
fn set_permission(
|
||||
&mut self,
|
||||
request: PermissionRequest,
|
||||
decision: PermissionDecision,
|
||||
) -> Result<(), ServoHostError>;
|
||||
|
||||
fn state(&self, webview_id: &WebViewId) -> Result<WebViewState, ServoHostError>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod error;
|
||||
mod host;
|
||||
|
||||
pub use error::ServoHostError;
|
||||
pub use host::{NavigationRequest, PermissionDecision, PermissionRequest, ServoHost, WebViewState};
|
||||
Reference in New Issue
Block a user