logiguard fork: GPUI xdg-activation keyboard-focus serial fix
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled
Single-commit orphan branch: full zed-industries/zed @ 8c74db0 source tree with a 3-file patch applied (no upstream history). Patch (crates/gpui_linux/src/linux/wayland/): - serial.rs: add SerialKind::KeyboardEnter - client.rs: store wl_keyboard.enter serial; add latest_serial_of() - window.rs: activate() uses keyboard-enter serial (Mutter focus gate) Mutter honors window activation only when the token carries the keyboard- focus serial from wl_keyboard.enter; GPUI used a stale mouse-press serial. See docs/tray-window-focus-wayland.md in logiguard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
74
crates/settings_ui/Cargo.toml
Normal file
74
crates/settings_ui/Cargo.toml
Normal file
@@ -0,0 +1,74 @@
|
||||
[package]
|
||||
name = "settings_ui"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/settings_ui.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
agent.workspace = true
|
||||
agent_settings.workspace = true
|
||||
anyhow.workspace = true
|
||||
audio.workspace = true
|
||||
component.workspace = true
|
||||
codestral.workspace = true
|
||||
copilot.workspace = true
|
||||
copilot_ui.workspace = true
|
||||
cpal.workspace = true
|
||||
edit_prediction.workspace = true
|
||||
edit_prediction_ui.workspace = true
|
||||
editor.workspace = true
|
||||
fs.workspace = true
|
||||
feature_flags.workspace = true
|
||||
futures.workspace = true
|
||||
fuzzy.workspace = true
|
||||
gpui.workspace = true
|
||||
heck.workspace = true
|
||||
itertools.workspace = true
|
||||
language.workspace = true
|
||||
log.workspace = true
|
||||
menu.workspace = true
|
||||
paths.workspace = true
|
||||
picker.workspace = true
|
||||
regex.workspace = true
|
||||
platform_title_bar.workspace = true
|
||||
project.workspace = true
|
||||
release_channel.workspace = true
|
||||
rodio.workspace = true
|
||||
schemars.workspace = true
|
||||
search.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
settings.workspace = true
|
||||
shell_command_parser.workspace = true
|
||||
strum.workspace = true
|
||||
telemetry.workspace = true
|
||||
theme.workspace = true
|
||||
theme_settings.workspace = true
|
||||
ui.workspace = true
|
||||
util.workspace = true
|
||||
workspace.workspace = true
|
||||
zed_actions.workspace = true
|
||||
zed_credentials_provider.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
fs = { workspace = true, features = ["test-support"] }
|
||||
futures.workspace = true
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
paths.workspace = true
|
||||
pretty_assertions.workspace = true
|
||||
project = { workspace = true, features = ["test-support"] }
|
||||
serde_json.workspace = true
|
||||
settings = { workspace = true, features = ["test-support"] }
|
||||
title_bar = { workspace = true, features = ["test-support"] }
|
||||
workspace = { workspace = true, features = ["test-support"] }
|
||||
1
crates/settings_ui/LICENSE-GPL
Symbolic link
1
crates/settings_ui/LICENSE-GPL
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
17
crates/settings_ui/src/components.rs
Normal file
17
crates/settings_ui/src/components.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
mod dropdown;
|
||||
mod font_picker;
|
||||
mod icon_theme_picker;
|
||||
mod input_field;
|
||||
mod number_field;
|
||||
mod ollama_model_picker;
|
||||
mod section_items;
|
||||
mod theme_picker;
|
||||
|
||||
pub use dropdown::*;
|
||||
pub use font_picker::font_picker;
|
||||
pub use icon_theme_picker::icon_theme_picker;
|
||||
pub use input_field::*;
|
||||
pub use number_field::*;
|
||||
pub use ollama_model_picker::render_ollama_model_picker;
|
||||
pub use section_items::*;
|
||||
pub use theme_picker::theme_picker;
|
||||
108
crates/settings_ui/src/components/dropdown.rs
Normal file
108
crates/settings_ui/src/components/dropdown.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{App, ElementId, IntoElement, RenderOnce};
|
||||
use heck::ToTitleCase as _;
|
||||
use ui::{
|
||||
ButtonSize, ContextMenu, DropdownMenu, DropdownStyle, FluentBuilder as _, IconPosition, px,
|
||||
};
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct EnumVariantDropdown<T>
|
||||
where
|
||||
T: strum::VariantArray + strum::VariantNames + Copy + PartialEq + Send + Sync + 'static,
|
||||
{
|
||||
id: ElementId,
|
||||
current_value: T,
|
||||
variants: &'static [T],
|
||||
labels: &'static [&'static str],
|
||||
should_do_title_case: bool,
|
||||
tab_index: Option<isize>,
|
||||
on_change: Rc<dyn Fn(T, &mut ui::Window, &mut App) + 'static>,
|
||||
}
|
||||
|
||||
impl<T> EnumVariantDropdown<T>
|
||||
where
|
||||
T: strum::VariantArray + strum::VariantNames + Copy + PartialEq + Send + Sync + 'static,
|
||||
{
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
current_value: T,
|
||||
variants: &'static [T],
|
||||
labels: &'static [&'static str],
|
||||
on_change: impl Fn(T, &mut ui::Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
current_value,
|
||||
variants,
|
||||
labels,
|
||||
should_do_title_case: true,
|
||||
tab_index: None,
|
||||
on_change: Rc::new(on_change),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn title_case(mut self, title_case: bool) -> Self {
|
||||
self.should_do_title_case = title_case;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn tab_index(mut self, tab_index: isize) -> Self {
|
||||
self.tab_index = Some(tab_index);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> RenderOnce for EnumVariantDropdown<T>
|
||||
where
|
||||
T: strum::VariantArray + strum::VariantNames + Copy + PartialEq + Send + Sync + 'static,
|
||||
{
|
||||
fn render(self, window: &mut ui::Window, cx: &mut ui::App) -> impl gpui::IntoElement {
|
||||
let current_value_label = self.labels[self
|
||||
.variants
|
||||
.iter()
|
||||
.position(|v| *v == self.current_value)
|
||||
.unwrap()];
|
||||
|
||||
let context_menu = window.use_keyed_state(current_value_label, cx, |window, cx| {
|
||||
ContextMenu::new(window, cx, move |mut menu, _, _| {
|
||||
for (&value, &label) in std::iter::zip(self.variants, self.labels) {
|
||||
let on_change = self.on_change.clone();
|
||||
let current_value = self.current_value;
|
||||
menu = menu.toggleable_entry(
|
||||
if self.should_do_title_case {
|
||||
label.to_title_case()
|
||||
} else {
|
||||
label.to_string()
|
||||
},
|
||||
value == current_value,
|
||||
IconPosition::End,
|
||||
None,
|
||||
move |window, cx| {
|
||||
on_change(value, window, cx);
|
||||
},
|
||||
);
|
||||
}
|
||||
menu
|
||||
})
|
||||
});
|
||||
|
||||
DropdownMenu::new(
|
||||
self.id,
|
||||
if self.should_do_title_case {
|
||||
current_value_label.to_title_case()
|
||||
} else {
|
||||
current_value_label.to_string()
|
||||
},
|
||||
context_menu,
|
||||
)
|
||||
.when_some(self.tab_index, |elem, tab_index| elem.tab_index(tab_index))
|
||||
.trigger_size(ButtonSize::Medium)
|
||||
.style(DropdownStyle::Outlined)
|
||||
.offset(gpui::Point {
|
||||
x: px(0.0),
|
||||
y: px(2.0),
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
181
crates/settings_ui/src/components/font_picker.rs
Normal file
181
crates/settings_ui/src/components/font_picker.rs
Normal file
@@ -0,0 +1,181 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use fuzzy::{StringMatch, StringMatchCandidate};
|
||||
use gpui::{AnyElement, App, Context, DismissEvent, SharedString, Task, Window};
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use theme::FontFamilyCache;
|
||||
use ui::{ListItem, ListItemSpacing, prelude::*};
|
||||
|
||||
type FontPicker = Picker<FontPickerDelegate>;
|
||||
|
||||
pub struct FontPickerDelegate {
|
||||
fonts: Vec<SharedString>,
|
||||
filtered_fonts: Vec<StringMatch>,
|
||||
selected_index: usize,
|
||||
current_font: SharedString,
|
||||
on_font_changed: Arc<dyn Fn(SharedString, &mut Window, &mut App) + 'static>,
|
||||
}
|
||||
|
||||
impl FontPickerDelegate {
|
||||
fn new(
|
||||
current_font: SharedString,
|
||||
on_font_changed: impl Fn(SharedString, &mut Window, &mut App) + 'static,
|
||||
cx: &mut Context<FontPicker>,
|
||||
) -> Self {
|
||||
let font_family_cache = FontFamilyCache::global(cx);
|
||||
|
||||
let fonts = font_family_cache
|
||||
.try_list_font_families()
|
||||
.unwrap_or_else(|| vec![current_font.clone()]);
|
||||
let selected_index = fonts
|
||||
.iter()
|
||||
.position(|font| *font == current_font)
|
||||
.unwrap_or(0);
|
||||
|
||||
let filtered_fonts = fonts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, font)| StringMatch {
|
||||
candidate_id: index,
|
||||
string: font.to_string(),
|
||||
positions: Vec::new(),
|
||||
score: 0.0,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
fonts,
|
||||
filtered_fonts,
|
||||
selected_index,
|
||||
current_font,
|
||||
on_font_changed: Arc::new(on_font_changed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PickerDelegate for FontPickerDelegate {
|
||||
type ListItem = AnyElement;
|
||||
|
||||
fn match_count(&self) -> usize {
|
||||
self.filtered_fonts.len()
|
||||
}
|
||||
|
||||
fn selected_index(&self) -> usize {
|
||||
self.selected_index
|
||||
}
|
||||
|
||||
fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<FontPicker>) {
|
||||
self.selected_index = ix.min(self.filtered_fonts.len().saturating_sub(1));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
|
||||
"Search fonts…".into()
|
||||
}
|
||||
|
||||
fn update_matches(
|
||||
&mut self,
|
||||
query: String,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<FontPicker>,
|
||||
) -> Task<()> {
|
||||
let fonts = self.fonts.clone();
|
||||
let current_font = self.current_font.clone();
|
||||
|
||||
let matches: Vec<StringMatch> = if query.is_empty() {
|
||||
fonts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, font)| StringMatch {
|
||||
candidate_id: index,
|
||||
string: font.to_string(),
|
||||
positions: Vec::new(),
|
||||
score: 0.0,
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
let _candidates: Vec<StringMatchCandidate> = fonts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(id, font)| StringMatchCandidate::new(id, font.as_ref()))
|
||||
.collect();
|
||||
|
||||
fonts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, font)| font.to_lowercase().contains(&query.to_lowercase()))
|
||||
.map(|(index, font)| StringMatch {
|
||||
candidate_id: index,
|
||||
string: font.to_string(),
|
||||
positions: Vec::new(),
|
||||
score: 0.0,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let selected_index = if query.is_empty() {
|
||||
fonts
|
||||
.iter()
|
||||
.position(|font| *font == current_font)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
matches
|
||||
.iter()
|
||||
.position(|m| fonts[m.candidate_id] == current_font)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
self.filtered_fonts = matches;
|
||||
self.selected_index = selected_index;
|
||||
cx.notify();
|
||||
|
||||
Task::ready(())
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<FontPicker>) {
|
||||
if let Some(font_match) = self.filtered_fonts.get(self.selected_index) {
|
||||
let font = font_match.string.clone();
|
||||
(self.on_font_changed)(font.into(), window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn dismissed(&mut self, window: &mut Window, cx: &mut Context<FontPicker>) {
|
||||
cx.defer_in(window, |picker, window, cx| {
|
||||
picker.set_query("", window, cx);
|
||||
});
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
|
||||
fn render_match(
|
||||
&self,
|
||||
ix: usize,
|
||||
selected: bool,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<FontPicker>,
|
||||
) -> Option<Self::ListItem> {
|
||||
let font_match = self.filtered_fonts.get(ix)?;
|
||||
|
||||
Some(
|
||||
ListItem::new(ix)
|
||||
.inset(true)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
.toggle_state(selected)
|
||||
.child(Label::new(font_match.string.clone()))
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn font_picker(
|
||||
current_font: SharedString,
|
||||
on_font_changed: impl Fn(SharedString, &mut Window, &mut App) + 'static,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<FontPicker>,
|
||||
) -> FontPicker {
|
||||
let delegate = FontPickerDelegate::new(current_font, on_font_changed, cx);
|
||||
|
||||
Picker::uniform_list(delegate, window, cx)
|
||||
.show_scrollbar(true)
|
||||
.width(rems_from_px(210.))
|
||||
.max_height(Some(rems(18.).into()))
|
||||
}
|
||||
194
crates/settings_ui/src/components/icon_theme_picker.rs
Normal file
194
crates/settings_ui/src/components/icon_theme_picker.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use fuzzy::{StringMatch, StringMatchCandidate};
|
||||
use gpui::{AnyElement, App, Context, DismissEvent, SharedString, Task, Window};
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use theme::ThemeRegistry;
|
||||
use ui::{ListItem, ListItemSpacing, prelude::*};
|
||||
|
||||
type IconThemePicker = Picker<IconThemePickerDelegate>;
|
||||
|
||||
pub struct IconThemePickerDelegate {
|
||||
icon_themes: Vec<SharedString>,
|
||||
filtered_themes: Vec<StringMatch>,
|
||||
selected_index: usize,
|
||||
current_theme: SharedString,
|
||||
on_theme_changed: Arc<dyn Fn(SharedString, &mut Window, &mut App) + 'static>,
|
||||
}
|
||||
|
||||
impl IconThemePickerDelegate {
|
||||
fn new(
|
||||
current_theme: SharedString,
|
||||
on_theme_changed: impl Fn(SharedString, &mut Window, &mut App) + 'static,
|
||||
cx: &mut Context<IconThemePicker>,
|
||||
) -> Self {
|
||||
let theme_registry = ThemeRegistry::global(cx);
|
||||
|
||||
let icon_themes: Vec<SharedString> = theme_registry
|
||||
.list_icon_themes()
|
||||
.into_iter()
|
||||
.map(|theme_meta| theme_meta.name)
|
||||
.collect();
|
||||
|
||||
let selected_index = icon_themes
|
||||
.iter()
|
||||
.position(|icon_theme| *icon_theme == current_theme)
|
||||
.unwrap_or(0);
|
||||
|
||||
let filtered_themes = icon_themes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, theme)| StringMatch {
|
||||
candidate_id: index,
|
||||
string: theme.to_string(),
|
||||
positions: Vec::new(),
|
||||
score: 0.0,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
icon_themes,
|
||||
filtered_themes,
|
||||
selected_index,
|
||||
current_theme,
|
||||
on_theme_changed: Arc::new(on_theme_changed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PickerDelegate for IconThemePickerDelegate {
|
||||
type ListItem = AnyElement;
|
||||
|
||||
fn match_count(&self) -> usize {
|
||||
self.filtered_themes.len()
|
||||
}
|
||||
|
||||
fn selected_index(&self) -> usize {
|
||||
self.selected_index
|
||||
}
|
||||
|
||||
fn set_selected_index(
|
||||
&mut self,
|
||||
index: usize,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<IconThemePicker>,
|
||||
) {
|
||||
self.selected_index = index.min(self.filtered_themes.len().saturating_sub(1));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
|
||||
"Search icon themes…".into()
|
||||
}
|
||||
|
||||
fn update_matches(
|
||||
&mut self,
|
||||
query: String,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<IconThemePicker>,
|
||||
) -> Task<()> {
|
||||
let icon_themes = self.icon_themes.clone();
|
||||
let current_theme = self.current_theme.clone();
|
||||
|
||||
let matches: Vec<StringMatch> = if query.is_empty() {
|
||||
icon_themes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, theme)| StringMatch {
|
||||
candidate_id: index,
|
||||
string: theme.to_string(),
|
||||
positions: Vec::new(),
|
||||
score: 0.0,
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
let _candidates: Vec<StringMatchCandidate> = icon_themes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(id, theme)| StringMatchCandidate::new(id, theme.as_ref()))
|
||||
.collect();
|
||||
|
||||
icon_themes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, theme)| theme.to_lowercase().contains(&query.to_lowercase()))
|
||||
.map(|(index, theme)| StringMatch {
|
||||
candidate_id: index,
|
||||
string: theme.to_string(),
|
||||
positions: Vec::new(),
|
||||
score: 0.0,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let selected_index = if query.is_empty() {
|
||||
icon_themes
|
||||
.iter()
|
||||
.position(|theme| *theme == current_theme)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
matches
|
||||
.iter()
|
||||
.position(|m| icon_themes[m.candidate_id] == current_theme)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
self.filtered_themes = matches;
|
||||
self.selected_index = selected_index;
|
||||
cx.notify();
|
||||
|
||||
Task::ready(())
|
||||
}
|
||||
|
||||
fn confirm(
|
||||
&mut self,
|
||||
_secondary: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<IconThemePicker>,
|
||||
) {
|
||||
if let Some(theme_match) = self.filtered_themes.get(self.selected_index) {
|
||||
let theme = theme_match.string.clone();
|
||||
(self.on_theme_changed)(theme.into(), window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn dismissed(&mut self, window: &mut Window, cx: &mut Context<IconThemePicker>) {
|
||||
cx.defer_in(window, |picker, window, cx| {
|
||||
picker.set_query("", window, cx);
|
||||
});
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
|
||||
fn render_match(
|
||||
&self,
|
||||
index: usize,
|
||||
selected: bool,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<IconThemePicker>,
|
||||
) -> Option<Self::ListItem> {
|
||||
let theme_match = self.filtered_themes.get(index)?;
|
||||
|
||||
Some(
|
||||
ListItem::new(index)
|
||||
.inset(true)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
.toggle_state(selected)
|
||||
.child(Label::new(theme_match.string.clone()))
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon_theme_picker(
|
||||
current_theme: SharedString,
|
||||
on_theme_changed: impl Fn(SharedString, &mut Window, &mut App) + 'static,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<IconThemePicker>,
|
||||
) -> IconThemePicker {
|
||||
let delegate = IconThemePickerDelegate::new(current_theme, on_theme_changed, cx);
|
||||
|
||||
Picker::uniform_list(delegate, window, cx)
|
||||
.show_scrollbar(true)
|
||||
.width(rems_from_px(210.))
|
||||
.max_height(Some(rems(18.).into()))
|
||||
}
|
||||
318
crates/settings_ui/src/components/input_field.rs
Normal file
318
crates/settings_ui/src/components/input_field.rs
Normal file
@@ -0,0 +1,318 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use editor::Editor;
|
||||
use gpui::{AnyElement, ElementId, Focusable, TextStyleRefinement};
|
||||
use settings::Settings as _;
|
||||
use theme_settings::ThemeSettings;
|
||||
use ui::{Tooltip, prelude::*, rems};
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct SettingsInputField {
|
||||
id: Option<ElementId>,
|
||||
initial_text: Option<String>,
|
||||
placeholder: Option<&'static str>,
|
||||
confirm: Option<Rc<dyn Fn(Option<String>, &mut Window, &mut App)>>,
|
||||
tab_index: Option<isize>,
|
||||
use_buffer_font: bool,
|
||||
display_confirm_button: bool,
|
||||
display_clear_button: bool,
|
||||
clear_on_confirm: bool,
|
||||
action_slot: Option<AnyElement>,
|
||||
color: Option<Color>,
|
||||
}
|
||||
|
||||
impl SettingsInputField {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
initial_text: None,
|
||||
placeholder: None,
|
||||
confirm: None,
|
||||
tab_index: None,
|
||||
use_buffer_font: false,
|
||||
display_confirm_button: false,
|
||||
display_clear_button: false,
|
||||
clear_on_confirm: false,
|
||||
action_slot: None,
|
||||
color: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_id(mut self, id: impl Into<ElementId>) -> Self {
|
||||
self.id = Some(id.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_initial_text(mut self, initial_text: String) -> Self {
|
||||
self.initial_text = Some(initial_text);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_placeholder(mut self, placeholder: &'static str) -> Self {
|
||||
self.placeholder = Some(placeholder);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_confirm(
|
||||
mut self,
|
||||
confirm: impl Fn(Option<String>, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.confirm = Some(Rc::new(confirm));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn display_confirm_button(mut self) -> Self {
|
||||
self.display_confirm_button = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn display_clear_button(mut self) -> Self {
|
||||
self.display_clear_button = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn clear_on_confirm(mut self) -> Self {
|
||||
self.clear_on_confirm = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn action_slot(mut self, action: impl IntoElement) -> Self {
|
||||
self.action_slot = Some(action.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn tab_index(mut self, arg: isize) -> Self {
|
||||
self.tab_index = Some(arg);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_buffer_font(mut self) -> Self {
|
||||
self.use_buffer_font = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn color(mut self, color: Color) -> Self {
|
||||
self.color = Some(color);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SettingsInputField {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl ui::IntoElement {
|
||||
let settings = ThemeSettings::get_global(cx);
|
||||
let use_buffer_font = self.use_buffer_font;
|
||||
let color = self.color.map(|c| c.color(cx));
|
||||
let styles = TextStyleRefinement {
|
||||
font_family: use_buffer_font.then(|| settings.buffer_font.family.clone()),
|
||||
font_size: use_buffer_font.then(|| rems(0.75).into()),
|
||||
color,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let first_render_initial_text = window.use_state(cx, |_, _| self.initial_text.clone());
|
||||
|
||||
let editor = if let Some(id) = self.id {
|
||||
window.use_keyed_state(id, cx, {
|
||||
let initial_text = self.initial_text.clone();
|
||||
let placeholder = self.placeholder;
|
||||
let mut confirm = self.confirm.clone();
|
||||
|
||||
move |window, cx| {
|
||||
let mut editor = Editor::single_line(window, cx);
|
||||
let editor_focus_handle = editor.focus_handle(cx);
|
||||
if let Some(text) = initial_text {
|
||||
editor.set_text(text, window, cx);
|
||||
}
|
||||
|
||||
if let Some(confirm) = confirm.take()
|
||||
&& !self.display_confirm_button
|
||||
&& !self.display_clear_button
|
||||
&& !self.clear_on_confirm
|
||||
{
|
||||
cx.on_focus_out(
|
||||
&editor_focus_handle,
|
||||
window,
|
||||
move |editor, _, window, cx| {
|
||||
let text = Some(editor.text(cx));
|
||||
confirm(text, window, cx);
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
}
|
||||
|
||||
if let Some(placeholder) = placeholder {
|
||||
editor.set_placeholder_text(placeholder, window, cx);
|
||||
}
|
||||
editor.set_text_style_refinement(styles);
|
||||
editor
|
||||
}
|
||||
})
|
||||
} else {
|
||||
window.use_state(cx, {
|
||||
let initial_text = self.initial_text.clone();
|
||||
let placeholder = self.placeholder;
|
||||
let mut confirm = self.confirm.clone();
|
||||
|
||||
move |window, cx| {
|
||||
let mut editor = Editor::single_line(window, cx);
|
||||
let editor_focus_handle = editor.focus_handle(cx);
|
||||
if let Some(text) = initial_text {
|
||||
editor.set_text(text, window, cx);
|
||||
}
|
||||
|
||||
if let Some(confirm) = confirm.take()
|
||||
&& !self.display_confirm_button
|
||||
&& !self.display_clear_button
|
||||
&& !self.clear_on_confirm
|
||||
{
|
||||
cx.on_focus_out(
|
||||
&editor_focus_handle,
|
||||
window,
|
||||
move |editor, _, window, cx| {
|
||||
let text = Some(editor.text(cx));
|
||||
confirm(text, window, cx);
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
}
|
||||
|
||||
if let Some(placeholder) = placeholder {
|
||||
editor.set_placeholder_text(placeholder, window, cx);
|
||||
}
|
||||
editor.set_text_style_refinement(styles);
|
||||
editor
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// When settings change externally (e.g. editing settings.json), the page
|
||||
// re-renders but use_keyed_state returns the cached editor with stale text.
|
||||
// Reconcile with the expected initial_text when the editor is not focused,
|
||||
// so we don't clobber what the user is actively typing.
|
||||
if let Some(initial_text) = &self.initial_text
|
||||
&& let Some(first_initial) = first_render_initial_text.read(cx)
|
||||
{
|
||||
if initial_text != first_initial && !editor.read(cx).is_focused(window) {
|
||||
*first_render_initial_text.as_mut(cx) = self.initial_text.clone();
|
||||
let weak_editor = editor.downgrade();
|
||||
let initial_text = initial_text.clone();
|
||||
|
||||
window.defer(cx, move |window, cx| {
|
||||
weak_editor
|
||||
.update(cx, |editor, cx| {
|
||||
editor.set_text(initial_text, window, cx);
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let weak_editor = editor.downgrade();
|
||||
let weak_editor_for_button = editor.downgrade();
|
||||
let weak_editor_for_clear = editor.downgrade();
|
||||
|
||||
let clear_on_confirm = self.clear_on_confirm;
|
||||
let clear_on_confirm_for_button = self.clear_on_confirm;
|
||||
|
||||
let theme_colors = cx.theme().colors();
|
||||
|
||||
let display_confirm_button = self.display_confirm_button;
|
||||
let display_clear_button = self.display_clear_button;
|
||||
let confirm_for_button = self.confirm.clone();
|
||||
let is_editor_empty = editor.read(cx).text(cx).trim().is_empty();
|
||||
let is_editor_focused = editor.read(cx).is_focused(window);
|
||||
|
||||
h_flex()
|
||||
.group("settings-input-field-editor")
|
||||
.relative()
|
||||
.py_1()
|
||||
.px_2()
|
||||
.h_8()
|
||||
.min_w_64()
|
||||
.rounded_md()
|
||||
.border_1()
|
||||
.border_color(theme_colors.border)
|
||||
.bg(theme_colors.editor_background)
|
||||
.when_some(self.tab_index, |this, tab_index| {
|
||||
let focus_handle = editor.focus_handle(cx).tab_index(tab_index).tab_stop(true);
|
||||
this.track_focus(&focus_handle)
|
||||
.focus(|s| s.border_color(theme_colors.border_focused))
|
||||
})
|
||||
.child(editor)
|
||||
.child(
|
||||
h_flex()
|
||||
.absolute()
|
||||
.top_1()
|
||||
.right_1()
|
||||
.invisible()
|
||||
.when(is_editor_focused, |this| this.visible())
|
||||
.group_hover("settings-input-field-editor", |this| this.visible())
|
||||
.when(
|
||||
display_clear_button && !is_editor_empty && is_editor_focused,
|
||||
|this| {
|
||||
this.child(
|
||||
IconButton::new("clear-button", IconName::Close)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted)
|
||||
.tooltip(Tooltip::text("Clear"))
|
||||
.on_click(move |_, window, cx| {
|
||||
let Some(editor) = weak_editor_for_clear.upgrade() else {
|
||||
return;
|
||||
};
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.set_text("", window, cx);
|
||||
});
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
.when(
|
||||
display_confirm_button && !is_editor_empty && is_editor_focused,
|
||||
|this| {
|
||||
this.child(
|
||||
IconButton::new("confirm-button", IconName::Check)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Success)
|
||||
.tooltip(Tooltip::text("Enter to Confirm"))
|
||||
.on_click(move |_, window, cx| {
|
||||
let Some(confirm) = confirm_for_button.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(editor) = weak_editor_for_button.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let new_value =
|
||||
editor.read_with(cx, |editor, cx| editor.text(cx));
|
||||
let new_value =
|
||||
(!new_value.is_empty()).then_some(new_value);
|
||||
confirm(new_value, window, cx);
|
||||
if clear_on_confirm_for_button {
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.set_text("", window, cx);
|
||||
});
|
||||
}
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
.when_some(self.action_slot, |this, action| this.child(action)),
|
||||
)
|
||||
.when_some(self.confirm, |this, confirm| {
|
||||
this.on_action::<menu::Confirm>({
|
||||
move |_, window, cx| {
|
||||
let Some(editor) = weak_editor.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let new_value = editor.read_with(cx, |editor, cx| editor.text(cx));
|
||||
let new_value = (!new_value.is_empty()).then_some(new_value);
|
||||
confirm(new_value, window, cx);
|
||||
if clear_on_confirm {
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.set_text("", window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
769
crates/settings_ui/src/components/number_field.rs
Normal file
769
crates/settings_ui/src/components/number_field.rs
Normal file
@@ -0,0 +1,769 @@
|
||||
use std::{
|
||||
fmt::Display,
|
||||
num::{NonZero, NonZeroU32, NonZeroU64},
|
||||
rc::Rc,
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
use editor::Editor;
|
||||
use gpui::{
|
||||
ClickEvent, Entity, FocusHandle, Focusable, FontWeight, Modifiers, TextAlign,
|
||||
TextStyleRefinement, WeakEntity,
|
||||
};
|
||||
|
||||
use settings::{
|
||||
CenteredPaddingSettings, CodeFade, DelayMs, FontSize, FontWeightContent, InactiveOpacity,
|
||||
MinimumContrast,
|
||||
};
|
||||
use ui::prelude::*;
|
||||
use zed_actions::editor::{MoveDown, MoveUp};
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum NumberFieldMode {
|
||||
#[default]
|
||||
Read,
|
||||
Edit,
|
||||
}
|
||||
|
||||
pub trait NumberFieldType: Display + Copy + Clone + Sized + PartialOrd + FromStr + 'static {
|
||||
fn default_format(value: &Self) -> String {
|
||||
format!("{}", value)
|
||||
}
|
||||
fn default_step() -> Self;
|
||||
fn large_step() -> Self;
|
||||
fn small_step() -> Self;
|
||||
fn min_value() -> Self;
|
||||
fn max_value() -> Self;
|
||||
fn saturating_add(self, rhs: Self) -> Self;
|
||||
fn saturating_sub(self, rhs: Self) -> Self;
|
||||
}
|
||||
|
||||
macro_rules! impl_newtype_numeric_stepper_float {
|
||||
($type:ident, $default:expr, $large:expr, $small:expr, $min:expr, $max:expr) => {
|
||||
impl NumberFieldType for $type {
|
||||
fn default_step() -> Self {
|
||||
$default.into()
|
||||
}
|
||||
|
||||
fn large_step() -> Self {
|
||||
$large.into()
|
||||
}
|
||||
|
||||
fn small_step() -> Self {
|
||||
$small.into()
|
||||
}
|
||||
|
||||
fn min_value() -> Self {
|
||||
$min.into()
|
||||
}
|
||||
|
||||
fn max_value() -> Self {
|
||||
$max.into()
|
||||
}
|
||||
|
||||
fn saturating_add(self, rhs: Self) -> Self {
|
||||
$type((self.0 + rhs.0).min(Self::max_value().0))
|
||||
}
|
||||
|
||||
fn saturating_sub(self, rhs: Self) -> Self {
|
||||
$type((self.0 - rhs.0).max(Self::min_value().0))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! impl_newtype_numeric_stepper_int {
|
||||
($type:ident, $default:expr, $large:expr, $small:expr, $min:expr, $max:expr) => {
|
||||
impl NumberFieldType for $type {
|
||||
fn default_step() -> Self {
|
||||
$default.into()
|
||||
}
|
||||
|
||||
fn large_step() -> Self {
|
||||
$large.into()
|
||||
}
|
||||
|
||||
fn small_step() -> Self {
|
||||
$small.into()
|
||||
}
|
||||
|
||||
fn min_value() -> Self {
|
||||
$min.into()
|
||||
}
|
||||
|
||||
fn max_value() -> Self {
|
||||
$max.into()
|
||||
}
|
||||
|
||||
fn saturating_add(self, rhs: Self) -> Self {
|
||||
$type(self.0.saturating_add(rhs.0).min(Self::max_value().0))
|
||||
}
|
||||
|
||||
fn saturating_sub(self, rhs: Self) -> Self {
|
||||
$type(self.0.saturating_sub(rhs.0).max(Self::min_value().0))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[rustfmt::skip]
|
||||
impl_newtype_numeric_stepper_float!(FontWeight, 50., 100., 10., FontWeight::THIN, FontWeight::BLACK);
|
||||
impl_newtype_numeric_stepper_float!(
|
||||
FontWeightContent,
|
||||
50.,
|
||||
100.,
|
||||
10.,
|
||||
FontWeightContent::THIN,
|
||||
FontWeightContent::BLACK
|
||||
);
|
||||
impl_newtype_numeric_stepper_float!(CodeFade, 0.1, 0.2, 0.05, 0.0, 0.9);
|
||||
impl_newtype_numeric_stepper_float!(FontSize, 1.0, 4.0, 0.5, 6.0, 72.0);
|
||||
impl_newtype_numeric_stepper_float!(InactiveOpacity, 0.1, 0.2, 0.05, 0.0, 1.0);
|
||||
impl_newtype_numeric_stepper_float!(MinimumContrast, 1., 10., 0.5, 0.0, 106.0);
|
||||
impl_newtype_numeric_stepper_int!(DelayMs, 100, 500, 10, 0, 2000);
|
||||
impl_newtype_numeric_stepper_float!(
|
||||
CenteredPaddingSettings,
|
||||
0.05,
|
||||
0.2,
|
||||
0.1,
|
||||
CenteredPaddingSettings::MIN_PADDING,
|
||||
CenteredPaddingSettings::MAX_PADDING
|
||||
);
|
||||
|
||||
macro_rules! impl_numeric_stepper_int {
|
||||
($type:ident) => {
|
||||
impl NumberFieldType for $type {
|
||||
fn default_step() -> Self {
|
||||
1
|
||||
}
|
||||
|
||||
fn large_step() -> Self {
|
||||
10
|
||||
}
|
||||
|
||||
fn small_step() -> Self {
|
||||
1
|
||||
}
|
||||
|
||||
fn min_value() -> Self {
|
||||
<$type>::MIN
|
||||
}
|
||||
|
||||
fn max_value() -> Self {
|
||||
<$type>::MAX
|
||||
}
|
||||
|
||||
fn saturating_add(self, rhs: Self) -> Self {
|
||||
self.saturating_add(rhs)
|
||||
}
|
||||
|
||||
fn saturating_sub(self, rhs: Self) -> Self {
|
||||
self.saturating_sub(rhs)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! impl_numeric_stepper_nonzero_int {
|
||||
($nonzero:ty, $inner:ty) => {
|
||||
impl NumberFieldType for $nonzero {
|
||||
fn default_step() -> Self {
|
||||
<$nonzero>::new(1).unwrap()
|
||||
}
|
||||
|
||||
fn large_step() -> Self {
|
||||
<$nonzero>::new(10).unwrap()
|
||||
}
|
||||
|
||||
fn small_step() -> Self {
|
||||
<$nonzero>::new(1).unwrap()
|
||||
}
|
||||
|
||||
fn min_value() -> Self {
|
||||
<$nonzero>::MIN
|
||||
}
|
||||
|
||||
fn max_value() -> Self {
|
||||
<$nonzero>::MAX
|
||||
}
|
||||
|
||||
fn saturating_add(self, rhs: Self) -> Self {
|
||||
let result = self.get().saturating_add(rhs.get());
|
||||
<$nonzero>::new(result.max(1)).unwrap()
|
||||
}
|
||||
|
||||
fn saturating_sub(self, rhs: Self) -> Self {
|
||||
let result = self.get().saturating_sub(rhs.get()).max(1);
|
||||
<$nonzero>::new(result).unwrap()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! impl_numeric_stepper_float {
|
||||
($type:ident) => {
|
||||
impl NumberFieldType for $type {
|
||||
fn default_format(value: &Self) -> String {
|
||||
format!("{:.2}", value)
|
||||
}
|
||||
|
||||
fn default_step() -> Self {
|
||||
1.0
|
||||
}
|
||||
|
||||
fn large_step() -> Self {
|
||||
10.0
|
||||
}
|
||||
|
||||
fn small_step() -> Self {
|
||||
0.1
|
||||
}
|
||||
|
||||
fn min_value() -> Self {
|
||||
<$type>::MIN
|
||||
}
|
||||
|
||||
fn max_value() -> Self {
|
||||
<$type>::MAX
|
||||
}
|
||||
|
||||
fn saturating_add(self, rhs: Self) -> Self {
|
||||
(self + rhs).clamp(Self::min_value(), Self::max_value())
|
||||
}
|
||||
|
||||
fn saturating_sub(self, rhs: Self) -> Self {
|
||||
(self - rhs).clamp(Self::min_value(), Self::max_value())
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl_numeric_stepper_float!(f32);
|
||||
impl_numeric_stepper_float!(f64);
|
||||
impl_numeric_stepper_int!(isize);
|
||||
impl_numeric_stepper_int!(usize);
|
||||
impl_numeric_stepper_int!(i32);
|
||||
impl_numeric_stepper_int!(u32);
|
||||
impl_numeric_stepper_int!(i64);
|
||||
impl_numeric_stepper_int!(u64);
|
||||
|
||||
impl_numeric_stepper_nonzero_int!(NonZeroU32, u32);
|
||||
impl_numeric_stepper_nonzero_int!(NonZeroU64, u64);
|
||||
impl_numeric_stepper_nonzero_int!(NonZero<usize>, usize);
|
||||
|
||||
type OnChangeCallback<T> = Rc<dyn Fn(&T, &mut Window, &mut App) + 'static>;
|
||||
|
||||
#[derive(IntoElement, RegisterComponent)]
|
||||
pub struct NumberField<T: NumberFieldType = usize> {
|
||||
id: ElementId,
|
||||
value: T,
|
||||
focus_handle: FocusHandle,
|
||||
mode: Entity<NumberFieldMode>,
|
||||
/// Stores a weak reference to the editor when in edit mode, so buttons can update its text
|
||||
edit_editor: Entity<Option<WeakEntity<Editor>>>,
|
||||
/// Stores the on_change callback in Entity state so it's not stale in focus_out handlers
|
||||
on_change_state: Entity<Option<OnChangeCallback<T>>>,
|
||||
/// Tracks the last prop value we synced to, so we can detect external changes (like reset)
|
||||
last_synced_value: Entity<Option<T>>,
|
||||
format: Box<dyn FnOnce(&T) -> String>,
|
||||
large_step: T,
|
||||
small_step: T,
|
||||
step: T,
|
||||
min_value: T,
|
||||
max_value: T,
|
||||
on_reset: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
|
||||
on_change: Rc<dyn Fn(&T, &mut Window, &mut App) + 'static>,
|
||||
tab_index: Option<isize>,
|
||||
}
|
||||
|
||||
impl<T: NumberFieldType> NumberField<T> {
|
||||
pub fn new(id: impl Into<ElementId>, value: T, window: &mut Window, cx: &mut App) -> Self {
|
||||
let id = id.into();
|
||||
|
||||
let (mode, focus_handle, edit_editor, on_change_state, last_synced_value) =
|
||||
window.with_id(id.clone(), |window| {
|
||||
let mode = window.use_state(cx, |_, _| NumberFieldMode::default());
|
||||
let focus_handle = window.use_state(cx, |_, cx| cx.focus_handle());
|
||||
let edit_editor = window.use_state(cx, |_, _| None);
|
||||
let on_change_state: Entity<Option<OnChangeCallback<T>>> =
|
||||
window.use_state(cx, |_, _| None);
|
||||
let last_synced_value: Entity<Option<T>> = window.use_state(cx, |_, _| None);
|
||||
(
|
||||
mode,
|
||||
focus_handle,
|
||||
edit_editor,
|
||||
on_change_state,
|
||||
last_synced_value,
|
||||
)
|
||||
});
|
||||
|
||||
Self {
|
||||
id,
|
||||
mode,
|
||||
edit_editor,
|
||||
on_change_state,
|
||||
last_synced_value,
|
||||
value,
|
||||
focus_handle: focus_handle.read(cx).clone(),
|
||||
format: Box::new(T::default_format),
|
||||
large_step: T::large_step(),
|
||||
step: T::default_step(),
|
||||
small_step: T::small_step(),
|
||||
min_value: T::min_value(),
|
||||
max_value: T::max_value(),
|
||||
on_reset: None,
|
||||
on_change: Rc::new(|_, _, _| {}),
|
||||
tab_index: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn min(mut self, min: T) -> Self {
|
||||
self.min_value = min;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn max(mut self, max: T) -> Self {
|
||||
self.max_value = max;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn mode(self, mode: NumberFieldMode, cx: &mut App) -> Self {
|
||||
self.mode.write(cx, mode);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn tab_index(mut self, tab_index: isize) -> Self {
|
||||
self.tab_index = Some(tab_index);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_change(mut self, on_change: impl Fn(&T, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_change = Rc::new(on_change);
|
||||
self
|
||||
}
|
||||
|
||||
fn sync_on_change_state(&self, cx: &mut App) {
|
||||
self.on_change_state
|
||||
.update(cx, |state, _| *state = Some(self.on_change.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ValueChangeDirection {
|
||||
Increment,
|
||||
Decrement,
|
||||
}
|
||||
|
||||
impl<T: NumberFieldType> RenderOnce for NumberField<T> {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
// Sync the on_change callback to Entity state so focus_out handlers can access it
|
||||
self.sync_on_change_state(cx);
|
||||
|
||||
let is_edit_mode = matches!(*self.mode.read(cx), NumberFieldMode::Edit);
|
||||
|
||||
let get_step = {
|
||||
let large_step = self.large_step;
|
||||
let step = self.step;
|
||||
let small_step = self.small_step;
|
||||
move |modifiers: Modifiers| -> T {
|
||||
if modifiers.shift {
|
||||
large_step
|
||||
} else if modifiers.alt {
|
||||
small_step
|
||||
} else {
|
||||
step
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let clamp_value = {
|
||||
let min = self.min_value;
|
||||
let max = self.max_value;
|
||||
move |value: T| -> T {
|
||||
if value < min {
|
||||
min
|
||||
} else if value > max {
|
||||
max
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let change_value = {
|
||||
move |current: T, step: T, direction: ValueChangeDirection| -> T {
|
||||
let new_value = match direction {
|
||||
ValueChangeDirection::Increment => current.saturating_add(step),
|
||||
ValueChangeDirection::Decrement => current.saturating_sub(step),
|
||||
};
|
||||
clamp_value(new_value)
|
||||
}
|
||||
};
|
||||
|
||||
let get_current_value = {
|
||||
let value = self.value;
|
||||
let edit_editor = self.edit_editor.clone();
|
||||
|
||||
Rc::new(move |cx: &App| -> T {
|
||||
if !is_edit_mode {
|
||||
return value;
|
||||
}
|
||||
edit_editor
|
||||
.read(cx)
|
||||
.as_ref()
|
||||
.and_then(|weak| weak.upgrade())
|
||||
.and_then(|editor| editor.read(cx).text(cx).parse::<T>().ok())
|
||||
.unwrap_or(value)
|
||||
})
|
||||
};
|
||||
|
||||
let update_editor_text = {
|
||||
let edit_editor = self.edit_editor.clone();
|
||||
|
||||
Rc::new(move |new_value: T, window: &mut Window, cx: &mut App| {
|
||||
if !is_edit_mode {
|
||||
return;
|
||||
}
|
||||
let Some(editor) = edit_editor
|
||||
.read(cx)
|
||||
.as_ref()
|
||||
.and_then(|weak| weak.upgrade())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.set_text(format!("{}", new_value), window, cx);
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
let bg_color = cx.theme().colors().surface_background;
|
||||
let hover_bg_color = cx.theme().colors().element_hover;
|
||||
|
||||
let border_color = cx.theme().colors().border_variant;
|
||||
let focus_border_color = cx.theme().colors().border_focused;
|
||||
|
||||
let base_button = |icon: IconName| {
|
||||
h_flex()
|
||||
.cursor_pointer()
|
||||
.p_1p5()
|
||||
.size_full()
|
||||
.justify_center()
|
||||
.overflow_hidden()
|
||||
.border_1()
|
||||
.border_color(border_color)
|
||||
.bg(bg_color)
|
||||
.hover(|s| s.bg(hover_bg_color))
|
||||
.focus_visible(|s| s.border_color(focus_border_color).bg(hover_bg_color))
|
||||
.child(Icon::new(icon).size(IconSize::Small))
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.id(self.id.clone())
|
||||
.track_focus(&self.focus_handle)
|
||||
.gap_1()
|
||||
.when_some(self.on_reset, |this, on_reset| {
|
||||
this.child(
|
||||
IconButton::new("reset", IconName::RotateCcw)
|
||||
.icon_size(IconSize::Small)
|
||||
.when_some(self.tab_index, |this, _| this.tab_index(0isize))
|
||||
.on_click(on_reset),
|
||||
)
|
||||
})
|
||||
.child({
|
||||
let on_change_for_increment = self.on_change.clone();
|
||||
|
||||
h_flex()
|
||||
.map(|decrement| {
|
||||
let decrement_handler = {
|
||||
let on_change = self.on_change.clone();
|
||||
let get_current_value = get_current_value.clone();
|
||||
let update_editor_text = update_editor_text.clone();
|
||||
|
||||
move |click: &ClickEvent, window: &mut Window, cx: &mut App| {
|
||||
let current_value = get_current_value(cx);
|
||||
let step = get_step(click.modifiers());
|
||||
let new_value = change_value(
|
||||
current_value,
|
||||
step,
|
||||
ValueChangeDirection::Decrement,
|
||||
);
|
||||
|
||||
update_editor_text(new_value, window, cx);
|
||||
on_change(&new_value, window, cx);
|
||||
}
|
||||
};
|
||||
|
||||
decrement.child(
|
||||
base_button(IconName::Dash)
|
||||
.id((self.id.clone(), "decrement_button"))
|
||||
.rounded_tl_sm()
|
||||
.rounded_bl_sm()
|
||||
.when_some(self.tab_index, |this, _| this.tab_index(0isize))
|
||||
.on_click(decrement_handler),
|
||||
)
|
||||
})
|
||||
.child({
|
||||
h_flex()
|
||||
.min_w_16()
|
||||
.size_full()
|
||||
.border_y_1()
|
||||
.border_color(border_color)
|
||||
.bg(bg_color)
|
||||
.in_focus(|this| this.border_color(focus_border_color))
|
||||
.child(match *self.mode.read(cx) {
|
||||
NumberFieldMode::Read => h_flex()
|
||||
.px_1()
|
||||
.flex_1()
|
||||
.justify_center()
|
||||
.child(
|
||||
Label::new((self.format)(&self.value)).color(Color::Muted),
|
||||
)
|
||||
.into_any_element(),
|
||||
NumberFieldMode::Edit => {
|
||||
let expected_text = format!("{}", self.value);
|
||||
|
||||
let editor = window.use_state(cx, {
|
||||
let expected_text = expected_text.clone();
|
||||
|
||||
move |window, cx| {
|
||||
let mut editor = Editor::single_line(window, cx);
|
||||
|
||||
editor.set_text_style_refinement(TextStyleRefinement {
|
||||
color: Some(cx.theme().colors().text),
|
||||
text_align: Some(TextAlign::Center),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
editor.set_text(expected_text, window, cx);
|
||||
|
||||
let editor_weak = cx.entity().downgrade();
|
||||
|
||||
self.edit_editor.update(cx, |state, _| {
|
||||
*state = Some(editor_weak);
|
||||
});
|
||||
|
||||
editor
|
||||
.register_action::<MoveUp>({
|
||||
let on_change = self.on_change.clone();
|
||||
let editor_handle = cx.entity().downgrade();
|
||||
move |_, window, cx| {
|
||||
let Some(editor) = editor_handle.upgrade()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
editor.update(cx, |editor, cx| {
|
||||
if let Ok(current_value) =
|
||||
editor.text(cx).parse::<T>()
|
||||
{
|
||||
let step =
|
||||
get_step(window.modifiers());
|
||||
let new_value = change_value(
|
||||
current_value,
|
||||
step,
|
||||
ValueChangeDirection::Increment,
|
||||
);
|
||||
editor.set_text(
|
||||
format!("{}", new_value),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
on_change(&new_value, window, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
editor
|
||||
.register_action::<MoveDown>({
|
||||
let on_change = self.on_change.clone();
|
||||
let editor_handle = cx.entity().downgrade();
|
||||
move |_, window, cx| {
|
||||
let Some(editor) = editor_handle.upgrade()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
editor.update(cx, |editor, cx| {
|
||||
if let Ok(current_value) =
|
||||
editor.text(cx).parse::<T>()
|
||||
{
|
||||
let step =
|
||||
get_step(window.modifiers());
|
||||
let new_value = change_value(
|
||||
current_value,
|
||||
step,
|
||||
ValueChangeDirection::Decrement,
|
||||
);
|
||||
editor.set_text(
|
||||
format!("{}", new_value),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
on_change(&new_value, window, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.on_focus_out(&editor.focus_handle(cx), window, {
|
||||
let on_change_state = self.on_change_state.clone();
|
||||
move |this, _, window, cx| {
|
||||
if let Ok(parsed_value) =
|
||||
this.text(cx).parse::<T>()
|
||||
{
|
||||
let new_value = clamp_value(parsed_value);
|
||||
let on_change =
|
||||
on_change_state.read(cx).clone();
|
||||
|
||||
if let Some(on_change) = on_change.as_ref()
|
||||
{
|
||||
on_change(&new_value, window, cx);
|
||||
}
|
||||
};
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
editor
|
||||
}
|
||||
});
|
||||
|
||||
let focus_handle = editor.focus_handle(cx);
|
||||
let is_focused = focus_handle.is_focused(window);
|
||||
|
||||
if !is_focused {
|
||||
let current_text = editor.read(cx).text(cx);
|
||||
let last_synced = *self.last_synced_value.read(cx);
|
||||
|
||||
// Detect if the value changed externally (e.g., reset button)
|
||||
let value_changed_externally = last_synced
|
||||
.map(|last| last != self.value)
|
||||
.unwrap_or(true);
|
||||
|
||||
let should_sync = if value_changed_externally {
|
||||
true
|
||||
} else {
|
||||
match current_text.parse::<T>().ok() {
|
||||
Some(parsed) => parsed == self.value,
|
||||
None => true,
|
||||
}
|
||||
};
|
||||
|
||||
if should_sync && current_text != expected_text {
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.set_text(expected_text.clone(), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
self.last_synced_value
|
||||
.update(cx, |state, _| *state = Some(self.value));
|
||||
}
|
||||
|
||||
let focus_handle = if self.tab_index.is_some() {
|
||||
focus_handle.tab_index(0isize).tab_stop(true)
|
||||
} else {
|
||||
focus_handle
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.h_full()
|
||||
.track_focus(&focus_handle)
|
||||
.when(is_focused, |this| {
|
||||
this.border_1()
|
||||
.border_color(cx.theme().colors().border_focused)
|
||||
})
|
||||
.child(editor)
|
||||
.on_action::<menu::Confirm>({
|
||||
move |_, window, _| {
|
||||
window.blur();
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
})
|
||||
})
|
||||
.map(|increment| {
|
||||
let increment_handler = {
|
||||
let on_change = on_change_for_increment.clone();
|
||||
let get_current_value = get_current_value.clone();
|
||||
let update_editor_text = update_editor_text.clone();
|
||||
|
||||
move |click: &ClickEvent, window: &mut Window, cx: &mut App| {
|
||||
let current_value = get_current_value(cx);
|
||||
let step = get_step(click.modifiers());
|
||||
let new_value = change_value(
|
||||
current_value,
|
||||
step,
|
||||
ValueChangeDirection::Increment,
|
||||
);
|
||||
|
||||
update_editor_text(new_value, window, cx);
|
||||
on_change(&new_value, window, cx);
|
||||
}
|
||||
};
|
||||
|
||||
increment.child(
|
||||
base_button(IconName::Plus)
|
||||
.id((self.id.clone(), "increment_button"))
|
||||
.rounded_tr_sm()
|
||||
.rounded_br_sm()
|
||||
.when_some(self.tab_index, |this, _| this.tab_index(0isize))
|
||||
.on_click(increment_handler),
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for NumberField<usize> {
|
||||
fn scope() -> ComponentScope {
|
||||
ComponentScope::Input
|
||||
}
|
||||
|
||||
fn name() -> &'static str {
|
||||
"Number Field"
|
||||
}
|
||||
|
||||
fn description() -> Option<&'static str> {
|
||||
Some("A numeric input element with increment and decrement buttons.")
|
||||
}
|
||||
|
||||
fn preview(window: &mut Window, cx: &mut App) -> Option<AnyElement> {
|
||||
let default_ex = window.use_state(cx, |_, _| 100.0);
|
||||
let edit_ex = window.use_state(cx, |_, _| 500.0);
|
||||
|
||||
Some(
|
||||
v_flex()
|
||||
.gap_6()
|
||||
.children(vec![
|
||||
single_example(
|
||||
"Button-Only Number Field",
|
||||
NumberField::new("number-field", *default_ex.read(cx), window, cx)
|
||||
.on_change({
|
||||
let default_ex = default_ex.clone();
|
||||
move |value, _, cx| default_ex.write(cx, *value)
|
||||
})
|
||||
.min(1.0)
|
||||
.max(100.0)
|
||||
.into_any_element(),
|
||||
),
|
||||
single_example(
|
||||
"Editable Number Field",
|
||||
NumberField::new("editable-number-field", *edit_ex.read(cx), window, cx)
|
||||
.on_change({
|
||||
let edit_ex = edit_ex.clone();
|
||||
move |value, _, cx| edit_ex.write(cx, *value)
|
||||
})
|
||||
.min(100.0)
|
||||
.max(500.0)
|
||||
.mode(NumberFieldMode::Edit, cx)
|
||||
.into_any_element(),
|
||||
),
|
||||
])
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
214
crates/settings_ui/src/components/ollama_model_picker.rs
Normal file
214
crates/settings_ui/src/components/ollama_model_picker.rs
Normal file
@@ -0,0 +1,214 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use fuzzy::StringMatch;
|
||||
use gpui::{AnyElement, App, Context, DismissEvent, ReadGlobal, SharedString, Task, Window, px};
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use settings::SettingsStore;
|
||||
use ui::{ListItem, ListItemSpacing, PopoverMenu, prelude::*};
|
||||
use util::ResultExt;
|
||||
|
||||
use crate::{
|
||||
SettingField, SettingsFieldMetadata, SettingsUiFile, render_picker_trigger_button,
|
||||
update_settings_file,
|
||||
};
|
||||
|
||||
type OllamaModelPicker = Picker<OllamaModelPickerDelegate>;
|
||||
|
||||
struct OllamaModelPickerDelegate {
|
||||
models: Vec<SharedString>,
|
||||
filtered_models: Vec<StringMatch>,
|
||||
selected_index: usize,
|
||||
on_model_changed: Arc<dyn Fn(SharedString, &mut Window, &mut App) + 'static>,
|
||||
}
|
||||
|
||||
impl OllamaModelPickerDelegate {
|
||||
fn new(
|
||||
current_model: SharedString,
|
||||
on_model_changed: impl Fn(SharedString, &mut Window, &mut App) + 'static,
|
||||
cx: &mut Context<OllamaModelPicker>,
|
||||
) -> Self {
|
||||
let mut models = edit_prediction::ollama::fetch_models(cx);
|
||||
|
||||
let current_in_list = models.contains(¤t_model);
|
||||
if !current_model.is_empty() && !current_in_list {
|
||||
models.insert(0, current_model.clone());
|
||||
}
|
||||
|
||||
let selected_index = models
|
||||
.iter()
|
||||
.position(|model| *model == current_model)
|
||||
.unwrap_or(0);
|
||||
|
||||
let filtered_models = models
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, model)| StringMatch {
|
||||
candidate_id: index,
|
||||
string: model.to_string(),
|
||||
positions: Vec::new(),
|
||||
score: 0.0,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
models,
|
||||
filtered_models,
|
||||
selected_index,
|
||||
on_model_changed: Arc::new(on_model_changed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PickerDelegate for OllamaModelPickerDelegate {
|
||||
type ListItem = AnyElement;
|
||||
|
||||
fn match_count(&self) -> usize {
|
||||
self.filtered_models.len()
|
||||
}
|
||||
|
||||
fn selected_index(&self) -> usize {
|
||||
self.selected_index
|
||||
}
|
||||
|
||||
fn set_selected_index(
|
||||
&mut self,
|
||||
ix: usize,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<OllamaModelPicker>,
|
||||
) {
|
||||
self.selected_index = ix.min(self.filtered_models.len().saturating_sub(1));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
|
||||
"Search models…".into()
|
||||
}
|
||||
|
||||
fn update_matches(
|
||||
&mut self,
|
||||
query: String,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<OllamaModelPicker>,
|
||||
) -> Task<()> {
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
self.filtered_models = self
|
||||
.models
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, model)| query.is_empty() || model.to_lowercase().contains(&query_lower))
|
||||
.map(|(index, model)| StringMatch {
|
||||
candidate_id: index,
|
||||
string: model.to_string(),
|
||||
positions: Vec::new(),
|
||||
score: 0.0,
|
||||
})
|
||||
.collect();
|
||||
|
||||
self.selected_index = 0;
|
||||
cx.notify();
|
||||
|
||||
Task::ready(())
|
||||
}
|
||||
|
||||
fn confirm(
|
||||
&mut self,
|
||||
_secondary: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<OllamaModelPicker>,
|
||||
) {
|
||||
let Some(model_match) = self.filtered_models.get(self.selected_index) else {
|
||||
return;
|
||||
};
|
||||
|
||||
(self.on_model_changed)(model_match.string.clone().into(), window, cx);
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
|
||||
fn dismissed(&mut self, window: &mut Window, cx: &mut Context<OllamaModelPicker>) {
|
||||
cx.defer_in(window, |picker, window, cx| {
|
||||
picker.set_query("", window, cx);
|
||||
});
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
|
||||
fn render_match(
|
||||
&self,
|
||||
ix: usize,
|
||||
selected: bool,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<OllamaModelPicker>,
|
||||
) -> Option<Self::ListItem> {
|
||||
let model_match = self.filtered_models.get(ix)?;
|
||||
|
||||
Some(
|
||||
ListItem::new(ix)
|
||||
.inset(true)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
.toggle_state(selected)
|
||||
.child(Label::new(model_match.string.clone()))
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_ollama_model_picker(
|
||||
field: SettingField<settings::OllamaModelName>,
|
||||
file: SettingsUiFile,
|
||||
_metadata: Option<&SettingsFieldMetadata>,
|
||||
_window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> AnyElement {
|
||||
let (_, value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
|
||||
let current_value: SharedString = value
|
||||
.map(|m| m.0.clone().into())
|
||||
.unwrap_or_else(|| "".into());
|
||||
|
||||
PopoverMenu::new("ollama-model-picker")
|
||||
.trigger(render_picker_trigger_button(
|
||||
"ollama_model_picker_trigger".into(),
|
||||
if current_value.is_empty() {
|
||||
"Select a model…".into()
|
||||
} else {
|
||||
current_value.clone()
|
||||
},
|
||||
))
|
||||
.menu(move |window, cx| {
|
||||
Some(cx.new(|cx| {
|
||||
let file = file.clone();
|
||||
let current_value = current_value.clone();
|
||||
let delegate = OllamaModelPickerDelegate::new(
|
||||
current_value,
|
||||
move |model_name, window, cx| {
|
||||
update_settings_file(
|
||||
file.clone(),
|
||||
field.json_path,
|
||||
window,
|
||||
cx,
|
||||
move |settings, app| {
|
||||
(field.write)(
|
||||
settings,
|
||||
Some(settings::OllamaModelName(model_name.to_string())),
|
||||
app,
|
||||
);
|
||||
},
|
||||
)
|
||||
.log_err();
|
||||
},
|
||||
cx,
|
||||
);
|
||||
|
||||
Picker::uniform_list(delegate, window, cx)
|
||||
.show_scrollbar(true)
|
||||
.width(rems_from_px(210.))
|
||||
.max_height(Some(rems(18.).into()))
|
||||
}))
|
||||
})
|
||||
.anchor(gpui::Anchor::TopLeft)
|
||||
.offset(gpui::Point {
|
||||
x: px(0.0),
|
||||
y: px(2.0),
|
||||
})
|
||||
.with_handle(ui::PopoverMenuHandle::default())
|
||||
.into_any_element()
|
||||
}
|
||||
56
crates/settings_ui/src/components/section_items.rs
Normal file
56
crates/settings_ui/src/components/section_items.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use gpui::{IntoElement, ParentElement, Styled};
|
||||
use ui::{Divider, DividerColor, prelude::*};
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct SettingsSectionHeader {
|
||||
icon: Option<IconName>,
|
||||
label: SharedString,
|
||||
no_padding: bool,
|
||||
}
|
||||
|
||||
impl SettingsSectionHeader {
|
||||
pub fn new(label: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
icon: None,
|
||||
no_padding: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(mut self, icon: IconName) -> Self {
|
||||
self.icon = Some(icon);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn no_padding(mut self, no_padding: bool) -> Self {
|
||||
self.no_padding = no_padding;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SettingsSectionHeader {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let label = Label::new(self.label)
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted)
|
||||
.buffer_font(cx);
|
||||
|
||||
v_flex()
|
||||
.w_full()
|
||||
.when(!self.no_padding, |this| this.px_8())
|
||||
.gap_1p5()
|
||||
.map(|this| {
|
||||
if let Some(icon) = self.icon {
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.child(Icon::new(icon).color(Color::Muted))
|
||||
.child(label),
|
||||
)
|
||||
} else {
|
||||
this.child(label)
|
||||
}
|
||||
})
|
||||
.child(Divider::horizontal().color(DividerColor::BorderFaded))
|
||||
}
|
||||
}
|
||||
179
crates/settings_ui/src/components/theme_picker.rs
Normal file
179
crates/settings_ui/src/components/theme_picker.rs
Normal file
@@ -0,0 +1,179 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use fuzzy::{StringMatch, StringMatchCandidate};
|
||||
use gpui::{AnyElement, App, Context, DismissEvent, SharedString, Task, Window};
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use theme::ThemeRegistry;
|
||||
use ui::{ListItem, ListItemSpacing, prelude::*};
|
||||
|
||||
type ThemePicker = Picker<ThemePickerDelegate>;
|
||||
|
||||
pub struct ThemePickerDelegate {
|
||||
themes: Vec<SharedString>,
|
||||
filtered_themes: Vec<StringMatch>,
|
||||
selected_index: usize,
|
||||
current_theme: SharedString,
|
||||
on_theme_changed: Arc<dyn Fn(SharedString, &mut Window, &mut App) + 'static>,
|
||||
}
|
||||
|
||||
impl ThemePickerDelegate {
|
||||
fn new(
|
||||
current_theme: SharedString,
|
||||
on_theme_changed: impl Fn(SharedString, &mut Window, &mut App) + 'static,
|
||||
cx: &mut Context<ThemePicker>,
|
||||
) -> Self {
|
||||
let theme_registry = ThemeRegistry::global(cx);
|
||||
|
||||
let themes = theme_registry.list_names();
|
||||
let selected_index = themes
|
||||
.iter()
|
||||
.position(|theme| *theme == current_theme)
|
||||
.unwrap_or(0);
|
||||
|
||||
let filtered_themes = themes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, theme)| StringMatch {
|
||||
candidate_id: index,
|
||||
string: theme.to_string(),
|
||||
positions: Vec::new(),
|
||||
score: 0.0,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
themes,
|
||||
filtered_themes,
|
||||
selected_index,
|
||||
current_theme,
|
||||
on_theme_changed: Arc::new(on_theme_changed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PickerDelegate for ThemePickerDelegate {
|
||||
type ListItem = AnyElement;
|
||||
|
||||
fn match_count(&self) -> usize {
|
||||
self.filtered_themes.len()
|
||||
}
|
||||
|
||||
fn selected_index(&self) -> usize {
|
||||
self.selected_index
|
||||
}
|
||||
|
||||
fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<ThemePicker>) {
|
||||
self.selected_index = ix.min(self.filtered_themes.len().saturating_sub(1));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
|
||||
"Search theme…".into()
|
||||
}
|
||||
|
||||
fn update_matches(
|
||||
&mut self,
|
||||
query: String,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<ThemePicker>,
|
||||
) -> Task<()> {
|
||||
let themes = self.themes.clone();
|
||||
let current_theme = self.current_theme.clone();
|
||||
|
||||
let matches: Vec<StringMatch> = if query.is_empty() {
|
||||
themes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, theme)| StringMatch {
|
||||
candidate_id: index,
|
||||
string: theme.to_string(),
|
||||
positions: Vec::new(),
|
||||
score: 0.0,
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
let _candidates: Vec<StringMatchCandidate> = themes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(id, theme)| StringMatchCandidate::new(id, theme.as_ref()))
|
||||
.collect();
|
||||
|
||||
themes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, theme)| theme.to_lowercase().contains(&query.to_lowercase()))
|
||||
.map(|(index, theme)| StringMatch {
|
||||
candidate_id: index,
|
||||
string: theme.to_string(),
|
||||
positions: Vec::new(),
|
||||
score: 0.0,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let selected_index = if query.is_empty() {
|
||||
themes
|
||||
.iter()
|
||||
.position(|theme| *theme == current_theme)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
matches
|
||||
.iter()
|
||||
.position(|m| themes[m.candidate_id] == current_theme)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
self.filtered_themes = matches;
|
||||
self.selected_index = selected_index;
|
||||
cx.notify();
|
||||
|
||||
Task::ready(())
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<ThemePicker>) {
|
||||
if let Some(theme_match) = self.filtered_themes.get(self.selected_index) {
|
||||
let theme = theme_match.string.clone();
|
||||
(self.on_theme_changed)(theme.into(), window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn dismissed(&mut self, window: &mut Window, cx: &mut Context<ThemePicker>) {
|
||||
cx.defer_in(window, |picker, window, cx| {
|
||||
picker.set_query("", window, cx);
|
||||
});
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
|
||||
fn render_match(
|
||||
&self,
|
||||
ix: usize,
|
||||
selected: bool,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<ThemePicker>,
|
||||
) -> Option<Self::ListItem> {
|
||||
let theme_match = self.filtered_themes.get(ix)?;
|
||||
|
||||
Some(
|
||||
ListItem::new(ix)
|
||||
.inset(true)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
.toggle_state(selected)
|
||||
.child(Label::new(theme_match.string.clone()))
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn theme_picker(
|
||||
current_theme: SharedString,
|
||||
on_theme_changed: impl Fn(SharedString, &mut Window, &mut App) + 'static,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<ThemePicker>,
|
||||
) -> ThemePicker {
|
||||
let delegate = ThemePickerDelegate::new(current_theme, on_theme_changed, cx);
|
||||
|
||||
Picker::uniform_list(delegate, window, cx)
|
||||
.show_scrollbar(true)
|
||||
.width(rems_from_px(210.))
|
||||
.max_height(Some(rems(18.).into()))
|
||||
}
|
||||
9700
crates/settings_ui/src/page_data.rs
Normal file
9700
crates/settings_ui/src/page_data.rs
Normal file
File diff suppressed because it is too large
Load Diff
20
crates/settings_ui/src/pages.rs
Normal file
20
crates/settings_ui/src/pages.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
mod audio_input_output_setup;
|
||||
mod audio_test_window;
|
||||
mod edit_prediction_provider_setup;
|
||||
mod feature_flags;
|
||||
mod tool_permissions_setup;
|
||||
|
||||
pub(crate) use audio_input_output_setup::{
|
||||
render_input_audio_device_dropdown, render_output_audio_device_dropdown,
|
||||
};
|
||||
pub(crate) use audio_test_window::open_audio_test_window;
|
||||
pub(crate) use edit_prediction_provider_setup::render_edit_prediction_setup_page;
|
||||
pub(crate) use feature_flags::render_feature_flags_page;
|
||||
pub(crate) use tool_permissions_setup::render_tool_permissions_setup_page;
|
||||
|
||||
pub use tool_permissions_setup::{
|
||||
render_copy_path_tool_config, render_create_directory_tool_config,
|
||||
render_delete_path_tool_config, render_edit_file_tool_config, render_fetch_tool_config,
|
||||
render_move_path_tool_config, render_terminal_tool_config, render_web_search_tool_config,
|
||||
render_write_file_tool_config,
|
||||
};
|
||||
153
crates/settings_ui/src/pages/audio_input_output_setup.rs
Normal file
153
crates/settings_ui/src/pages/audio_input_output_setup.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
use audio::{AudioDeviceInfo, AvailableAudioDevices};
|
||||
use cpal::DeviceId;
|
||||
use gpui::{AnyElement, App, ElementId, ReadGlobal, SharedString, Window};
|
||||
use settings::{AudioInputDeviceName, AudioOutputDeviceName, SettingsStore};
|
||||
use std::str::FromStr;
|
||||
use ui::{ContextMenu, DropdownMenu, DropdownStyle, IconPosition, IntoElement};
|
||||
use util::ResultExt;
|
||||
|
||||
use crate::{SettingField, SettingsFieldMetadata, SettingsUiFile, update_settings_file};
|
||||
|
||||
pub(crate) const SYSTEM_DEFAULT: &str = "System Default";
|
||||
|
||||
pub(crate) fn get_current_device(
|
||||
current_id: Option<&DeviceId>,
|
||||
is_input: bool,
|
||||
devices: &[AudioDeviceInfo],
|
||||
) -> Option<AudioDeviceInfo> {
|
||||
let Some(current_id) = current_id else {
|
||||
return None;
|
||||
};
|
||||
devices
|
||||
.iter()
|
||||
.find(|d| d.matches(current_id, is_input))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub(crate) fn render_audio_device_dropdown<F>(
|
||||
dropdown_id: impl Into<ElementId>,
|
||||
current_device_id: Option<DeviceId>,
|
||||
is_input: bool,
|
||||
on_select: F,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> AnyElement
|
||||
where
|
||||
F: Fn(Option<DeviceId>, &mut Window, &mut App) + Clone + 'static,
|
||||
{
|
||||
audio::ensure_devices_initialized(cx);
|
||||
let devices = cx.global::<AvailableAudioDevices>().0.clone();
|
||||
let current_device = get_current_device(current_device_id.as_ref(), is_input, &devices);
|
||||
|
||||
let menu = ContextMenu::build(window, cx, {
|
||||
let current_device = current_device.clone();
|
||||
move |mut menu, _, _cx| {
|
||||
let is_system_default = current_device.is_none();
|
||||
menu = menu.toggleable_entry(
|
||||
SYSTEM_DEFAULT,
|
||||
is_system_default,
|
||||
IconPosition::Start,
|
||||
None,
|
||||
{
|
||||
let on_select = on_select.clone();
|
||||
move |window, cx| {
|
||||
on_select(None, window, cx);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
for device in devices.iter().filter(|d| d.matches_input(is_input)) {
|
||||
let is_current = current_device
|
||||
.as_ref()
|
||||
.map(|info| info.matches(&device.id, is_input))
|
||||
.unwrap_or(false);
|
||||
let device_id = device.id.clone();
|
||||
|
||||
menu = menu.toggleable_entry(
|
||||
device.to_string(),
|
||||
is_current,
|
||||
IconPosition::Start,
|
||||
None,
|
||||
{
|
||||
let on_select = on_select.clone();
|
||||
move |window, cx| {
|
||||
on_select(Some(device_id.clone()), window, cx);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
menu
|
||||
}
|
||||
});
|
||||
|
||||
DropdownMenu::new(
|
||||
dropdown_id,
|
||||
current_device
|
||||
.map(|info| info.desc.name().to_string())
|
||||
.unwrap_or(SYSTEM_DEFAULT.to_string()),
|
||||
menu,
|
||||
)
|
||||
.style(DropdownStyle::Outlined)
|
||||
.full_width(true)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_settings_audio_device_dropdown<T: AsRef<Option<String>> + From<Option<String>> + Send>(
|
||||
field: SettingField<T>,
|
||||
file: SettingsUiFile,
|
||||
is_input: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> AnyElement {
|
||||
let (_, current_value): (_, Option<&T>) =
|
||||
SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
|
||||
let current_device_id =
|
||||
current_value.and_then(|x| x.as_ref().clone().and_then(|x| DeviceId::from_str(&x).ok()));
|
||||
|
||||
let dropdown_id: SharedString = if is_input {
|
||||
"input-audio-device-dropdown".into()
|
||||
} else {
|
||||
"output-audio-device-dropdown".into()
|
||||
};
|
||||
|
||||
render_audio_device_dropdown(
|
||||
dropdown_id,
|
||||
current_device_id,
|
||||
is_input,
|
||||
move |device_id, window, cx| {
|
||||
let value: Option<T> = device_id.map(|id| T::from(Some(id.to_string())));
|
||||
update_settings_file(
|
||||
file.clone(),
|
||||
field.json_path,
|
||||
window,
|
||||
cx,
|
||||
move |settings, app| {
|
||||
(field.write)(settings, value, app);
|
||||
},
|
||||
)
|
||||
.log_err();
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn render_input_audio_device_dropdown(
|
||||
field: SettingField<AudioInputDeviceName>,
|
||||
file: SettingsUiFile,
|
||||
_metadata: Option<&SettingsFieldMetadata>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> AnyElement {
|
||||
render_settings_audio_device_dropdown(field, file, true, window, cx)
|
||||
}
|
||||
|
||||
pub fn render_output_audio_device_dropdown(
|
||||
field: SettingField<AudioOutputDeviceName>,
|
||||
file: SettingsUiFile,
|
||||
_metadata: Option<&SettingsFieldMetadata>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> AnyElement {
|
||||
render_settings_audio_device_dropdown(field, file, false, window, cx)
|
||||
}
|
||||
304
crates/settings_ui/src/pages/audio_test_window.rs
Normal file
304
crates/settings_ui/src/pages/audio_test_window.rs
Normal file
@@ -0,0 +1,304 @@
|
||||
use audio::{AudioSettings, CHANNEL_COUNT, RodioExt, SAMPLE_RATE};
|
||||
use cpal::DeviceId;
|
||||
use gpui::{
|
||||
App, Context, Entity, FocusHandle, Focusable, Render, Size, Tiling, Window, WindowBounds,
|
||||
WindowKind, WindowOptions, prelude::*, px,
|
||||
};
|
||||
use platform_title_bar::PlatformTitleBar;
|
||||
use release_channel::ReleaseChannel;
|
||||
use rodio::Source;
|
||||
use settings::{AudioInputDeviceName, AudioOutputDeviceName, Settings};
|
||||
use std::{
|
||||
any::Any,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
use ui::{Button, ButtonStyle, Label, prelude::*};
|
||||
use util::ResultExt;
|
||||
use workspace::client_side_decorations;
|
||||
|
||||
use super::audio_input_output_setup::render_audio_device_dropdown;
|
||||
use crate::{SettingsUiFile, update_settings_file};
|
||||
|
||||
pub struct AudioTestWindow {
|
||||
title_bar: Option<Entity<PlatformTitleBar>>,
|
||||
input_device_id: Option<DeviceId>,
|
||||
output_device_id: Option<DeviceId>,
|
||||
focus_handle: FocusHandle,
|
||||
_stop_playback: Option<Box<dyn Any + Send>>,
|
||||
}
|
||||
|
||||
impl AudioTestWindow {
|
||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||
let title_bar = if !cfg!(target_os = "macos") {
|
||||
Some(cx.new(|cx| PlatformTitleBar::new("audio-test-title-bar", cx)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let audio_settings = AudioSettings::get_global(cx);
|
||||
let input_device_id = audio_settings.input_audio_device.clone();
|
||||
let output_device_id = audio_settings.output_audio_device.clone();
|
||||
|
||||
Self {
|
||||
title_bar,
|
||||
input_device_id,
|
||||
output_device_id,
|
||||
focus_handle: cx.focus_handle(),
|
||||
_stop_playback: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_testing(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(_cb) = self._stop_playback.take() {
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(cb) =
|
||||
start_test_playback(self.input_device_id.clone(), self.output_device_id.clone()).ok()
|
||||
{
|
||||
self._stop_playback = Some(cb);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn start_test_playback(
|
||||
input_device_id: Option<DeviceId>,
|
||||
output_device_id: Option<DeviceId>,
|
||||
) -> anyhow::Result<Box<dyn Any + Send>> {
|
||||
let stop_signal = Arc::new(AtomicBool::new(false));
|
||||
|
||||
thread::Builder::new()
|
||||
.name("AudioTestPlayback".to_string())
|
||||
.spawn({
|
||||
let stop_signal = stop_signal.clone();
|
||||
move || {
|
||||
let microphone = match open_test_microphone(input_device_id, stop_signal.clone()) {
|
||||
Ok(mic) => mic,
|
||||
Err(e) => {
|
||||
log::error!("Could not open microphone for audio test: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Ok(output) = audio::open_test_output(output_device_id) else {
|
||||
log::error!("Could not open output device for audio test");
|
||||
return;
|
||||
};
|
||||
|
||||
// let microphone = rx.recv().unwrap();
|
||||
output.mixer().add(microphone);
|
||||
|
||||
// Keep thread (and output device) alive until stop signal
|
||||
while !stop_signal.load(Ordering::Relaxed) {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(Box::new(util::defer(move || {
|
||||
stop_signal.store(true, Ordering::Relaxed);
|
||||
})))
|
||||
}
|
||||
|
||||
fn open_test_microphone(
|
||||
input_device_id: Option<DeviceId>,
|
||||
stop_signal: Arc<AtomicBool>,
|
||||
) -> anyhow::Result<impl Source> {
|
||||
let stream = audio::open_input_stream(input_device_id)?;
|
||||
let stream = stream
|
||||
.possibly_disconnected_channels_to_mono()
|
||||
.constant_samplerate(SAMPLE_RATE)
|
||||
.constant_params(CHANNEL_COUNT, SAMPLE_RATE)
|
||||
.stoppable()
|
||||
.periodic_access(
|
||||
Duration::from_millis(50),
|
||||
move |stoppable: &mut rodio::source::Stoppable<_>| {
|
||||
if stop_signal.load(Ordering::Relaxed) {
|
||||
stoppable.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
impl Render for AudioTestWindow {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let is_testing = self._stop_playback.is_some();
|
||||
let button_text = if is_testing {
|
||||
"Stop Testing"
|
||||
} else {
|
||||
"Start Testing"
|
||||
};
|
||||
|
||||
let button_style = if is_testing {
|
||||
ButtonStyle::Tinted(ui::TintColor::Error)
|
||||
} else {
|
||||
ButtonStyle::Filled
|
||||
};
|
||||
|
||||
let weak_entity = cx.entity().downgrade();
|
||||
let input_dropdown = {
|
||||
let weak_entity = weak_entity.clone();
|
||||
render_audio_device_dropdown(
|
||||
"audio-test-input-dropdown",
|
||||
self.input_device_id.clone(),
|
||||
true,
|
||||
move |device_id, window, cx| {
|
||||
weak_entity
|
||||
.update(cx, |this, cx| {
|
||||
this.input_device_id = device_id.clone();
|
||||
cx.notify();
|
||||
})
|
||||
.log_err();
|
||||
let value: Option<AudioInputDeviceName> =
|
||||
device_id.map(|id| AudioInputDeviceName(Some(id.to_string())));
|
||||
update_settings_file(
|
||||
SettingsUiFile::User,
|
||||
Some("audio.experimental.input_audio_device"),
|
||||
window,
|
||||
cx,
|
||||
move |settings, _cx| {
|
||||
settings.audio.get_or_insert_default().input_audio_device = value;
|
||||
},
|
||||
)
|
||||
.log_err();
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
};
|
||||
|
||||
let output_dropdown = render_audio_device_dropdown(
|
||||
"audio-test-output-dropdown",
|
||||
self.output_device_id.clone(),
|
||||
false,
|
||||
move |device_id, window, cx| {
|
||||
weak_entity
|
||||
.update(cx, |this, cx| {
|
||||
this.output_device_id = device_id.clone();
|
||||
cx.notify();
|
||||
})
|
||||
.log_err();
|
||||
let value: Option<AudioOutputDeviceName> =
|
||||
device_id.map(|id| AudioOutputDeviceName(Some(id.to_string())));
|
||||
update_settings_file(
|
||||
SettingsUiFile::User,
|
||||
Some("audio.experimental.output_audio_device"),
|
||||
window,
|
||||
cx,
|
||||
move |settings, _cx| {
|
||||
settings.audio.get_or_insert_default().output_audio_device = value;
|
||||
},
|
||||
)
|
||||
.log_err();
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
let content = v_flex()
|
||||
.id("audio-test-window")
|
||||
.track_focus(&self.focus_handle)
|
||||
.size_full()
|
||||
.p_4()
|
||||
.when(cfg!(target_os = "macos"), |this| this.pt_10())
|
||||
.gap_4()
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(Label::new("Output Device"))
|
||||
.child(output_dropdown),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(Label::new("Input Device"))
|
||||
.child(input_dropdown),
|
||||
)
|
||||
.child(
|
||||
h_flex().w_full().justify_center().pt_4().child(
|
||||
Button::new("test-audio-toggle", button_text)
|
||||
.style(button_style)
|
||||
.on_click(cx.listener(|this, _, _, cx| this.toggle_testing(cx))),
|
||||
),
|
||||
);
|
||||
|
||||
client_side_decorations(
|
||||
v_flex()
|
||||
.size_full()
|
||||
.text_color(cx.theme().colors().text)
|
||||
.children(self.title_bar.clone())
|
||||
.child(content),
|
||||
window,
|
||||
cx,
|
||||
Tiling::default(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Focusable for AudioTestWindow {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AudioTestWindow {
|
||||
fn drop(&mut self) {
|
||||
let _ = self._stop_playback.take();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_audio_test_window(_window: &mut Window, cx: &mut App) {
|
||||
let existing = cx
|
||||
.windows()
|
||||
.into_iter()
|
||||
.find_map(|w| w.downcast::<AudioTestWindow>());
|
||||
|
||||
if let Some(existing) = existing {
|
||||
existing
|
||||
.update(cx, |_, window, _| window.activate_window())
|
||||
.log_err();
|
||||
return;
|
||||
}
|
||||
|
||||
let app_id = ReleaseChannel::global(cx).app_id();
|
||||
let window_size = Size {
|
||||
width: px(640.0),
|
||||
height: px(300.0),
|
||||
};
|
||||
let window_min_size = Size {
|
||||
width: px(400.0),
|
||||
height: px(240.0),
|
||||
};
|
||||
|
||||
cx.open_window(
|
||||
WindowOptions {
|
||||
titlebar: Some(gpui::TitlebarOptions {
|
||||
title: Some("Audio Test".into()),
|
||||
appears_transparent: true,
|
||||
traffic_light_position: Some(gpui::point(px(12.0), px(12.0))),
|
||||
}),
|
||||
focus: true,
|
||||
show: true,
|
||||
is_movable: true,
|
||||
kind: WindowKind::Normal,
|
||||
window_background: cx.theme().window_background_appearance(),
|
||||
app_id: Some(app_id.to_owned()),
|
||||
window_decorations: Some(gpui::WindowDecorations::Client),
|
||||
window_bounds: Some(WindowBounds::centered(window_size, cx)),
|
||||
window_min_size: Some(window_min_size),
|
||||
..Default::default()
|
||||
},
|
||||
|_, cx| cx.new(AudioTestWindow::new),
|
||||
)
|
||||
.log_err();
|
||||
}
|
||||
748
crates/settings_ui/src/pages/edit_prediction_provider_setup.rs
Normal file
748
crates/settings_ui/src/pages/edit_prediction_provider_setup.rs
Normal file
@@ -0,0 +1,748 @@
|
||||
use codestral::{CODESTRAL_API_URL, codestral_api_key_state, codestral_api_url};
|
||||
use edit_prediction::{
|
||||
ApiKeyState,
|
||||
mercury::{MERCURY_CREDENTIALS_URL, mercury_api_token},
|
||||
open_ai_compatible::{open_ai_compatible_api_token, open_ai_compatible_api_url},
|
||||
};
|
||||
use edit_prediction_ui::{get_available_providers, set_completion_provider};
|
||||
use gpui::{App, Entity, ScrollHandle, TaskExt, prelude::*};
|
||||
use language::language_settings::AllLanguageSettings;
|
||||
|
||||
use settings::Settings as _;
|
||||
use ui::{ButtonLink, ConfiguredApiCard, ContextMenu, DropdownMenu, DropdownStyle, prelude::*};
|
||||
use workspace::AppState;
|
||||
|
||||
const OLLAMA_API_URL_PLACEHOLDER: &str = "http://localhost:11434";
|
||||
const OLLAMA_MODEL_PLACEHOLDER: &str = "qwen2.5-coder:3b-base";
|
||||
|
||||
use crate::{
|
||||
SettingField, SettingItem, SettingsFieldMetadata, SettingsPageItem, SettingsWindow, USER,
|
||||
components::{SettingsInputField, SettingsSectionHeader},
|
||||
};
|
||||
|
||||
pub(crate) fn render_edit_prediction_setup_page(
|
||||
settings_window: &SettingsWindow,
|
||||
scroll_handle: &ScrollHandle,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<SettingsWindow>,
|
||||
) -> AnyElement {
|
||||
let providers = [
|
||||
Some(render_provider_dropdown(window, cx)),
|
||||
render_github_copilot_provider(window, cx).map(IntoElement::into_any_element),
|
||||
Some(
|
||||
render_api_key_provider(
|
||||
IconName::Inception,
|
||||
"Mercury",
|
||||
ApiKeyDocs::Link {
|
||||
dashboard_url: "https://platform.inceptionlabs.ai/dashboard/api-keys".into(),
|
||||
},
|
||||
mercury_api_token(cx),
|
||||
|_cx| MERCURY_CREDENTIALS_URL,
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.into_any_element(),
|
||||
),
|
||||
Some(
|
||||
render_api_key_provider(
|
||||
IconName::AiMistral,
|
||||
"Codestral",
|
||||
ApiKeyDocs::Link {
|
||||
dashboard_url: "https://console.mistral.ai/codestral".into(),
|
||||
},
|
||||
codestral_api_key_state(cx),
|
||||
|cx| codestral_api_url(cx),
|
||||
Some(
|
||||
settings_window
|
||||
.render_sub_page_items_section(
|
||||
codestral_settings().iter().enumerate(),
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.into_any_element(),
|
||||
),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.into_any_element(),
|
||||
),
|
||||
Some(render_ollama_provider(settings_window, window, cx).into_any_element()),
|
||||
Some(
|
||||
render_api_key_provider(
|
||||
IconName::AiOpenAiCompat,
|
||||
"OpenAI Compatible API",
|
||||
ApiKeyDocs::Custom {
|
||||
message: "The API key sent as Authorization: Bearer {key}.".into(),
|
||||
},
|
||||
open_ai_compatible_api_token(cx),
|
||||
|cx| open_ai_compatible_api_url(cx),
|
||||
Some(
|
||||
settings_window
|
||||
.render_sub_page_items_section(
|
||||
open_ai_compatible_settings().iter().enumerate(),
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.into_any_element(),
|
||||
),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.into_any_element(),
|
||||
),
|
||||
];
|
||||
|
||||
div()
|
||||
.size_full()
|
||||
.child(
|
||||
v_flex()
|
||||
.id("ep-setup-page")
|
||||
.min_w_0()
|
||||
.size_full()
|
||||
.px_8()
|
||||
.pb_16()
|
||||
.overflow_y_scroll()
|
||||
.track_scroll(&scroll_handle)
|
||||
.children(providers.into_iter().flatten()),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_provider_dropdown(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
let current_provider = AllLanguageSettings::get_global(cx)
|
||||
.edit_predictions
|
||||
.provider;
|
||||
let current_provider_name = current_provider.display_name().unwrap_or("No provider set");
|
||||
|
||||
let menu = ContextMenu::build(window, cx, move |mut menu, _, cx| {
|
||||
let available_providers = get_available_providers(cx);
|
||||
let fs = <dyn fs::Fs>::global(cx);
|
||||
|
||||
for provider in available_providers {
|
||||
let Some(name) = provider.display_name() else {
|
||||
continue;
|
||||
};
|
||||
let is_current = provider == current_provider;
|
||||
|
||||
menu = menu.toggleable_entry(name, is_current, IconPosition::Start, None, {
|
||||
let fs = fs.clone();
|
||||
move |_, cx| {
|
||||
set_completion_provider(fs.clone(), cx, provider);
|
||||
}
|
||||
});
|
||||
}
|
||||
menu
|
||||
});
|
||||
|
||||
v_flex()
|
||||
.id("provider-selector")
|
||||
.min_w_0()
|
||||
.gap_1p5()
|
||||
.child(SettingsSectionHeader::new("Active Provider").no_padding(true))
|
||||
.child(
|
||||
h_flex()
|
||||
.pt_2p5()
|
||||
.w_full()
|
||||
.min_w_0()
|
||||
.justify_between()
|
||||
.child(
|
||||
v_flex()
|
||||
.w_full()
|
||||
.min_w_0()
|
||||
.max_w_1_2()
|
||||
.child(Label::new("Provider"))
|
||||
.child(
|
||||
Label::new("Select which provider to use for edit predictions.")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
DropdownMenu::new("provider-dropdown", current_provider_name, menu)
|
||||
.tab_index(0)
|
||||
.style(DropdownStyle::Outlined),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
enum ApiKeyDocs {
|
||||
Link { dashboard_url: SharedString },
|
||||
Custom { message: SharedString },
|
||||
}
|
||||
|
||||
fn render_api_key_provider(
|
||||
icon: IconName,
|
||||
title: &'static str,
|
||||
docs: ApiKeyDocs,
|
||||
api_key_state: Entity<ApiKeyState>,
|
||||
current_url: fn(&mut App) -> SharedString,
|
||||
additional_fields: Option<AnyElement>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<SettingsWindow>,
|
||||
) -> impl IntoElement {
|
||||
let weak_page = cx.weak_entity();
|
||||
let credentials_provider = zed_credentials_provider::global(cx);
|
||||
_ = window.use_keyed_state(current_url(cx), cx, |_, cx| {
|
||||
let task = api_key_state.update(cx, |key_state, cx| {
|
||||
key_state.load_if_needed(
|
||||
current_url(cx),
|
||||
|state| state,
|
||||
credentials_provider.clone(),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
cx.spawn(async move |_, cx| {
|
||||
task.await.ok();
|
||||
weak_page
|
||||
.update(cx, |_, cx| {
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
});
|
||||
|
||||
let (has_key, env_var_name, is_from_env_var) = api_key_state.read_with(cx, |state, _| {
|
||||
(
|
||||
state.has_key(),
|
||||
Some(state.env_var_name().clone()),
|
||||
state.is_from_env_var(),
|
||||
)
|
||||
});
|
||||
|
||||
let write_key = move |api_key: Option<String>, cx: &mut App| {
|
||||
let credentials_provider = zed_credentials_provider::global(cx);
|
||||
api_key_state
|
||||
.update(cx, |key_state, cx| {
|
||||
let url = current_url(cx);
|
||||
key_state.store(
|
||||
url,
|
||||
api_key,
|
||||
|key_state| key_state,
|
||||
credentials_provider,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
};
|
||||
|
||||
let base_container = v_flex().id(title).min_w_0().pt_8().gap_1p5();
|
||||
let header = SettingsSectionHeader::new(title)
|
||||
.icon(icon)
|
||||
.no_padding(true);
|
||||
let button_link_label = format!("{} dashboard", title);
|
||||
let description = match docs {
|
||||
ApiKeyDocs::Custom { message } => div().min_w_0().w_full().child(
|
||||
Label::new(message)
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
),
|
||||
ApiKeyDocs::Link { dashboard_url } => h_flex()
|
||||
.w_full()
|
||||
.min_w_0()
|
||||
.flex_wrap()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
Label::new("Visit the")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(
|
||||
ButtonLink::new(button_link_label, dashboard_url)
|
||||
.no_icon(true)
|
||||
.label_size(LabelSize::Small)
|
||||
.label_color(Color::Muted),
|
||||
)
|
||||
.child(
|
||||
Label::new("to generate an API key.")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
),
|
||||
};
|
||||
let configured_card_label = if is_from_env_var {
|
||||
"API Key Set in Environment Variable"
|
||||
} else {
|
||||
"API Key Configured"
|
||||
};
|
||||
|
||||
let container = if has_key {
|
||||
base_container.child(header).child(
|
||||
ConfiguredApiCard::new(configured_card_label)
|
||||
.button_label("Reset Key")
|
||||
.button_tab_index(0)
|
||||
.disabled(is_from_env_var)
|
||||
.when_some(env_var_name, |this, env_var_name| {
|
||||
this.when(is_from_env_var, |this| {
|
||||
this.tooltip_label(format!(
|
||||
"To reset your API key, unset the {} environment variable.",
|
||||
env_var_name
|
||||
))
|
||||
})
|
||||
})
|
||||
.on_click(move |_, _, cx| {
|
||||
write_key(None, cx);
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
base_container.child(header).child(
|
||||
h_flex()
|
||||
.pt_2p5()
|
||||
.w_full()
|
||||
.min_w_0()
|
||||
.justify_between()
|
||||
.child(
|
||||
v_flex()
|
||||
.w_full()
|
||||
.min_w_0()
|
||||
.max_w_1_2()
|
||||
.child(Label::new("API Key"))
|
||||
.child(description)
|
||||
.when_some(env_var_name, |this, env_var_name| {
|
||||
this.child({
|
||||
let label = format!(
|
||||
"Or set the {} env var and restart Zed.",
|
||||
env_var_name.as_ref()
|
||||
);
|
||||
Label::new(label).size(LabelSize::Small).color(Color::Muted)
|
||||
})
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
SettingsInputField::new()
|
||||
.tab_index(0)
|
||||
.with_placeholder("xxxxxxxxxxxxxxxxxxxx")
|
||||
.on_confirm(move |api_key, _window, cx| {
|
||||
write_key(api_key.filter(|key| !key.is_empty()), cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
};
|
||||
|
||||
container.when_some(additional_fields, |this, additional_fields| {
|
||||
this.child(
|
||||
div()
|
||||
.map(|this| if has_key { this.mt_1() } else { this.mt_4() })
|
||||
.px_neg_8()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.child(additional_fields),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn render_ollama_provider(
|
||||
settings_window: &SettingsWindow,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<SettingsWindow>,
|
||||
) -> impl IntoElement {
|
||||
let ollama_settings = ollama_settings();
|
||||
let additional_fields = settings_window
|
||||
.render_sub_page_items_section(ollama_settings.iter().enumerate(), true, window, cx)
|
||||
.into_any_element();
|
||||
|
||||
v_flex()
|
||||
.id("ollama")
|
||||
.min_w_0()
|
||||
.pt_8()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
SettingsSectionHeader::new("Ollama")
|
||||
.icon(IconName::AiOllama)
|
||||
.no_padding(true),
|
||||
)
|
||||
.child(div().px_neg_8().child(additional_fields))
|
||||
}
|
||||
|
||||
fn ollama_settings() -> Box<[SettingsPageItem]> {
|
||||
Box::new([
|
||||
SettingsPageItem::SettingItem(SettingItem {
|
||||
title: "API URL",
|
||||
description: "The base URL of your Ollama server.",
|
||||
field: Box::new(SettingField {
|
||||
pick: |settings| {
|
||||
settings
|
||||
.project
|
||||
.all_languages
|
||||
.edit_predictions
|
||||
.as_ref()?
|
||||
.ollama
|
||||
.as_ref()?
|
||||
.api_url
|
||||
.as_ref()
|
||||
},
|
||||
write: |settings, value, _app: &App| {
|
||||
settings
|
||||
.project
|
||||
.all_languages
|
||||
.edit_predictions
|
||||
.get_or_insert_default()
|
||||
.ollama
|
||||
.get_or_insert_default()
|
||||
.api_url = value;
|
||||
},
|
||||
json_path: Some("edit_predictions.ollama.api_url"),
|
||||
}),
|
||||
metadata: Some(Box::new(SettingsFieldMetadata {
|
||||
placeholder: Some(OLLAMA_API_URL_PLACEHOLDER),
|
||||
..Default::default()
|
||||
})),
|
||||
files: USER,
|
||||
}),
|
||||
SettingsPageItem::SettingItem(SettingItem {
|
||||
title: "Model",
|
||||
description: "The Ollama model to use for edit predictions.",
|
||||
field: Box::new(SettingField {
|
||||
pick: |settings| {
|
||||
settings
|
||||
.project
|
||||
.all_languages
|
||||
.edit_predictions
|
||||
.as_ref()?
|
||||
.ollama
|
||||
.as_ref()?
|
||||
.model
|
||||
.as_ref()
|
||||
},
|
||||
write: |settings, value, _app: &App| {
|
||||
settings
|
||||
.project
|
||||
.all_languages
|
||||
.edit_predictions
|
||||
.get_or_insert_default()
|
||||
.ollama
|
||||
.get_or_insert_default()
|
||||
.model = value;
|
||||
},
|
||||
json_path: Some("edit_predictions.ollama.model"),
|
||||
}),
|
||||
metadata: Some(Box::new(SettingsFieldMetadata {
|
||||
placeholder: Some(OLLAMA_MODEL_PLACEHOLDER),
|
||||
..Default::default()
|
||||
})),
|
||||
files: USER,
|
||||
}),
|
||||
SettingsPageItem::SettingItem(SettingItem {
|
||||
title: "Prompt Format",
|
||||
description: "The prompt format to use when requesting predictions. Set to Infer to have the format inferred based on the model name.",
|
||||
field: Box::new(SettingField {
|
||||
pick: |settings| {
|
||||
settings
|
||||
.project
|
||||
.all_languages
|
||||
.edit_predictions
|
||||
.as_ref()?
|
||||
.ollama
|
||||
.as_ref()?
|
||||
.prompt_format
|
||||
.as_ref()
|
||||
},
|
||||
write: |settings, value, _app: &App| {
|
||||
settings
|
||||
.project
|
||||
.all_languages
|
||||
.edit_predictions
|
||||
.get_or_insert_default()
|
||||
.ollama
|
||||
.get_or_insert_default()
|
||||
.prompt_format = value;
|
||||
},
|
||||
json_path: Some("edit_predictions.ollama.prompt_format"),
|
||||
}),
|
||||
files: USER,
|
||||
metadata: None,
|
||||
}),
|
||||
SettingsPageItem::SettingItem(SettingItem {
|
||||
title: "Max Output Tokens",
|
||||
description: "The maximum number of tokens to generate.",
|
||||
field: Box::new(SettingField {
|
||||
pick: |settings| {
|
||||
settings
|
||||
.project
|
||||
.all_languages
|
||||
.edit_predictions
|
||||
.as_ref()?
|
||||
.ollama
|
||||
.as_ref()?
|
||||
.max_output_tokens
|
||||
.as_ref()
|
||||
},
|
||||
write: |settings, value, _app: &App| {
|
||||
settings
|
||||
.project
|
||||
.all_languages
|
||||
.edit_predictions
|
||||
.get_or_insert_default()
|
||||
.ollama
|
||||
.get_or_insert_default()
|
||||
.max_output_tokens = value;
|
||||
},
|
||||
json_path: Some("edit_predictions.ollama.max_output_tokens"),
|
||||
}),
|
||||
metadata: None,
|
||||
files: USER,
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
fn open_ai_compatible_settings() -> Box<[SettingsPageItem]> {
|
||||
Box::new([
|
||||
SettingsPageItem::SettingItem(SettingItem {
|
||||
title: "API URL",
|
||||
description: "The URL of your OpenAI-compatible server's completions API.",
|
||||
field: Box::new(SettingField {
|
||||
pick: |settings| {
|
||||
settings
|
||||
.project
|
||||
.all_languages
|
||||
.edit_predictions
|
||||
.as_ref()?
|
||||
.open_ai_compatible_api
|
||||
.as_ref()?
|
||||
.api_url
|
||||
.as_ref()
|
||||
},
|
||||
write: |settings, value, _app: &App| {
|
||||
settings
|
||||
.project
|
||||
.all_languages
|
||||
.edit_predictions
|
||||
.get_or_insert_default()
|
||||
.open_ai_compatible_api
|
||||
.get_or_insert_default()
|
||||
.api_url = value;
|
||||
},
|
||||
json_path: Some("edit_predictions.open_ai_compatible_api.api_url"),
|
||||
}),
|
||||
metadata: Some(Box::new(SettingsFieldMetadata {
|
||||
placeholder: Some(OLLAMA_API_URL_PLACEHOLDER),
|
||||
..Default::default()
|
||||
})),
|
||||
files: USER,
|
||||
}),
|
||||
SettingsPageItem::SettingItem(SettingItem {
|
||||
title: "Model",
|
||||
description: "The model string to pass to the OpenAI-compatible server.",
|
||||
field: Box::new(SettingField {
|
||||
pick: |settings| {
|
||||
settings
|
||||
.project
|
||||
.all_languages
|
||||
.edit_predictions
|
||||
.as_ref()?
|
||||
.open_ai_compatible_api
|
||||
.as_ref()?
|
||||
.model
|
||||
.as_ref()
|
||||
},
|
||||
write: |settings, value, _app: &App| {
|
||||
settings
|
||||
.project
|
||||
.all_languages
|
||||
.edit_predictions
|
||||
.get_or_insert_default()
|
||||
.open_ai_compatible_api
|
||||
.get_or_insert_default()
|
||||
.model = value;
|
||||
},
|
||||
json_path: Some("edit_predictions.open_ai_compatible_api.model"),
|
||||
}),
|
||||
metadata: Some(Box::new(SettingsFieldMetadata {
|
||||
placeholder: Some(OLLAMA_MODEL_PLACEHOLDER),
|
||||
..Default::default()
|
||||
})),
|
||||
files: USER,
|
||||
}),
|
||||
SettingsPageItem::SettingItem(SettingItem {
|
||||
title: "Prompt Format",
|
||||
description: "The prompt format to use when requesting predictions. Set to Infer to have the format inferred based on the model name.",
|
||||
field: Box::new(SettingField {
|
||||
pick: |settings| {
|
||||
settings
|
||||
.project
|
||||
.all_languages
|
||||
.edit_predictions
|
||||
.as_ref()?
|
||||
.open_ai_compatible_api
|
||||
.as_ref()?
|
||||
.prompt_format
|
||||
.as_ref()
|
||||
},
|
||||
write: |settings, value, _app: &App| {
|
||||
settings
|
||||
.project
|
||||
.all_languages
|
||||
.edit_predictions
|
||||
.get_or_insert_default()
|
||||
.open_ai_compatible_api
|
||||
.get_or_insert_default()
|
||||
.prompt_format = value;
|
||||
},
|
||||
json_path: Some("edit_predictions.open_ai_compatible_api.prompt_format"),
|
||||
}),
|
||||
files: USER,
|
||||
metadata: None,
|
||||
}),
|
||||
SettingsPageItem::SettingItem(SettingItem {
|
||||
title: "Max Output Tokens",
|
||||
description: "The maximum number of tokens to generate.",
|
||||
field: Box::new(SettingField {
|
||||
pick: |settings| {
|
||||
settings
|
||||
.project
|
||||
.all_languages
|
||||
.edit_predictions
|
||||
.as_ref()?
|
||||
.open_ai_compatible_api
|
||||
.as_ref()?
|
||||
.max_output_tokens
|
||||
.as_ref()
|
||||
},
|
||||
write: |settings, value, _app: &App| {
|
||||
settings
|
||||
.project
|
||||
.all_languages
|
||||
.edit_predictions
|
||||
.get_or_insert_default()
|
||||
.open_ai_compatible_api
|
||||
.get_or_insert_default()
|
||||
.max_output_tokens = value;
|
||||
},
|
||||
json_path: Some("edit_predictions.open_ai_compatible_api.max_output_tokens"),
|
||||
}),
|
||||
metadata: None,
|
||||
files: USER,
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
fn codestral_settings() -> Box<[SettingsPageItem]> {
|
||||
Box::new([
|
||||
SettingsPageItem::SettingItem(SettingItem {
|
||||
title: "API URL",
|
||||
description: "The API URL to use for Codestral.",
|
||||
field: Box::new(SettingField {
|
||||
pick: |settings| {
|
||||
settings
|
||||
.project
|
||||
.all_languages
|
||||
.edit_predictions
|
||||
.as_ref()?
|
||||
.codestral
|
||||
.as_ref()?
|
||||
.api_url
|
||||
.as_ref()
|
||||
},
|
||||
write: |settings, value, _app: &App| {
|
||||
settings
|
||||
.project
|
||||
.all_languages
|
||||
.edit_predictions
|
||||
.get_or_insert_default()
|
||||
.codestral
|
||||
.get_or_insert_default()
|
||||
.api_url = value;
|
||||
},
|
||||
json_path: Some("edit_predictions.codestral.api_url"),
|
||||
}),
|
||||
metadata: Some(Box::new(SettingsFieldMetadata {
|
||||
placeholder: Some(CODESTRAL_API_URL),
|
||||
..Default::default()
|
||||
})),
|
||||
files: USER,
|
||||
}),
|
||||
SettingsPageItem::SettingItem(SettingItem {
|
||||
title: "Max Tokens",
|
||||
description: "The maximum number of tokens to generate.",
|
||||
field: Box::new(SettingField {
|
||||
pick: |settings| {
|
||||
settings
|
||||
.project
|
||||
.all_languages
|
||||
.edit_predictions
|
||||
.as_ref()?
|
||||
.codestral
|
||||
.as_ref()?
|
||||
.max_tokens
|
||||
.as_ref()
|
||||
},
|
||||
write: |settings, value, _app: &App| {
|
||||
settings
|
||||
.project
|
||||
.all_languages
|
||||
.edit_predictions
|
||||
.get_or_insert_default()
|
||||
.codestral
|
||||
.get_or_insert_default()
|
||||
.max_tokens = value;
|
||||
},
|
||||
json_path: Some("edit_predictions.codestral.max_tokens"),
|
||||
}),
|
||||
metadata: None,
|
||||
files: USER,
|
||||
}),
|
||||
SettingsPageItem::SettingItem(SettingItem {
|
||||
title: "Model",
|
||||
description: "The Codestral model id to use.",
|
||||
field: Box::new(SettingField {
|
||||
pick: |settings| {
|
||||
settings
|
||||
.project
|
||||
.all_languages
|
||||
.edit_predictions
|
||||
.as_ref()?
|
||||
.codestral
|
||||
.as_ref()?
|
||||
.model
|
||||
.as_ref()
|
||||
},
|
||||
write: |settings, value, _app: &App| {
|
||||
settings
|
||||
.project
|
||||
.all_languages
|
||||
.edit_predictions
|
||||
.get_or_insert_default()
|
||||
.codestral
|
||||
.get_or_insert_default()
|
||||
.model = value;
|
||||
},
|
||||
json_path: Some("edit_predictions.codestral.model"),
|
||||
}),
|
||||
metadata: Some(Box::new(SettingsFieldMetadata {
|
||||
placeholder: Some("codestral-latest"),
|
||||
..Default::default()
|
||||
})),
|
||||
files: USER,
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
fn render_github_copilot_provider(window: &mut Window, cx: &mut App) -> Option<impl IntoElement> {
|
||||
let configuration_view = window.use_state(cx, |_, cx| {
|
||||
copilot_ui::ConfigurationView::new(
|
||||
move |cx| {
|
||||
let app_state = AppState::global(cx);
|
||||
copilot::GlobalCopilotAuth::try_get_or_init(app_state, cx)
|
||||
.is_some_and(|copilot| copilot.0.read(cx).is_authenticated())
|
||||
},
|
||||
copilot_ui::ConfigurationMode::EditPrediction,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
Some(
|
||||
v_flex()
|
||||
.id("github-copilot")
|
||||
.min_w_0()
|
||||
.pt_8()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
SettingsSectionHeader::new("GitHub Copilot")
|
||||
.icon(IconName::Copilot)
|
||||
.no_padding(true),
|
||||
)
|
||||
.child(configuration_view),
|
||||
)
|
||||
}
|
||||
132
crates/settings_ui/src/pages/feature_flags.rs
Normal file
132
crates/settings_ui/src/pages/feature_flags.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
use feature_flags::{FeatureFlagDescriptor, FeatureFlagStore, FeatureFlagVariant};
|
||||
use fs::Fs;
|
||||
use gpui::{ScrollHandle, prelude::*};
|
||||
use ui::{Checkbox, ToggleState, prelude::*};
|
||||
|
||||
use crate::SettingsWindow;
|
||||
|
||||
pub(crate) fn render_feature_flags_page(
|
||||
_settings_window: &SettingsWindow,
|
||||
scroll_handle: &ScrollHandle,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<SettingsWindow>,
|
||||
) -> AnyElement {
|
||||
// Sort by flag name so the list is stable between renders even though
|
||||
// `inventory::iter` order depends on link order.
|
||||
let mut descriptors: Vec<&'static FeatureFlagDescriptor> =
|
||||
FeatureFlagStore::known_flags().collect();
|
||||
descriptors.sort_by_key(|descriptor| descriptor.name);
|
||||
|
||||
v_flex()
|
||||
.id("feature-flags-page")
|
||||
.min_w_0()
|
||||
.size_full()
|
||||
.pt_2p5()
|
||||
.px_8()
|
||||
.pb_16()
|
||||
.gap_4()
|
||||
.overflow_y_scroll()
|
||||
.track_scroll(scroll_handle)
|
||||
.children(
|
||||
descriptors
|
||||
.into_iter()
|
||||
.map(|descriptor| render_flag_row(descriptor, cx)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_flag_row(
|
||||
descriptor: &'static FeatureFlagDescriptor,
|
||||
cx: &mut Context<SettingsWindow>,
|
||||
) -> AnyElement {
|
||||
let forced_on = FeatureFlagStore::is_forced_on(descriptor);
|
||||
let resolved = cx.global::<FeatureFlagStore>().resolved_key(descriptor, cx);
|
||||
let has_override = FeatureFlagStore::override_for(descriptor.name, cx).is_some();
|
||||
|
||||
let header =
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.items_center()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(Label::new(descriptor.name).size(LabelSize::Default).color(
|
||||
if forced_on {
|
||||
Color::Muted
|
||||
} else {
|
||||
Color::Default
|
||||
},
|
||||
))
|
||||
.when(forced_on, |this| {
|
||||
this.child(
|
||||
Label::new("enabled for all")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.when(has_override && !forced_on, |this| {
|
||||
let name = descriptor.name;
|
||||
this.child(
|
||||
Button::new(SharedString::from(format!("reset-{}", name)), "Reset")
|
||||
.label_size(LabelSize::Small)
|
||||
.on_click(cx.listener(move |_, _, _, cx| {
|
||||
FeatureFlagStore::clear_override(name, <dyn Fs>::global(cx), cx);
|
||||
})),
|
||||
)
|
||||
});
|
||||
|
||||
v_flex()
|
||||
.id(SharedString::from(format!("flag-row-{}", descriptor.name)))
|
||||
.gap_1()
|
||||
.child(header)
|
||||
.child(render_flag_variants(descriptor, resolved, forced_on, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_flag_variants(
|
||||
descriptor: &'static FeatureFlagDescriptor,
|
||||
resolved: &'static str,
|
||||
forced_on: bool,
|
||||
cx: &mut Context<SettingsWindow>,
|
||||
) -> impl IntoElement {
|
||||
let variants: Vec<FeatureFlagVariant> = (descriptor.variants)();
|
||||
|
||||
let row_items = variants.into_iter().map({
|
||||
let name = descriptor.name;
|
||||
move |variant| {
|
||||
let key = variant.override_key;
|
||||
let label = variant.label;
|
||||
let selected = resolved == key;
|
||||
let state = if selected {
|
||||
ToggleState::Selected
|
||||
} else {
|
||||
ToggleState::Unselected
|
||||
};
|
||||
let checkbox_id = SharedString::from(format!("{}-{}", name, key));
|
||||
let disabled = forced_on;
|
||||
let mut checkbox = Checkbox::new(ElementId::from(checkbox_id), state)
|
||||
.label(label)
|
||||
.disabled(disabled);
|
||||
if !disabled {
|
||||
checkbox =
|
||||
checkbox.on_click(cx.listener(move |_, new_state: &ToggleState, _, cx| {
|
||||
// Clicking an already-selected option is a no-op rather than a
|
||||
// "deselect" — there's no valid "nothing selected" state.
|
||||
if *new_state == ToggleState::Unselected {
|
||||
return;
|
||||
}
|
||||
FeatureFlagStore::set_override(
|
||||
name,
|
||||
key.to_string(),
|
||||
<dyn Fs>::global(cx),
|
||||
cx,
|
||||
);
|
||||
}));
|
||||
}
|
||||
checkbox.into_any_element()
|
||||
}
|
||||
});
|
||||
|
||||
h_flex().gap_4().flex_wrap().children(row_items)
|
||||
}
|
||||
1458
crates/settings_ui/src/pages/tool_permissions_setup.rs
Normal file
1458
crates/settings_ui/src/pages/tool_permissions_setup.rs
Normal file
File diff suppressed because it is too large
Load Diff
5590
crates/settings_ui/src/settings_ui.rs
Normal file
5590
crates/settings_ui/src/settings_ui.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user