From b19748c42e9025590c71b719c52f81dcd7add453 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Wed, 25 Sep 2024 12:52:12 -0700 Subject: [PATCH] Make settings menu a regular window (#113) --- src-tauri/.gitignore | 2 +- src-tauri/capabilities/capabilities.json | 4 +- src-tauri/gen/schemas/capabilities.json | 2 +- src-tauri/src/lib.rs | 210 +++++++++++++++-------- src-tauri/src/tauri_plugin_mac_window.rs | 8 +- src-web/components/CommandPalette.tsx | 19 +- src-web/components/GlobalHooks.tsx | 3 +- src-web/components/SettingsDropdown.tsx | 20 +-- src-web/font-size.ts | 15 ++ src-web/hooks/useOpenSettings.ts | 22 +++ src-web/hooks/useOpenWorkspace.ts | 2 +- src-web/index.html | 1 + src-web/lib/tauri.ts | 4 +- 13 files changed, 201 insertions(+), 111 deletions(-) create mode 100644 src-web/font-size.ts create mode 100644 src-web/hooks/useOpenSettings.ts diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore index 87f5040a..de71bdbf 100644 --- a/src-tauri/.gitignore +++ b/src-tauri/.gitignore @@ -1,5 +1,5 @@ # Generated by Cargo # will have compiled files and executables -/target/ +target/ vendored diff --git a/src-tauri/capabilities/capabilities.json b/src-tauri/capabilities/capabilities.json index 89ae917c..6c80a97e 100644 --- a/src-tauri/capabilities/capabilities.json +++ b/src-tauri/capabilities/capabilities.json @@ -35,12 +35,12 @@ "core:window:allow-is-fullscreen", "core:window:allow-maximize", "core:window:allow-minimize", - "core:window:allow-toggle-maximize", "core:window:allow-set-decorations", "core:window:allow-set-title", "core:window:allow-start-dragging", - "core:window:allow-unmaximize", "core:window:allow-theme", + "core:window:allow-toggle-maximize", + "core:window:allow-unmaximize", "clipboard-manager:allow-read-text", "clipboard-manager:allow-write-text" ] diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 07b2db23..e324a669 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"main":{"identifier":"main","description":"Main permissions","local":true,"windows":["*"],"permissions":["core:event:allow-emit","core:event:allow-listen","core:event:allow-unlisten","os:allow-os-type","clipboard-manager:allow-clear","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","dialog:allow-open","dialog:allow-save","fs:allow-read-file","fs:allow-read-text-file",{"identifier":"fs:scope","allow":[{"path":"$APPDATA"},{"path":"$APPDATA/**"}]},"shell:allow-open","core:webview:allow-set-webview-zoom","core:window:allow-close","core:window:allow-is-fullscreen","core:window:allow-maximize","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-set-decorations","core:window:allow-set-title","core:window:allow-start-dragging","core:window:allow-unmaximize","core:window:allow-theme","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text"]}} \ No newline at end of file +{"main":{"identifier":"main","description":"Main permissions","local":true,"windows":["*"],"permissions":["core:event:allow-emit","core:event:allow-listen","core:event:allow-unlisten","os:allow-os-type","clipboard-manager:allow-clear","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","dialog:allow-open","dialog:allow-save","fs:allow-read-file","fs:allow-read-text-file",{"identifier":"fs:scope","allow":[{"path":"$APPDATA"},{"path":"$APPDATA/**"}]},"shell:allow-open","core:webview:allow-set-webview-zoom","core:window:allow-close","core:window:allow-is-fullscreen","core:window:allow-maximize","core:window:allow-minimize","core:window:allow-set-decorations","core:window:allow-set-title","core:window:allow-start-dragging","core:window:allow-theme","core:window:allow-toggle-maximize","core:window:allow-unmaximize","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text"]}} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a3b2a8c2..39ce6d5b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,13 +2,13 @@ extern crate core; #[cfg(target_os = "macos")] extern crate objc; -use std::collections::{BTreeMap}; -use std::fs; +use std::collections::BTreeMap; use std::fs::{create_dir_all, read_to_string, File}; use std::path::PathBuf; use std::process::exit; use std::str::FromStr; use std::time::Duration; +use std::{fs, panic}; use base64::prelude::BASE64_STANDARD; use base64::Engine; @@ -83,7 +83,9 @@ const DEFAULT_WINDOW_HEIGHT: f64 = 600.0; const MIN_WINDOW_WIDTH: f64 = 300.0; const MIN_WINDOW_HEIGHT: f64 = 300.0; + const MAIN_WINDOW_PREFIX: &str = "main_"; +const OTHER_WINDOW_PREFIX: &str = "other_"; #[derive(serde::Serialize)] #[serde(default, rename_all = "camelCase")] @@ -1649,19 +1651,83 @@ async fn cmd_list_workspaces(w: WebviewWindow) -> Result, String> } #[tauri::command] -async fn cmd_new_window(app_handle: AppHandle, url: &str) -> Result<(), String> { - create_window(&app_handle, url); +async fn cmd_new_child_window( + parent_window: WebviewWindow, + url: &str, + label: &str, + title: &str, + inner_size: (f64, f64), +) -> Result<(), String> { + let app_handle = parent_window.app_handle(); + let label = format!("{OTHER_WINDOW_PREFIX}_{label}"); + let scale_factor = parent_window.scale_factor().unwrap(); + + let current_pos = parent_window + .inner_position() + .unwrap() + .to_logical::(scale_factor); + let current_size = parent_window + .inner_size() + .unwrap() + .to_logical::(scale_factor); + + // Position the new window in the middle of the parent + let position = ( + current_pos.x + current_size.width / 2.0 - inner_size.0 / 2.0, + current_pos.y + current_size.height / 2.0 - inner_size.1 / 2.0, + ); + + let config = CreateWindowConfig { + label: label.as_str(), + title, + url, + inner_size, + position, + }; + + let child_window = create_window(&app_handle, config); + + // NOTE: These listeners will remain active even when the windows close. Unfortunately, + // there's no way to unlisten to events for now, so we just have to be defensive. + + { + let parent_window = parent_window.clone(); + let child_window = child_window.clone(); + child_window.clone().on_window_event(move |e| match e { + // When the new window is destroyed, bring the other up behind it + WindowEvent::Destroyed => { + if let Some(w) = parent_window.get_webview_window(child_window.label()) { + w.set_focus().unwrap(); + } + } + _ => {} + }); + } + + { + let parent_window = parent_window.clone(); + let child_window = child_window.clone(); + parent_window.clone().on_window_event(move |e| match e { + // When the parent window is closed, close the child + WindowEvent::CloseRequested { .. } => child_window.destroy().unwrap(), + // When the parent window is focused, bring the child above + WindowEvent::Focused(focus) => { + if *focus { + if let Some(w) = parent_window.get_webview_window(child_window.label()) { + w.set_focus().unwrap(); + }; + } + } + _ => {} + }); + } + Ok(()) } #[tauri::command] -async fn cmd_new_nested_window( - window: WebviewWindow, - url: &str, - label: &str, - title: &str, -) -> Result<(), String> { - create_nested_window(&window, label, url, title); +async fn cmd_new_main_window(app_handle: AppHandle, url: &str) -> Result<(), String> { + create_main_window(&app_handle, url); Ok(()) } @@ -1721,7 +1787,18 @@ pub fn run() { .build(), ) .plugin(tauri_plugin_clipboard_manager::init()) - .plugin(tauri_plugin_window_state::Builder::default().build()) + .plugin( + tauri_plugin_window_state::Builder::default() + .with_denylist(&["ignored"]) + .map_label(|label| { + if label.starts_with(OTHER_WINDOW_PREFIX) { + "ignored" + } else { + label + } + }) + .build(), + ) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_updater::Builder::default().build()) .plugin(tauri_plugin_dialog::init()) @@ -1810,8 +1887,8 @@ pub fn run() { cmd_list_plugins, cmd_list_workspaces, cmd_metadata, - cmd_new_nested_window, - cmd_new_window, + cmd_new_child_window, + cmd_new_main_window, cmd_parse_template, cmd_plugin_info, cmd_reload_plugins, @@ -1844,7 +1921,7 @@ pub fn run() { .run(|app_handle, event| { match event { RunEvent::Ready => { - let w = create_window(app_handle, "/"); + let w = create_main_window(app_handle, "/"); tauri::async_runtime::spawn(async move { let info = analytics::track_launch_event(&w).await; debug!("Launched Yaak {:?}", info); @@ -1866,7 +1943,9 @@ pub fn run() { tauri::async_runtime::spawn(async move { let val: State<'_, Mutex> = h.state(); let update_mode = get_update_mode(&h).await; - _ = val.lock().await.check(&h, update_mode).await; + if let Err(e) = val.lock().await.check(&h, update_mode).await { + warn!("Failed to check for updates {e:?}"); + }; }); let h = app_handle.clone(); @@ -1897,47 +1976,31 @@ fn is_dev() -> bool { } } -fn create_nested_window( - window: &WebviewWindow, - label: &str, - url: &str, - title: &str, -) -> WebviewWindow { - info!("Create new nested window label={label}"); - let mut win_builder = tauri::WebviewWindowBuilder::new( - window, - format!("nested_{}_{}", window.label(), label), - WebviewUrl::App(url.into()), - ) - .resizable(true) - .fullscreen(false) - .disable_drag_drop_handler() // Required for frontend Dnd on windows - .title(title) - .parent(&window) - .unwrap() - .min_inner_size(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT) - .inner_size(DEFAULT_WINDOW_WIDTH * 0.7, DEFAULT_WINDOW_HEIGHT * 0.9); - - // Add macOS-only things - #[cfg(target_os = "macos")] - { - win_builder = win_builder - .hidden_title(true) - .title_bar_style(TitleBarStyle::Overlay); - } - - // Add non-macOS things - #[cfg(not(target_os = "macos"))] - { - win_builder = win_builder.decorations(false); - } - - let win = win_builder.build().expect("failed to build window"); - - win +fn create_main_window(handle: &AppHandle, url: &str) -> WebviewWindow { + let label = format!("{MAIN_WINDOW_PREFIX}{}", handle.webview_windows().len()); + let config = CreateWindowConfig { + url, + label: label.as_str(), + title: "Yaak", + inner_size: (DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT), + position: ( + // Offset by random amount so it's easier to differentiate + 100.0 + random::() * 20.0, + 100.0 + random::() * 20.0, + ), + }; + create_window(handle, config) } -fn create_window(handle: &AppHandle, url: &str) -> WebviewWindow { +struct CreateWindowConfig<'s> { + url: &'s str, + label: &'s str, + title: &'s str, + inner_size: (f64, f64), + position: (f64, f64), +} + +fn create_window(handle: &AppHandle, config: CreateWindowConfig) -> WebviewWindow { #[allow(unused_variables)] let menu = app_menu(handle).unwrap(); @@ -1945,22 +2008,17 @@ fn create_window(handle: &AppHandle, url: &str) -> WebviewWindow { #[cfg(not(target_os = "linux"))] handle.set_menu(menu).expect("Failed to set app menu"); - let window_num = handle.webview_windows().len(); - let label = format!("{MAIN_WINDOW_PREFIX}{window_num}"); - info!("Create new window label={label}"); + info!("Create new window label={}", config.label); + let mut win_builder = - tauri::WebviewWindowBuilder::new(handle, label, WebviewUrl::App(url.into())) + tauri::WebviewWindowBuilder::new(handle, config.label, WebviewUrl::App(config.url.into())) + .title(config.title) .resizable(true) .fullscreen(false) .disable_drag_drop_handler() // Required for frontend Dnd on windows - .inner_size(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT) - .position( - // Randomly offset so windows don't stack exactly - 100.0 + random::() * 30.0, - 100.0 + random::() * 30.0, - ) - .min_inner_size(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT) - .title(handle.package_info().name.to_string()); + .inner_size(config.inner_size.0, config.inner_size.1) + .position(config.position.0, config.position.1) + .min_inner_size(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT); // Add macOS-only things #[cfg(target_os = "macos")] @@ -1977,7 +2035,16 @@ fn create_window(handle: &AppHandle, url: &str) -> WebviewWindow { win_builder = win_builder.decorations(false); } - let win = win_builder.build().expect("failed to build window"); + if let Some(w) = handle.webview_windows().get(config.label) { + info!( + "Webview with label {} already exists. Focusing existing", + config.label + ); + w.set_focus().unwrap(); + return w.to_owned(); + } + + let win = win_builder.build().unwrap(); let webview_window = win.clone(); win.on_menu_event(move |w, event| { @@ -1994,10 +2061,13 @@ fn create_window(handle: &AppHandle, url: &str) -> WebviewWindow { "zoom_out" => w.emit("zoom_out", true).unwrap(), "settings" => w.emit("settings", true).unwrap(), "open_feedback" => { - _ = webview_window + if let Err(e) = webview_window .app_handle() .shell() - .open("https://yaak.app/roadmap", None) + .open("https://yaak.app/feedback", None) + { + warn!("Failed to open feedback {e:?}") + } } // Commands for development diff --git a/src-tauri/src/tauri_plugin_mac_window.rs b/src-tauri/src/tauri_plugin_mac_window.rs index 7367c2b9..6a9bb969 100644 --- a/src-tauri/src/tauri_plugin_mac_window.rs +++ b/src-tauri/src/tauri_plugin_mac_window.rs @@ -2,7 +2,11 @@ use hex_color::HexColor; use log::warn; use objc::{msg_send, sel, sel_impl}; use rand::{distributions::Alphanumeric, Rng}; -use tauri::{plugin::{Builder, TauriPlugin}, Manager, Runtime, Window, WindowEvent, Emitter, Listener}; +use tauri::{ + plugin::{Builder, TauriPlugin}, + Emitter, Listener, Manager, Runtime, Window, WindowEvent, +}; +use crate::MAIN_WINDOW_PREFIX; const WINDOW_CONTROL_PAD_X: f64 = 13.0; const WINDOW_CONTROL_PAD_Y: f64 = 18.0; @@ -127,7 +131,7 @@ fn update_window_theme(window: Window, color: HexColor) { #[cfg(target_os = "macos")] fn position_traffic_lights(ns_window_handle: UnsafeWindowHandle, x: f64, y: f64, label: String) { - if label.starts_with("nested_") { + if !label.starts_with(MAIN_WINDOW_PREFIX) { return; } diff --git a/src-web/components/CommandPalette.tsx b/src-web/components/CommandPalette.tsx index dfe94a9c..43f9dd2c 100644 --- a/src-web/components/CommandPalette.tsx +++ b/src-web/components/CommandPalette.tsx @@ -1,11 +1,10 @@ import classNames from 'classnames'; import { search } from 'fast-fuzzy'; import type { KeyboardEvent, ReactNode } from 'react'; -import { useEffect, useRef, useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useActiveCookieJar } from '../hooks/useActiveCookieJar'; import { useActiveEnvironment } from '../hooks/useActiveEnvironment'; import { useActiveRequest } from '../hooks/useActiveRequest'; -import { useActiveWorkspace } from '../hooks/useActiveWorkspace'; import { useAppRoutes } from '../hooks/useAppRoutes'; import { useCreateEnvironment } from '../hooks/useCreateEnvironment'; import { useCreateGrpcRequest } from '../hooks/useCreateGrpcRequest'; @@ -17,6 +16,7 @@ import { useEnvironments } from '../hooks/useEnvironments'; import type { HotkeyAction } from '../hooks/useHotKey'; import { useHotKey } from '../hooks/useHotKey'; import { useHttpRequestActions } from '../hooks/useHttpRequestActions'; +import { useOpenSettings } from '../hooks/useOpenSettings'; import { useOpenWorkspace } from '../hooks/useOpenWorkspace'; import { useRecentEnvironments } from '../hooks/useRecentEnvironments'; import { useRecentRequests } from '../hooks/useRecentRequests'; @@ -28,7 +28,6 @@ import { useSendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest'; import { useSidebarHidden } from '../hooks/useSidebarHidden'; import { useWorkspaces } from '../hooks/useWorkspaces'; import { fallbackRequestName } from '../lib/fallbackRequestName'; -import { invokeCmd } from '../lib/tauri'; import { CookieDialog } from './CookieDialog'; import { Button } from './core/Button'; import { Heading } from './core/Heading'; @@ -74,11 +73,11 @@ export function CommandPalette({ onClose }: { onClose: () => void }) { const createGrpcRequest = useCreateGrpcRequest(); const createEnvironment = useCreateEnvironment(); const dialog = useDialog(); - const workspace = useActiveWorkspace(); const sendRequest = useSendAnyHttpRequest(); const renameRequest = useRenameRequest(activeRequest?.id ?? null); const deleteRequest = useDeleteRequest(activeRequest?.id ?? null); const [, setSidebarHidden] = useSidebarHidden(); + const openSettings = useOpenSettings(); const workspaceCommands = useMemo(() => { const commands: CommandPaletteItem[] = [ @@ -86,14 +85,7 @@ export function CommandPalette({ onClose }: { onClose: () => void }) { key: 'settings.open', label: 'Open Settings', action: 'settings.show', - onSelect: async () => { - if (workspace == null) return; - await invokeCmd('cmd_new_nested_window', { - url: routes.paths.workspaceSettings({ workspaceId: workspace.id }), - label: 'settings', - title: 'Yaak Settings', - }); - }, + onSelect: openSettings.mutate, }, { key: 'app.create', @@ -195,11 +187,10 @@ export function CommandPalette({ onClose }: { onClose: () => void }) { deleteRequest.mutate, dialog, httpRequestActions, + openSettings.mutate, renameRequest.mutate, - routes.paths, sendRequest, setSidebarHidden, - workspace, ]); const sortedRequests = useMemo(() => { diff --git a/src-web/components/GlobalHooks.tsx b/src-web/components/GlobalHooks.tsx index a804e297..fd473e98 100644 --- a/src-web/components/GlobalHooks.tsx +++ b/src-web/components/GlobalHooks.tsx @@ -160,9 +160,8 @@ export function GlobalHooks() { return; } - const { interfaceScale, interfaceFontSize, editorFontSize } = settings; + const { interfaceScale, editorFontSize } = settings; getCurrentWebviewWindow().setZoom(interfaceScale).catch(console.error); - document.documentElement.style.setProperty('font-size', `${interfaceFontSize}px`); document.documentElement.style.setProperty('--editor-font-size', `${editorFontSize}px`); }, [settings]); diff --git a/src-web/components/SettingsDropdown.tsx b/src-web/components/SettingsDropdown.tsx index 3744e911..b9bcbb4a 100644 --- a/src-web/components/SettingsDropdown.tsx +++ b/src-web/components/SettingsDropdown.tsx @@ -1,13 +1,11 @@ import { open } from '@tauri-apps/plugin-shell'; import { useRef } from 'react'; -import { useActiveWorkspace } from '../hooks/useActiveWorkspace'; import { useAppInfo } from '../hooks/useAppInfo'; -import { useAppRoutes } from '../hooks/useAppRoutes'; import { useCheckForUpdates } from '../hooks/useCheckForUpdates'; import { useExportData } from '../hooks/useExportData'; import { useImportData } from '../hooks/useImportData'; import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent'; -import { invokeCmd } from '../lib/tauri'; +import { useOpenSettings } from '../hooks/useOpenSettings'; import type { DropdownRef } from './core/Dropdown'; import { Dropdown } from './core/Dropdown'; import { Icon } from './core/Icon'; @@ -22,19 +20,9 @@ export function SettingsDropdown() { const dropdownRef = useRef(null); const dialog = useDialog(); const checkForUpdates = useCheckForUpdates(); - const routes = useAppRoutes(); - const workspace = useActiveWorkspace(); + const openSettings = useOpenSettings(); - const showSettings = async () => { - if (!workspace) return; - await invokeCmd('cmd_new_nested_window', { - url: routes.paths.workspaceSettings({ workspaceId: workspace.id }), - label: 'settings', - title: 'Yaak Settings', - }); - }; - - useListenToTauriEvent('settings', showSettings); + useListenToTauriEvent('settings', () => openSettings.mutate()); return ( , - onSelect: showSettings, + onSelect: openSettings.mutate, }, { key: 'hotkeys', diff --git a/src-web/font-size.ts b/src-web/font-size.ts new file mode 100644 index 00000000..6f2f921d --- /dev/null +++ b/src-web/font-size.ts @@ -0,0 +1,15 @@ +// Listen for settings changes, the re-compute theme +import { listen } from '@tauri-apps/api/event'; +import type { ModelPayload } from './components/GlobalHooks'; +import { getSettings } from './lib/store'; + +function setFontSizeOnDocument(fontSize: number) { + document.documentElement.style.fontSize = `${fontSize}px`; +} + +listen('upserted_model', async (event) => { + if (event.payload.model.model !== 'settings') return; + setFontSizeOnDocument(event.payload.model.interfaceFontSize); +}).catch(console.error); + +getSettings().then((settings) => setFontSizeOnDocument(settings.interfaceFontSize)); diff --git a/src-web/hooks/useOpenSettings.ts b/src-web/hooks/useOpenSettings.ts new file mode 100644 index 00000000..62eb215d --- /dev/null +++ b/src-web/hooks/useOpenSettings.ts @@ -0,0 +1,22 @@ +import { useMutation } from '@tanstack/react-query'; +import { invokeCmd } from '../lib/tauri'; +import { useActiveWorkspace } from './useActiveWorkspace'; +import { useAppRoutes } from './useAppRoutes'; + +export function useOpenSettings() { + const routes = useAppRoutes(); + const workspace = useActiveWorkspace(); + return useMutation({ + mutationKey: ['open_settings'], + mutationFn: async () => { + if (workspace == null) return; + + await invokeCmd('cmd_new_child_window', { + url: routes.paths.workspaceSettings({ workspaceId: workspace.id }), + label: 'settings', + title: 'Yaak Settings', + innerSize: [600, 550], + }); + }, + }); +} diff --git a/src-web/hooks/useOpenWorkspace.ts b/src-web/hooks/useOpenWorkspace.ts index 50737cd1..a0bb7519 100644 --- a/src-web/hooks/useOpenWorkspace.ts +++ b/src-web/hooks/useOpenWorkspace.ts @@ -26,7 +26,7 @@ export function useOpenWorkspace() { requestId != null ? routes.paths.request({ ...baseArgs, requestId }) : routes.paths.workspace({ ...baseArgs }); - await invokeCmd('cmd_new_window', { url: path }); + await invokeCmd('cmd_new_main_window', { url: path }); } else { if (requestId != null) { routes.navigate('request', { ...baseArgs, requestId }); diff --git a/src-web/index.html b/src-web/index.html index 9dd320c5..8e2bf679 100644 --- a/src-web/index.html +++ b/src-web/index.html @@ -27,6 +27,7 @@
+ diff --git a/src-web/lib/tauri.ts b/src-web/lib/tauri.ts index efdd0e7f..f875da7a 100644 --- a/src-web/lib/tauri.ts +++ b/src-web/lib/tauri.ts @@ -51,8 +51,8 @@ type TauriCmd = | 'cmd_list_plugins' | 'cmd_list_workspaces' | 'cmd_metadata' - | 'cmd_new_nested_window' - | 'cmd_new_window' + | 'cmd_new_main_window' + | 'cmd_new_child_window' | 'cmd_parse_template' | 'cmd_plugin_info' | 'cmd_render_template'