diff --git a/packages/scratch-gui/src/components/ruby-toolbar/messages.js b/packages/scratch-gui/src/components/ruby-toolbar/messages.js
index 0fc5f9c9c9f..a9913cd1c5a 100644
--- a/packages/scratch-gui/src/components/ruby-toolbar/messages.js
+++ b/packages/scratch-gui/src/components/ruby-toolbar/messages.js
@@ -88,6 +88,11 @@ const messages = defineMessages({
defaultMessage: 'Save Ruby script',
description: 'Label for save Ruby script menu item'
},
+ insertClass: {
+ id: 'gui.rubyToolbar.insertClass',
+ defaultMessage: 'Insert class',
+ description: 'Label for insert class menu item in More menu'
+ },
stage: {
id: 'gui.rubyToolbar.stage',
defaultMessage: 'Stage',
diff --git a/packages/scratch-gui/src/components/ruby-toolbar/ruby-toolbar.jsx b/packages/scratch-gui/src/components/ruby-toolbar/ruby-toolbar.jsx
index f30ad985021..e97cb812595 100644
--- a/packages/scratch-gui/src/components/ruby-toolbar/ruby-toolbar.jsx
+++ b/packages/scratch-gui/src/components/ruby-toolbar/ruby-toolbar.jsx
@@ -83,6 +83,12 @@ const RubyToolbar = props => {
setShowMoreMenu(prev => !prev);
}, []);
+ const handleInsertClass = useCallback(() => {
+ setShowMoreMenu(false);
+ if (props.onDismissBubble) props.onDismissBubble();
+ if (props.onInsertClass) props.onInsertClass();
+ }, [props]);
+
const handleOpenAutoCorrectSettings = useCallback(() => {
setShowMoreMenu(false);
if (props.onOpenAutoCorrectSettings) props.onOpenAutoCorrectSettings();
@@ -246,6 +252,13 @@ const RubyToolbar = props => {
/>
{intl.formatMessage(messages.saveRubyScript)}
+
+ {'{ }'}
+ {intl.formatMessage(messages.insertClass)}
+
{
}
}, [getSaveToComputerHandler]);
+ const handleInsertClass = useCallback(() => {
+ if (!editorRef.current) return;
+ const code = editorRef.current.getValue() || '';
+ const target = vm.editingTarget;
+ if (!target) return;
+ const wrapped = wrapCurrentCodeWithClass(code, target);
+ if (wrapped === null) return; // class already exists
+ const model = editorRef.current.getModel();
+ const fullRange = model.getFullModelRange();
+ editorRef.current.executeEdits('insertClass', [{
+ range: fullRange,
+ text: wrapped
+ }]);
+ }, [vm]);
+
const handleAISaveFinished = useCallback(() => {
onSetAiSaveStatus('saved');
setTimeout(() => {
@@ -806,6 +822,7 @@ const RubyTab = props => {
editorRef={editorRef.current}
onSelectTarget={handleSelectTarget}
onDownload={handleDownload}
+ onInsertClass={handleInsertClass}
onExecuteLine={handleExecuteLine}
onDismissBubble={handleDismissBubbleStable}
onOpenGeminiModal={onOpenGeminiModal}
diff --git a/packages/scratch-gui/src/lib/insert-class.js b/packages/scratch-gui/src/lib/insert-class.js
new file mode 100644
index 00000000000..095ee0bf439
--- /dev/null
+++ b/packages/scratch-gui/src/lib/insert-class.js
@@ -0,0 +1,195 @@
+// === Smalruby: This file is Smalruby-specific (class insertion utility for Ruby tab) ===
+
+const INDENT = ' ';
+
+/**
+ * Quote a string for Ruby output (double-quoted).
+ * @param {string} string The string to quote.
+ * @returns {string} The quoted string.
+ */
+const quote = string => {
+ const escapeChars = {
+ '\\': '\\\\',
+ '"': '\\"',
+ '\n': '\\n',
+ '\t': '\\t'
+ };
+ const s = String(string);
+ const sb = ['"'];
+ for (let i = 0; i < s.length; i++) {
+ const ch = s.charAt(i);
+ sb.push(escapeChars[ch] || ch);
+ }
+ sb.push('"');
+ return sb.join('');
+};
+
+/**
+ * Prepend a prefix onto each line of code.
+ * @param {string} text The lines of code.
+ * @param {string} prefix The common prefix.
+ * @returns {string} The prefixed lines of code.
+ */
+const prefixLines = (text, prefix = INDENT) =>
+ prefix + text.replace(/(?!\n$)\n/g, `\n${prefix}`);
+
+/**
+ * Check if a string is a valid Ruby constant name (class name).
+ * @param {string} name The name to check.
+ * @returns {boolean} Whether the name is a valid class name.
+ */
+const isValidClassName = name => /^[A-Z][\p{L}\p{N}_]*$/u.test(name);
+
+/**
+ * Check if code already contains a class definition.
+ * Matches lines starting with optional whitespace followed by "class ".
+ * Ignores comments (lines starting with #).
+ * @param {string} code The code to check.
+ * @returns {boolean} Whether the code contains a class definition.
+ */
+const hasClassDefinition = code => {
+ const lines = code.split('\n');
+ for (const line of lines) {
+ const trimmed = line.trimStart();
+ if (trimmed.startsWith('#')) continue;
+ if (/^class\s/.test(trimmed)) return true;
+ }
+ return false;
+};
+
+/**
+ * Generate set_xxx lines for sprite attributes that differ from defaults.
+ * @param {object} target The sprite target.
+ * @param {string[]} setLines Array to push set lines into.
+ */
+const generateSetXxx = (target, setLines) => {
+ if (target.x !== 0) {
+ setLines.push(`set_x ${target.x}`);
+ }
+ if (target.y !== 0) {
+ setLines.push(`set_y ${target.y}`);
+ }
+ if (target.direction !== 90) {
+ setLines.push(`set_direction ${target.direction}`);
+ }
+ if (!target.visible) {
+ setLines.push(`set_visible ${!!target.visible}`);
+ }
+ if (target.size !== 100) {
+ setLines.push(`set_size ${target.size}`);
+ }
+ if (target.currentCostume > 0) {
+ setLines.push(`set_current_costume ${target.currentCostume + 1}`);
+ }
+ if (target.rotationStyle !== 'all around') {
+ setLines.push(`set_rotation_style ${quote(target.rotationStyle)}`);
+ }
+};
+
+/**
+ * Generate set_xxx lines for stage attributes that differ from defaults.
+ * @param {object} target The stage target.
+ * @param {string[]} setLines Array to push set lines into.
+ */
+const generateStageSetXxx = (target, setLines) => {
+ if (target.currentCostume > 0) {
+ setLines.push(`set_current_backdrop ${target.currentCostume + 1}`);
+ }
+};
+
+/**
+ * Wrap existing code with a class definition.
+ * Returns null if code already contains a class definition.
+ * This replicates the logic of RubyGenerator._wrapWithClass with forFileOutput=true.
+ * @param {string} code The current editor code.
+ * @param {object} target The editing target (vm.editingTarget).
+ * @returns {string|null} The wrapped code, or null if class already exists.
+ */
+const wrapCurrentCodeWithClass = (code, target) => {
+ if (hasClassDefinition(code)) {
+ return null;
+ }
+
+ const isStage = target.isStage;
+ let className;
+ const setLines = [];
+
+ if (isStage) {
+ className = 'Stage';
+ if (target.sprite.name !== 'Stage') {
+ setLines.push(`set_name ${quote(target.sprite.name)}`);
+ }
+ } else {
+ const spriteName = target.sprite.name;
+ if (isValidClassName(spriteName)) {
+ className = spriteName;
+ } else {
+ const sprites = target.runtime.targets.filter(t => !t.isStage);
+ const index = sprites.indexOf(target) + 1;
+ className = `Sprite${index}`;
+ setLines.push(`set_name ${quote(spriteName)}`);
+ }
+ }
+
+ // Generate set_xxx for non-default attributes
+ if (isStage) {
+ generateStageSetXxx(target, setLines);
+ } else {
+ generateSetXxx(target, setLines);
+ }
+
+ let setCode = '';
+ if (setLines.length > 0) {
+ setCode = setLines.map(line => `${INDENT}${line}\n`).join('');
+ }
+
+ // Split code into hat/def blocks vs other top-level code
+ let bodyCode = code;
+ let outsideCode = '';
+
+ if (bodyCode.length > 0) {
+ const sections = bodyCode.split(/\n\n/);
+ const insideSections = [];
+ const outsideSections = [];
+ for (const section of sections) {
+ const trimmed = section.trim();
+ if (trimmed.length === 0) continue;
+ if (/^self\.when\(/.test(trimmed) ||
+ /^when_/.test(trimmed) ||
+ /^\w+\.when[\s_(]/.test(trimmed) ||
+ /^def /.test(trimmed)) {
+ insideSections.push(section);
+ } else {
+ outsideSections.push(section);
+ }
+ }
+ bodyCode = insideSections.join('\n\n');
+ if (bodyCode.length > 0 && !bodyCode.endsWith('\n')) {
+ bodyCode += '\n';
+ }
+ if (outsideSections.length > 0) {
+ const commented = outsideSections
+ .join('\n\n')
+ .split('\n')
+ .map(line => (line.trim().length > 0 ? `# ${line}` : ''))
+ .join('\n');
+ outsideCode = `\n${commented}\n`;
+ }
+ }
+
+ if (bodyCode.length > 0) {
+ bodyCode = prefixLines(bodyCode, INDENT);
+ }
+
+ const separator = setCode.length > 0 && bodyCode.length > 0 ? '\n' : '';
+ const inheritance = isStage ? '' : ' < ::Smalruby3::Sprite';
+ let result = `class ${className}${inheritance}\n${setCode}${separator}${bodyCode}end\n`;
+
+ if (outsideCode.length > 0) {
+ result += outsideCode;
+ }
+
+ return result;
+};
+
+export {wrapCurrentCodeWithClass, hasClassDefinition};
diff --git a/packages/scratch-gui/src/locales/ja-Hira.js b/packages/scratch-gui/src/locales/ja-Hira.js
index 2cd0b20b89d..fc9911bed9c 100644
--- a/packages/scratch-gui/src/locales/ja-Hira.js
+++ b/packages/scratch-gui/src/locales/ja-Hira.js
@@ -392,6 +392,7 @@ export default {
'gui.rubyToolbar.moreOptions': 'そのたのオプション',
'gui.rubyToolbar.autoCorrectSettings': 'じどうちかんせってい',
'gui.rubyToolbar.saveRubyScript': 'ルビースクリプトをほぞん',
+ 'gui.rubyToolbar.insertClass': 'クラスをそうにゅう',
'gui.autoCorrectModal.title': 'じどうちかんせってい',
'gui.autoCorrectModal.fullwidthNumbers': 'ぜんかくすうじ → はんかくすうじ',
'gui.autoCorrectModal.fullwidthAlpha': 'ぜんかくアルファベット → はんかくアルファベット',
diff --git a/packages/scratch-gui/src/locales/ja.js b/packages/scratch-gui/src/locales/ja.js
index d2833fcb85d..d65b4ab96b8 100644
--- a/packages/scratch-gui/src/locales/ja.js
+++ b/packages/scratch-gui/src/locales/ja.js
@@ -392,6 +392,7 @@ export default {
'gui.rubyToolbar.moreOptions': 'その他のオプション',
'gui.rubyToolbar.autoCorrectSettings': '自動置換設定',
'gui.rubyToolbar.saveRubyScript': 'ルビースクリプトを保存',
+ 'gui.rubyToolbar.insertClass': 'クラスを挿入',
'gui.autoCorrectModal.title': '自動置換設定',
'gui.autoCorrectModal.fullwidthNumbers': '全角数字 → 半角数字',
'gui.autoCorrectModal.fullwidthAlpha': '全角アルファベット → 半角アルファベット',
diff --git a/packages/scratch-gui/test/unit/lib/insert-class.test.js b/packages/scratch-gui/test/unit/lib/insert-class.test.js
new file mode 100644
index 00000000000..3e5b498ba68
--- /dev/null
+++ b/packages/scratch-gui/test/unit/lib/insert-class.test.js
@@ -0,0 +1,178 @@
+// === Smalruby: This file is Smalruby-specific (unit tests for insert-class utility) ===
+
+import {wrapCurrentCodeWithClass} from '../../../src/lib/insert-class';
+
+// Helper to create a mock target
+const makeTarget = (overrides = {}) => ({
+ isStage: false,
+ x: 0,
+ y: 0,
+ direction: 90,
+ visible: true,
+ size: 100,
+ currentCostume: 0,
+ rotationStyle: 'all around',
+ sprite: {
+ name: 'Sprite1',
+ costumes: [{name: 'costume1'}],
+ sounds: [{name: 'pop'}]
+ },
+ runtime: {
+ targets: [
+ {isStage: true},
+ // will be replaced with the target itself
+ ]
+ },
+ ...overrides
+});
+
+const makeStageTarget = (overrides = {}) => ({
+ isStage: true,
+ currentCostume: 0,
+ sprite: {
+ name: 'Stage',
+ costumes: [{name: 'backdrop1'}],
+ sounds: [{name: 'pop'}]
+ },
+ runtime: {
+ targets: [{isStage: true}]
+ },
+ ...overrides
+});
+
+describe('wrapCurrentCodeWithClass', () => {
+ test('returns null if code already contains a class definition', () => {
+ const code = 'class Cat\n def setup\n end\nend\n';
+ const target = makeTarget();
+ expect(wrapCurrentCodeWithClass(code, target)).toBeNull();
+ });
+
+ test('returns null for class with inheritance', () => {
+ const code = 'class Cat < Animal\nend\n';
+ const target = makeTarget();
+ expect(wrapCurrentCodeWithClass(code, target)).toBeNull();
+ });
+
+ test('wraps simple code with class', () => {
+ const code = 'self.when(:flag_clicked) do\n move(10)\nend';
+ const target = makeTarget();
+ target.runtime.targets.push(target);
+ const result = wrapCurrentCodeWithClass(code, target);
+ expect(result).toContain('class Sprite1');
+ expect(result).toContain(' self.when(:flag_clicked) do');
+ expect(result).toContain(' move(10)');
+ expect(result).toContain(' end');
+ expect(result).toMatch(/^class Sprite1 < ::Smalruby3::Sprite\n/);
+ expect(result).toMatch(/\nend\n$/);
+ });
+
+ test('wraps empty code with class (no body)', () => {
+ const code = '';
+ const target = makeTarget();
+ target.runtime.targets.push(target);
+ const result = wrapCurrentCodeWithClass(code, target);
+ expect(result).toBe('class Sprite1 < ::Smalruby3::Sprite\nend\n');
+ });
+
+ test('generates set_x when x is non-default', () => {
+ const target = makeTarget({x: 100});
+ target.runtime.targets.push(target);
+ const result = wrapCurrentCodeWithClass('', target);
+ expect(result).toContain(' set_x 100');
+ });
+
+ test('generates set_y when y is non-default', () => {
+ const target = makeTarget({y: -50});
+ target.runtime.targets.push(target);
+ const result = wrapCurrentCodeWithClass('', target);
+ expect(result).toContain(' set_y -50');
+ });
+
+ test('generates set_direction when direction is non-default', () => {
+ const target = makeTarget({direction: 45});
+ target.runtime.targets.push(target);
+ const result = wrapCurrentCodeWithClass('', target);
+ expect(result).toContain(' set_direction 45');
+ });
+
+ test('generates set_visible when not visible', () => {
+ const target = makeTarget({visible: false});
+ target.runtime.targets.push(target);
+ const result = wrapCurrentCodeWithClass('', target);
+ expect(result).toContain(' set_visible false');
+ });
+
+ test('generates set_size when size is non-default', () => {
+ const target = makeTarget({size: 50});
+ target.runtime.targets.push(target);
+ const result = wrapCurrentCodeWithClass('', target);
+ expect(result).toContain(' set_size 50');
+ });
+
+ test('generates set_current_costume when costume is non-default', () => {
+ const target = makeTarget({currentCostume: 2});
+ target.runtime.targets.push(target);
+ const result = wrapCurrentCodeWithClass('', target);
+ expect(result).toContain(' set_current_costume 3');
+ });
+
+ test('generates set_rotation_style when non-default', () => {
+ const target = makeTarget({rotationStyle: 'left-right'});
+ target.runtime.targets.push(target);
+ const result = wrapCurrentCodeWithClass('', target);
+ expect(result).toContain(' set_rotation_style "left-right"');
+ });
+
+ test('uses sprite name as class name when valid', () => {
+ const target = makeTarget();
+ target.sprite.name = 'Cat';
+ target.runtime.targets.push(target);
+ const result = wrapCurrentCodeWithClass('', target);
+ expect(result).toContain('class Cat');
+ });
+
+ test('uses Sprite when name is not a valid class name', () => {
+ const target = makeTarget();
+ target.sprite.name = 'my cat';
+ target.runtime.targets.push(target);
+ const result = wrapCurrentCodeWithClass('', target);
+ expect(result).toContain('class Sprite1');
+ expect(result).toContain(' set_name "my cat"');
+ });
+
+ test('comments out non-hat/non-def top-level code', () => {
+ const code = 'x = 1\n\nself.when(:flag_clicked) do\n move(10)\nend';
+ const target = makeTarget();
+ target.runtime.targets.push(target);
+ const result = wrapCurrentCodeWithClass(code, target);
+ expect(result).toContain('# x = 1');
+ expect(result).toContain(' self.when(:flag_clicked) do');
+ });
+
+ test('handles Stage target', () => {
+ const target = makeStageTarget();
+ const result = wrapCurrentCodeWithClass('', target);
+ expect(result).toContain('class Stage');
+ });
+
+ test('generates set_current_backdrop for Stage', () => {
+ const target = makeStageTarget({currentCostume: 1});
+ const result = wrapCurrentCodeWithClass('', target);
+ expect(result).toContain(' set_current_backdrop 2');
+ });
+
+ test('does not match class in comments or strings', () => {
+ const code = '# class Foo\nself.when(:flag_clicked) do\nend';
+ const target = makeTarget();
+ target.runtime.targets.push(target);
+ // A comment containing "class" should NOT be treated as existing class
+ const result = wrapCurrentCodeWithClass(code, target);
+ expect(result).not.toBeNull();
+ });
+
+ test('does match actual class definition even with leading whitespace', () => {
+ const code = ' class Foo\n end';
+ const target = makeTarget();
+ expect(wrapCurrentCodeWithClass(code, target)).toBeNull();
+ });
+});