-
Notifications
You must be signed in to change notification settings - Fork 5
Scripting
You can use scripts and libraries to enhance your Ameko experience. This page will cover the basics of writing your own scripts and libraries for Ameko's Package Manager system.
Scripts are written in standard C#. There are also Scriptlets, which are much more constrained, but may be suitable for simple automations.
All scripts are required to have two things: a self-identifying constructor, and a default entry point. Let's begin by looking at the "self-identification" part.
ModuleInfo contains basic information that identifies your script to Ameko:
class ModuleInfo
{
string DisplayName;
string QualifiedName;
MethodInfo[] Exports; // Optional
LogDisplay LogDisplay; // Optional
string? Submenu; // Optional
bool Headless; // Optional
}- Display Name is the name users of the script will see in the Package Manager and in the scripts menu.
-
Qualified Name is a unique namespaced identifier for the script. The most common format is
authorName.scriptName, but this is by no means required. - Exports describes the script's exported methods. We'll take a closer look at this later.
-
LogDisplay tells Ameko under which circumstances to display the script log window. By default, it's set to
LogDisplay.OnError, but there's also options forEphemeralandForced. - Submenu lets you define a submenu for the script. By default, scripts are placed in the root menu.
- Headless allows you to avoid having the default entry point in the menu if there are exported methods.
Now that we understand ModuleInfo, let's take a look at what it takes to say "Hello, World!"
This Hello World example represents the simplest possible script:
using System.Threading.Tasks;
using Holo.Scripting;
using Holo.Scripting.Models;
public class HelloWorld : HoloScript
{
public HelloWorld() : base(
new ModuleInfo
{
DisplayName = "Hello World",
QualifiedName = "example.helloWorld"
})
{ }
public override async Task<ExecutionResult> ExecuteAsync()
{
Logger.Info("Hello, World!");
return ExecutionResult.Success;
}
}using System.Threading.Tasks;
using Holo.Scripting;
using Holo.Scripting.Models;These import statements are required for all scripts. The Tasks import provides access to asynchronous execution, and the Holo.Scripting imports are for the script data itself.
public class HelloWorld : HoloScript
{
public HelloWorld() : base(
new ModuleInfo
{
DisplayName = "Hello World",
QualifiedName = "example.helloWorld"
})
{ }
}Create a class called HelloWorld that derives from HoloScript.
public override async Task<ExecutionResult> ExecuteAsync()
{
Logger.Info("Hello, World!");
return ExecutionResult.Success;
}ExecuteAsync is the main entry point for scripts. When the user executes a script, this function is called. It returns an ExecutionResult, which we'll take a closer look at later. Here, we're just invoking the Logger to print "Hello, World!" to the log and returning success.
Next, we'll look at your script's (optional) second entry point.
There's a good chance you want your script to do more than one thing. Maybe you want a "Do x on all lines" and a "Do x on selected lines". This is where those Exports come into play.
class MethodInfo
{
string DisplayName;
string QualifiedName;
string? Submenu; // Optional
}- Display Name is again, the display name of the method - "Add", for example.
- Qualified Name is a unique identifier for the method.
- Submenu allows you to put the method in a submenu.
Now, let's make a calculator that can calculate whatever you want, as long as it's adding and subtracting the numbers 5 and 10.
using System.Threading.Tasks;
using Holo.Scripting;
using Holo.Scripting.Models;
public class HanksCalculator : HoloScript
{
private static readonly ModuleInfo _info = new ModuleInfo
{
DisplayName = "Hank's Calculator",
QualifiedName = "hankhill.calculator",
Exports = [
new MethodInfo
{
DisplayName = "Add",
QualifiedName = "add"
},
new MethodInfo
[
DisplayName = "Subtract",
QualifiedName = "subtract"
]
],
Headless = true
};
public HanksCalculator : base(_info) { }
public override async Task<ExecutionResult> ExecuteAsync()
{
return ExecutionResult.Success; // Nothing here!
}
public override async Task<ExecutionResult> ExecuteAsync(string methodName)
{
switch (methodName)
{
case "add":
Logger.Info(5 + 10);
break;
case "subtract":
Logger.Info(5 - 10);
break;
default:
Logger.Error($"Unknown method {methodName}");
break;
}
return ExecutionResult.Success;
}
}Well, there's a bit more to see here! First, you'll notice I moved the ModuleInfo initialization out of the constructor:
private readonly ModuleInfo _info = new ModuleInfo { ... }
public HanksCalculator : base(_info) { }This is just for readability purposes, and has no effect on the script itself. By the way, a common convention in C#-land is to prefix private members with an underscore, hence the name _info. Feel free to follow the convention if you wish :)
Note that we've set Headless = true. This will prevent the default entry point from being listed in the scripts menu. (We're only allowed to use it because we have exported methods.) We still need to have an implementation of the default entry point, because it can still be called - if a user binds a key to it, for example. Here, it's not used at all, so we just return.
Now, onto the main event:
public override async Task<ExecutionResult> ExecuteAsync(string methodName)This is the entry point for methods. Ameko will call this method with the provided function name.
What you do with this info is up to you. A common paradigm is to use a switch block, as seen in the example:
switch (methodName)
{
case "add":
Logger.Info(5 + 10);
break;
case "subtract":
Logger.Info(5 - 10);
break;
default:
Logger.Error($"Unknown method {methodName}");
break;
}The switch block executes the section with the appropriate label, or default if none of them match. Because this is a simple example, all options are self-contained, but for larger scripts, you'll probably want to split the options out into their own functions:
switch (methodName)
// In the execute method
case "add":
Add();
break;
// Outside the method, in the class
private void Add() { ... }Now we know the basics of setting up a script, so let's actually do something useful!
It's time to get to the things you actually want to do! Let's make a script that modifies the currently-selected event.
The ScriptServiceLocator is what Ameko uses to expose internal functionality to scripts. For example, to get the currently-open Workspace (which contains the ASS Document), you need access to the open Project, which is provided by the ProjectProvider. Sounds complicated, right? Fortunately, while there is a bit of boilerplate involved, the actual process is quite simple!
If we need the ProjectProvider, we just need to ask for it:
// The Locator and Project Provider are in this imports:
using Holo.Providers;
var projectProvider = ScriptServiceLocator.Get<IProjectProvider>();And that's it! You now have access to the ProjectProvider. You may be wondering why we asked for an IProjectProvider, and that's because Ameko uses Dependency Injection under the hood. The only part relevant to scripting is that you'll need to request an interface, hence the I.
The ScriptServiceLocator is accessible from anywhere, but it's probably best to isolate its use it to your script's constructor (mostly for readability's sake). For everyone's sanity, never use the ScriptServiceLocator in a loop.
Finally, a script that does something! This script will naïvely make the the text content of the selected event UPPERCASE (without regard for tags).
using System.Threading.Tasks;
using Holo.Providers;
using Holo.Scripting;
using Holo.Scripting.Models;
public class UppercaseMachine : HoloScript
{
private static readonly ModuleInfo _info = new ModuleInfo
{
DisplayName = "Uppercase Machine",
QualifiedName = "example.uppercaseMachine"
};
private readonly IProjectProvider _prjProvider;
public UppercaseMachine() : base(_info)
{
_prjProvider = ScriptServiceLocator.Get<IProjectProvider>();
}
public override async Task<ExecutionResult> ExecuteAsync()
{
var currentWorkspace = _prjProvider.Current.WorkingSpace;
var activeEvent = currentWorkspace?.SelectionManager.ActiveEvent;
if (activeEvent is null)
return new ExecutionResult
{
Status = ExecutionStatus.Failure,
Message = "No event selected!"
};
activeEvent.Text = activeEvent.Text.ToUpper();
return ExecutionResult.Success;
}
}Starting from the top, remember your imports!
private readonly IProjectProvider _prjProvider;This creates a variable for our Project Provider that we can use elsewhere in our script. We'll need to initialize it in our constructor:
public UppercaseMachine() : base(_info)
{
_prjProvider = ScriptServiceLocator.Get<IProjectProvider>();
}Here we initialize that variable. If we needed access to more services, we'd do that here.
var currentWorkspace = _prjProvider.Current.WorkingSpace;First step in getting access to the selected ("active") event: Getting the current workspace, or WorkingSpace. The WorkingSpace can be null (if there's no file open), which is why the next line has a question mark - the "conditional access operator".
var activeEvent = currentWorkspace?.SelectionManager.ActiveEvent;Finally, we have the active event! Or do we? Just as the WorkingSpace might be null, the ActiveEvent might be null. Let's make sure we actually have an event before proceeding, less we throw a NullReferenceException (no bueno!)
if (activeEvent is null)
return new ExecutionResult
{
Status = ExecutionStatus.Failure,
Message = "No event selected!"
};Here we check if the active event is null, and if it is, we end the execution, returning a failing result. If your LogDisplay is set to LogDisplay.OnError, the script log window will open.
Now that we know we have an event to work with, we can UPPERCASE its text:
activeEvent.Text = activeEvent.Text.ToUpper();And that's it! That event's text is now uppercase. Unfortunately for the user, however, they have no way of undoing that...
Ameko is constantly committing changes made by the user to history. This is why you're able to undo and redo things. However, Ameko has no way to commit changes made by scripts automatically - you must do it yourself. Fortunately, there's not that much you need to do!
Let's consider the previous example:
activeEvent.Text = activeEvent.Text.ToUpper();Here, we set the text property of the currently-selected line to be UPPERCASE.
Because we're modifying an existing line, we want to tell Ameko to track the event we're modifying:
currentWorkspace.Document.HistoryManager.BeginTransaction(activeEvent);Once we've made the change, we need to commit it to history. We edited the text of the active event, so that's exactly what we commit:
currentWorkspace.Commit(activeEvent, ChangeType.Modify);Easy peasy. If we were editing multiple events, we'd pass a list in:
currentWorkspace.Document.HistoryManager.BeginTransaction(listOfEventsToChange);
// Do change
currentWorkspace.Commit(listOfChangedEvents, ChangeType.Modify);And of course, if we were doing something else, we'd pick the appropriate ChangeType - like Add or Remove.
Note that if you're only adding/removing events, you don't need to begin a transaction - that's only needed if you're modifying events' text.
Using libraries is relatively straightforward. There's two library sources, and they're imported in similar ways:
Libraries can be imported from the Package Manager using the //css_include directive, which takes in the filename of the library you want to import. The directive should be placed before your script's class begins. For example, here we import a library with the qualified name mario.shapes. Package Manager libraries are saved to the path qualifiedName.lib.cs, so we will specify mario.shapes.lib.cs.
using Holo;
//css_include mario.shapes.lib.cs
public class MyCoolScript : HoloScriptWhen publishing scripts that use Package Manager libraries, remember to list your dependencies!
Another option for library imports is NuGet, C#'s package manager. You can import any library that's on NuGet using the //css_nuget directive, just like the //css_include directive for Package Manager scripts. Note that //css_nuget uses dotnet.exe to download the packages, which I believe should be installed by default with the runtime needed to run Ameko in the first place?
In either case, you can also publish libraries to NuGet if it needs to work cross-script for some reason. (PkgMan libraries have per-script instances.)
using Holo;
// Specifying a version is optional
//css_nuget -ver:2.1.0 OpenAI
public class MyCoolScript : HoloScriptEventually, you're going to want to display some stuff to the user. Maybe have some buttons or a MessageBox or whatnot. Because scripts are hosted by Ameko (rather than Holo), you get full access to Avalonia!
There's a lot of great examples of using Avalonia without AXAML on Stevens Miller's examples repo, but we'll take a quick look at a couple simple ones here.
I'm going to stop showing the full source here, instead showing just the important parts.
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Layout;
// Inside your script class
public override async Task<ExecutionResult> ExecuteAsync()
{
var count = 0;
var win = new Window
{
Title = "Button Window",
Width = 256,
Height = 256,
WindowStartupLocation = WindowStartupLocation.CenterScreen,
};
var label = new Label { Content = "Click the button to begin", };
var button = new Button
{
Content = "Click me!",
FontSize = 24,
HorizontalAlignment = HorizontalAlignment.Center,
};
button.Click += (_, _) =>
{
count++;
label.Content = $"Button clicks so far: {count}";
};
var stack = new StackPanel();
stack.Children.Add(label);
stack.Children.Add(button);
win.Content = stack;
win.Show();
return ExecutionResult.Success;
}The imports should be rather self-explanatory.
First, we initialize a new Avalonia window, label, and button with the given parameters. Then, we add a callback to the button's Click event:
button.Click += (_, _) =>
{
count++;
label.Content = $"Button clicks so far: {count}";
};This is an anonymous callback function. If we were doing something more complex, we might want to split it out into a full standalone function:
button.Click += ClickCallback;
// Elsewhere
private void ClickCallback(object? sender, RoutedEventArgs e) { ... }Of course, if you do that, you'll need to make count a global variable not scoped to the execution. So there's benefits and tradeoffs.
var stack = new StackPanel();
stack.Children.Add(label);
stack.Children.Add(button);
win.Content = stack;Here we create a StackPanel, add the label and button to it, and make the panel the window's content. Windows can only have 1 child, so we need a multi-child capable panel, like the StackPanel, to contain our things.
win.Show();Finally, we show the window. In real scripts, you'd probably want to use ShowDialog(TopLevel), which takes in a reference to the main window. I plan on providing a service that provides the TopLevel to you.
You'll likely want to take a look at the Controls Reference to see exactly what you can put in your windows.
Now, let's show a MessageBox to the user!
using Holo.Models;
using Holo.Providers;
// using Material.Icons; // ← If you need a non-Info box
private readonly IMessageBoxService _msgBoxSvc = ScriptServiceLocator.Get<IMessageBoxService>();
// In ExecuteAsync
var userInput = string.Empty;
var inputBox = new TextBox { Watermark = "Input some text here!" };
button.Click += async (_, _) =>
{
userInput = inputBox.Text;
_ = await _msgBoxSvc.ShowAsync("Box Title", userInput, MessageBoxButtons.Ok);
};We've replaced the label with a TextBox! Then, when we click the button, we save the text from the TextBox and use it to build a MessageBox. Note that the callback is now async.
In this example, we're throwing away the MessageBox's result:
_ = await _msgBoxSvc.ShowAsync(...);If we wre using multiple buttons, say MessageBoxButtons.OkCancel, we would want to get that result and act upon it:
var result = await _msgBoxSvc.ShowAsync(...)
switch (result) { ... }Or something along those lines.
You can also create InputBoxes:
var result = await _msgBoxSvc.ShowInputAsync("Test", "What's your name?", string.Empty, MessageBoxButtons.Ok);
if (result is null) return; // or make some default values
var (buttons, userInput) = result; // Get the clicked button and the text the user inputtedThat's all I have for now! Come back later and there might be new goodies to read about! Or maybe the whole page will be different - anything's possible!