diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 5a04384b5..9480d37a3 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -95,6 +95,7 @@ extern "C" { * | `GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES`| `GhosttyTerminalDeviceAttributesFn`| Device attributes query (CSI c / > c / = c)| * | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE` | `GhosttyTerminalClipboardWriteFn` | Clipboard write via OSC 52 / OSC 1337 | * | `GHOSTTY_TERMINAL_OPT_DESKTOP_NOTIFICATION`| `GhosttyTerminalDesktopNotificationFn` | Desktop notification via OSC 9 / OSC 777 | + * | `GHOSTTY_TERMINAL_OPT_PROGRESS_REPORT` | `GhosttyTerminalProgressReportFn` | Progress report via OSC 9;4 | * * ### Defining a write_pty callback * @snippet c-vt-effects/src/main.c effects-write-pty @@ -484,6 +485,64 @@ typedef void (*GhosttyTerminalDesktopNotificationFn)( void* userdata, const GhosttyTerminalDesktopNotification* notification); +/** + * State of a terminal progress report. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Remove any visible progress indication. */ + GHOSTTY_TERMINAL_PROGRESS_STATE_REMOVE = 0, + + /** Show determinate progress. */ + GHOSTTY_TERMINAL_PROGRESS_STATE_SET = 1, + + /** Show a failed progress state. */ + GHOSTTY_TERMINAL_PROGRESS_STATE_ERROR = 2, + + /** Show indeterminate progress. */ + GHOSTTY_TERMINAL_PROGRESS_STATE_INDETERMINATE = 3, + + /** Show paused progress. */ + GHOSTTY_TERMINAL_PROGRESS_STATE_PAUSE = 4, + GHOSTTY_TERMINAL_PROGRESS_STATE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyTerminalProgressState; + +/** + * A progress report emitted by the running program. + * + * This is a sized struct. The callback must only access fields present in the + * size reported by `size`. + * + * @ingroup terminal + */ +typedef struct { + /** Size of this struct in bytes. */ + size_t size; + + /** Literal progress state reported by the running program. */ + GhosttyTerminalProgressState state; + + /** Progress percentage from 0 through 100, or -1 when omitted. */ + int8_t progress; +} GhosttyTerminalProgressReport; + +/** + * Callback function type for progress reports. + * + * Called synchronously when the terminal receives OSC 9;4. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param report Borrowed progress report + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalProgressReportFn)( + GhosttyTerminal terminal, + void* userdata, + const GhosttyTerminalProgressReport* report); + /** * Callback function type for color scheme queries (CSI ? 996 n). * @@ -960,6 +1019,14 @@ typedef enum GHOSTTY_ENUM_TYPED { * Input type: GhosttyTerminalDesktopNotificationFn */ GHOSTTY_TERMINAL_OPT_DESKTOP_NOTIFICATION = 29, + + /** + * Callback invoked when the running program reports progress via OSC 9;4. + * Set to NULL to ignore progress reports. + * + * Input type: GhosttyTerminalProgressReportFn + */ + GHOSTTY_TERMINAL_OPT_PROGRESS_REPORT = 30, GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalOption; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index bdc7af7f6..f900d1c0d 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -6,6 +6,7 @@ const lib = @import("../lib.zig"); const CAllocator = lib.alloc.Allocator; pub const ZigTerminal = @import("../Terminal.zig"); const Action = @import("../stream.zig").Action; +const osc = @import("../osc.zig"); const Stream = @import("../stream_terminal.zig").Stream; const Screen = @import("../Screen.zig"); const ScreenSet = @import("../ScreenSet.zig"); @@ -91,6 +92,26 @@ pub const DesktopNotification = extern struct { body: lib.String, }; +/// State of a terminal progress report. +/// +/// C: GhosttyTerminalProgressState +pub const ProgressState = enum(c_int) { + remove = 0, + set = 1, + @"error" = 2, + indeterminate = 3, + pause = 4, +}; + +/// A progress report emitted by the running program. +/// +/// C: GhosttyTerminalProgressReport +pub const ProgressReport = extern struct { + size: usize, + state: ProgressState, + progress: i8, +}; + /// C callback state for terminal effects. Trampolines are always /// installed on the stream handler; they check these fields and /// no-op when the corresponding callback is null. @@ -105,6 +126,7 @@ const Effects = struct { xtversion: ?XtversionFn = null, title_changed: ?TitleChangedFn = null, pwd_changed: ?PwdChangedFn = null, + progress_report: ?ProgressReportFn = null, size_cb: ?SizeFn = null, clipboard_write: ?ClipboardWriteFn = null, @@ -151,6 +173,9 @@ const Effects = struct { /// C function pointer type for the pwd_changed callback. pub const PwdChangedFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void; + /// C function pointer type for the progress_report callback. + pub const ProgressReportFn = *const fn (Terminal, ?*anyopaque, *const ProgressReport) callconv(lib.calling_conv) void; + /// C function pointer type for the size callback. /// Returns true and fills out_size if size is available, /// or returns false to silently ignore the query. @@ -320,6 +345,20 @@ const Effects = struct { func(@ptrCast(wrapper), wrapper.effects.userdata); } + fn progressReportTrampoline( + handler: *Handler, + report: osc.Command.ProgressReport, + ) void { + const wrapper = TerminalWrapper.fromHandler(handler); + const func = wrapper.effects.progress_report orelse return; + const c_report: ProgressReport = .{ + .size = @sizeOf(ProgressReport), + .state = @enumFromInt(@intFromEnum(report.state)), + .progress = if (report.progress) |value| @intCast(value) else -1, + }; + func(@ptrCast(wrapper), wrapper.effects.userdata, &c_report); + } + fn sizeTrampoline(handler: *Handler) ?size_report.Size { const wrapper = TerminalWrapper.fromHandler(handler); const func = wrapper.effects.size_cb orelse return null; @@ -417,6 +456,7 @@ fn new_( .xtversion = &Effects.xtversionTrampoline, .title_changed = &Effects.titleChangedTrampoline, .pwd_changed = &Effects.pwdChangedTrampoline, + .progress_report = &Effects.progressReportTrampoline, .size = &Effects.sizeTrampoline, .clipboard_write = &Effects.clipboardWriteTrampoline, }; @@ -494,6 +534,7 @@ pub const Option = enum(c_int) { scrollback_max_bytes = 27, scrollback_max_lines = 28, desktop_notification = 29, + progress_report = 30, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { @@ -508,6 +549,7 @@ pub const Option = enum(c_int) { .xtversion => ?Effects.XtversionFn, .title_changed => ?Effects.TitleChangedFn, .pwd_changed => ?Effects.PwdChangedFn, + .progress_report => ?Effects.ProgressReportFn, .size_cb => ?Effects.SizeFn, .clipboard_write => ?Effects.ClipboardWriteFn, .title, .pwd => ?*const lib.String, @@ -570,6 +612,7 @@ fn setTyped( .xtversion => wrapper.effects.xtversion = value, .title_changed => wrapper.effects.title_changed = value, .pwd_changed => wrapper.effects.pwd_changed = value, + .progress_report => wrapper.effects.progress_report = value, .size_cb => wrapper.effects.size_cb = value, .clipboard_write => wrapper.effects.clipboard_write = value, .title => { @@ -2957,6 +3000,82 @@ test "set desktop_notification callback" { try testing.expectEqual(@as(usize, 2), S.count); } +test "set progress_report callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var count: usize = 0; + var last_userdata: ?*anyopaque = null; + var last_size: usize = 0; + var last_state: ProgressState = .remove; + var last_progress: i8 = -1; + + fn progressReport( + _: Terminal, + ud: ?*anyopaque, + report: *const ProgressReport, + ) callconv(lib.calling_conv) void { + count += 1; + last_userdata = ud; + last_size = report.size; + last_state = report.state; + last_progress = report.progress; + } + }; + S.count = 0; + S.last_userdata = null; + S.last_size = 0; + S.last_state = .remove; + S.last_progress = -1; + + var sentinel: u8 = 100; + try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel))); + try testing.expectEqual(Result.success, set( + t, + .progress_report, + @ptrCast(&S.progressReport), + )); + + const cases = [_]struct { + sequence: []const u8, + state: ProgressState, + progress: i8, + }{ + .{ .sequence = "\x1B]9;4;0;\x1B\\", .state = .remove, .progress = -1 }, + .{ .sequence = "\x1B]9;4;1;42\x07", .state = .set, .progress = 42 }, + .{ .sequence = "\x1B]9;4;2;7\x1B\\", .state = .@"error", .progress = 7 }, + .{ .sequence = "\x1B]9;4;3\x1B\\", .state = .indeterminate, .progress = -1 }, + .{ .sequence = "\x1B]9;4;4;75\x1B\\", .state = .pause, .progress = 75 }, + }; + + for (cases, 1..) |case, expected_count| { + const midpoint = case.sequence.len / 2; + vt_write(t, case.sequence.ptr, midpoint); + try testing.expectEqual(expected_count - 1, S.count); + vt_write(t, case.sequence.ptr + midpoint, case.sequence.len - midpoint); + try testing.expectEqual(expected_count, S.count); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata); + try testing.expectEqual(@sizeOf(ProgressReport), S.last_size); + try testing.expectEqual(case.state, S.last_state); + try testing.expectEqual(case.progress, S.last_progress); + } + + try testing.expectEqual(Result.success, set(t, .progress_report, null)); + const ignored = "\x1B]9;4;1;90\x1B\\"; + vt_write(t, ignored, ignored.len); + try testing.expectEqual(@as(usize, cases.len), S.count); +} + test "set pwd_changed callback" { var t: Terminal = null; try testing.expectEqual(Result.success, new( diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index 04d4f5011..60f97c342 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -69,6 +69,7 @@ pub const structs: std.StaticStringMap(StructInfo) = structs: { .{ "GhosttyStyle", StructInfo.init(style_c.Style) }, .{ "GhosttyStyleColor", StructInfo.init(style_c.Color) }, .{ "GhosttyTerminalDesktopNotification", StructInfo.init(terminal.DesktopNotification) }, + .{ "GhosttyTerminalProgressReport", StructInfo.init(terminal.ProgressReport) }, .{ "GhosttyTerminalScrollbar", StructInfo.init(terminal.TerminalScrollbar) }, .{ "GhosttyTerminalScrollViewport", StructInfo.init(terminal.ScrollViewport) }, }); diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index fbe133bb1..89f48b417 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -109,6 +109,9 @@ pub const Handler = struct { /// handler.terminal.getPwd(). pwd_changed: ?*const fn (*Handler) void, + /// Called when the running program reports progress via OSC 9;4. + progress_report: ?*const fn (*Handler, osc.Command.ProgressReport) void, + /// Called when the running program writes to a clipboard. The write /// has a normalized destination and one or more decoded MIME /// representations. All request, MIME, and data memory is borrowed @@ -139,6 +142,7 @@ pub const Handler = struct { .desktop_notification = null, .device_attributes = null, .enquiry = null, + .progress_report = null, .size = null, .title_changed = null, .pwd_changed = null, @@ -330,6 +334,7 @@ pub const Handler = struct { .size_report => self.reportSize(value), .window_title => try self.windowTitle(value.title), .report_pwd => try self.reportPwd(value.url), + .progress_report => self.progressReport(value), .xtversion => self.reportXtversion(), .clipboard_contents => self.clipboardContents( value.kind, @@ -344,7 +349,6 @@ pub const Handler = struct { .dcs_unhook => try self.dcsUnhook(), // Have no terminal-modifying effect - .progress_report, .title_push, .title_pop, => {}, @@ -410,6 +414,11 @@ pub const Handler = struct { func(self, notification); } + fn progressReport(self: *Handler, report: osc.Command.ProgressReport) void { + const func = self.effects.progress_report orelse return; + func(self, report); + } + fn clipboardContents(self: *Handler, kind: u8, data: []const u8) !void { const func = self.effects.clipboard_write orelse return; @@ -2106,6 +2115,62 @@ test "desktop_notification effect callback" { try testing.expectEqualStrings("Needs attention", S.last_body); } +test "progress_report effect callback" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + // A null callback (the default readonly effects) silently ignores reports. + { + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + s.nextSlice("\x1B]9;4;1;25\x1B\\"); + } + + const S = struct { + var count: usize = 0; + var last_state: osc.Command.ProgressReport.State = .remove; + var last_progress: ?u8 = null; + + fn progressReport(_: *Handler, report: osc.Command.ProgressReport) void { + count += 1; + last_state = report.state; + last_progress = report.progress; + } + }; + S.count = 0; + S.last_state = .remove; + S.last_progress = null; + + var handler: Handler = .init(&t); + handler.effects.progress_report = &S.progressReport; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + const cases = [_]struct { + sequence: []const u8, + state: osc.Command.ProgressReport.State, + progress: ?u8, + }{ + .{ .sequence = "\x1B]9;4;0;\x1B\\", .state = .remove, .progress = null }, + .{ .sequence = "\x1B]9;4;1;42\x07", .state = .set, .progress = 42 }, + .{ .sequence = "\x1B]9;4;2;7\x1B\\", .state = .@"error", .progress = 7 }, + .{ .sequence = "\x1B]9;4;3\x1B\\", .state = .indeterminate, .progress = null }, + .{ .sequence = "\x1B]9;4;4;75\x1B\\", .state = .pause, .progress = 75 }, + }; + + for (cases, 1..) |case, expected_count| { + // Split each sequence to verify parsing survives PTY read boundaries. + const midpoint = case.sequence.len / 2; + s.nextSlice(case.sequence[0..midpoint]); + try testing.expectEqual(expected_count - 1, S.count); + s.nextSlice(case.sequence[midpoint..]); + try testing.expectEqual(expected_count, S.count); + try testing.expectEqual(case.state, S.last_state); + try testing.expectEqual(case.progress, S.last_progress); + } +} + test "clipboard_write effect callback" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator);