-
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 Dependency Control system.
Scripts are written in standard C#. There are currently no plans to support other languages, but as with everything, that is subject to change.
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 Dependency Control 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. Unlike the script's, however, the method qualified name has a strict naming convention:
scriptQualifiedName+methodName. If your script ishankhill.calculatorand your method isdivide, the qualified name needs to behankhill.calculator+divide. - 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 = "hankhill.calculator.add"
},
new MethodInfo
[
DisplayName = "Subtract",
QualifiedName = "hankhill.calculator.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 - for hankhill.calculator+add, it'll give you add.
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 Solution, which is provided by the SolutionProvider. Sounds complicated, right? Fortunately, while there is a bit of boilerplate involved, the actual process is quite simple!
If we need the SolutionProvider, we just need to ask for it:
// The Locator and Solution Provider are in these imports:
using Ameko.Services;
using Holo.Providers;
var solutionProvider = ScriptServiceLocator.Get<ISolutionProvider>();And that's it! You now have access to the SolutionProvider. You may be wondering why we asked for an ISolutionProvider, 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 Ameko.Services;
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 ISolutionProvider _slnProvider;
public UppercaseMachine() : base(_info)
{
_slnProvider = ScriptServiceLocator.Get<ISolutionProvider>();
}
public override async Task<ExecutionResult> ExecuteAsync()
{
var currentWorkspace = _slnProvider.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 ISolutionProvider _slnProvider;This creates a variable for our Solution Provider that we can use elsewhere in our script. We'll need to initialize it in our constructor:
public UppercaseMachine() : base(_info)
{
_slnProvider = ScriptServiceLocator.Get<ISolutionProvider>();
}Here we initialize that variable. If we needed access to more services, we'd do that here.
var currentWorkspace = _slnProvider.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 commiting 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.
Now we want to tell Ameko we made a change. That's the job of the Workspace. We edited the text of the active event, so that's exactly what we commit:
currentWorkspace.Commit(activeEvent, CommitType.EventText);Easy peasy. If we were editing multiple events, we'd pass the list in:
currentWorkspace.Commit(listOfChangedEvents, CommitType.EventText);And of course, if we were doing something else, we'd pick the appropriate CommitType - maybe EventTime or EventAdd.
That'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!